From 7eae0ee9459c640f5c540d3a87cf9411ed78422e Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:40:17 -0500 Subject: [PATCH] android: a name of your own for anyone on the network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop's local contact metadata on the phone: a peer's sheet ends in Name and notes — a petname, a group, a private note and the Favorite and Trusted marks, kept in the app's own config and published nowhere. The petname stands in for the username on the buddy list, in the chat list, on a thread's bar, in the peer sheet, in a room's roster and on a room line, and the real username stays with it — on the line beneath, or beside the name in a room — so a stranger is never quietly turned into someone you know. Favourites sit at the top of the buddy list, and clearing a person's details drops the entry rather than storing an empty one — the backend's own rule. --- ANDROID.md | 14 ++++++ src/lib/mockLibrary.js | 13 ++++++ src/mobile/HalfSheet.svelte | 45 ++++++++++++++++++- src/mobile/Mobile.svelte | 6 ++- src/mobile/contacts.svelte.js | 70 +++++++++++++++++++++++++++++ src/mobile/screens/Community.svelte | 18 +++++--- src/mobile/screens/Room.svelte | 3 +- src/mobile/screens/Thread.svelte | 7 +-- src/mobile/shell.css | 8 ++++ tests/mobile/smoke.mjs | 43 +++++++++++++++++- 10 files changed, 211 insertions(+), 16 deletions(-) create mode 100644 src/mobile/contacts.svelte.js diff --git a/ANDROID.md b/ANDROID.md index 127e8a6..ad2a1e5 100644 --- a/ANDROID.md +++ b/ANDROID.md @@ -19,6 +19,7 @@ implements is `research/mobile-mockup/` (gitignored; see its README). | `src/mobile/catalog.svelte.js` | the catalog lookups, cached per session: an artist by name (the desktop's most-followed-exact-match rule), a catalog record by id | | `src/mobile/media.svelte.js` | the lock-screen card's wiring: mirrors the bar into the media session plugin on every transport change (plus a top-up every 30 s), carries out the card's, the headset's and audio focus's actions | | `src/mobile/slsk.svelte.js` | the Soulseek hub: the connection, the rooms, the chats, the buddies — the desktop's `slsk` store and events | +| `src/mobile/contacts.svelte.js` | the local view of a person on the network: a petname, a group, a note and the two marks (`contacts_meta_list` / `contact_meta_set`) — this phone only, published nowhere | | `crates/tauri-plugin-media-session/` | a Tauri plugin with a Kotlin foreground service (`MediaService`) that owns the `MediaSession`, the MediaStyle notification, audio focus and the becoming-noisy pause. The audio never passes through it; it is what keeps the process alive in the background and what the lock screen shows | | `src/mobile/Sheet.svelte`, `HalfSheet.svelte`, `Lane.svelte`, `Cover.svelte`, `Release.svelte` | the player (art / lyrics / queue faces, container transform from the mini bar, drags), the bottom sheets (track, record, sort, the page overflow, the sleep timer), the waveform lane, the two card pieces (a long press on a card is its sheet) | | `src/mobile/screens/` | Home, Search (the library half: records and artists here, tracks through `library_track_search`), Library (albums / artists / tracks / playlists), Album, Artist (the library's shell at once, the catalog dissolving in: portrait, Popular, discography with what is here marked), CatalogAlbum (a catalog record, rows the library has play), Playlist, Community (sign in; rooms, chat, buddies — the Soulseek side; the private layer waits), Room, Thread, You (Appearance switches, real) | @@ -193,6 +194,19 @@ filter and renders 80 at a time with a "more" button; rendering all of them at once cost an audio dropout. The phone walk browses the first peer of its network search. +Every name in Community can wear one you gave it. The peer's sheet ends in +**Name and notes** (`src/mobile/contacts.svelte.js`, `contacts_meta_list` / +`contact_meta_set`): a petname, a group, a private note and the two marks, +Favorite and Trusted. All of it sits in the app's own config on this phone +and is published nowhere — Soulseek never learns it, and neither does the +person it describes. The petname stands in for the username on the buddy +list, in the chat list, on a thread's bar, in the peer sheet, in a room's +roster and on a room line, and the real username stays with it — on the +line beneath, or beside the name in a room — so a stranger is never quietly +turned into someone you know. Favourites sit at the top of the buddy list, +a star and a shield mark the two, and clearing a person's details drops the +entry rather than storing an empty one — the backend's own rule. + More long presses: a catalog song in the search results opens the catalog track's sheet (play, save, add to a playlist, the record, the artist); a row of the queue face opens the track's sheet with Play now, Move to next, diff --git a/src/lib/mockLibrary.js b/src/lib/mockLibrary.js index a779a0c..bc44ad0 100644 --- a/src/lib/mockLibrary.js +++ b/src/lib/mockLibrary.js @@ -381,6 +381,8 @@ const MOCK_USER = "mockuser"; const ROOMS = [["Lounge", 412], ["Electronic", 98], ["Vinyl Rips", 37], ["Jazz", 61], ["Lossless Only", 23], ...Array.from({ length: 90 }, (_, i) => ["Room " + (i + 1), 90 - i])]; const PEOPLE = ["ada", "bo", "cy", "dee"]; const LINES = ["anyone got the 2024 remaster?", "the vinyl rip is warmer imo", "that folder is complete btw", "brb"]; +/* one person already named, so a cold read of the list has something in it */ +const CONTACTS = [{ username: "dusty_crates", petname: "Dusty the digger", group: "Traders", note: "always has the vinyl rips", trusted: true, favorite: false }]; const PEERS = [ { username: "hifi_hank", slot: true, speed: 2400, queue: 0, ext: "flac", depth: 24, rate: 96000 }, { username: "dusty_crates", slot: true, speed: 900, queue: 0, ext: "flac", depth: 16, rate: 44100 }, @@ -579,6 +581,17 @@ function mockNet(cmd, args) { if (cmd === "buddy_list") return Promise.resolve(["ada", "cy"]); if (cmd === "buddy_add") return Promise.resolve(["ada", "cy", args?.username].filter(Boolean)); if (cmd === "buddy_remove") return Promise.resolve(["ada", "cy"].filter((u) => u !== args?.username)); + if (cmd === "contacts_meta_list") return Promise.resolve(CONTACTS.slice()); + if (cmd === "contact_meta_set") { + /* the backend's own rule: an entry with nothing set is dropped */ + const meta = args?.meta || {}; + const txt = (v) => (typeof v === "string" && v.trim() ? v.trim() : null); + const i = CONTACTS.findIndex((c) => c.username === meta.username); + if (i >= 0) CONTACTS.splice(i, 1); + const kept = { username: meta.username, petname: txt(meta.petname), group: txt(meta.group), note: txt(meta.note), trusted: !!meta.trusted, favorite: !!meta.favorite }; + if (kept.petname || kept.group || kept.note || kept.trusted || kept.favorite) CONTACTS.push(kept); + return Promise.resolve(CONTACTS.slice()); + } if (cmd === "slsk_room_list") { later(200, () => mockEmit("soulseek:room-list", ROOMS.map(([name, users]) => ({ name, users })))); return Promise.resolve(); diff --git a/src/mobile/HalfSheet.svelte b/src/mobile/HalfSheet.svelte index 722021e..db92ea3 100644 --- a/src/mobile/HalfSheet.svelte +++ b/src/mobile/HalfSheet.svelte @@ -18,6 +18,7 @@ import { lib, albumById, albumsBy, tracksOf, loadLibrary, refreshPlaylists, refreshRecents } from "./lib.svelte.js"; import { lyr, nudge, searchCandidates, closeCandidates, chooseCandidate, forgetLyrics, pasteLyrics } from "./lyrics.svelte.js"; import { art, wantArt } from "./art.svelte.js"; + import { metaOf, display, realName, setMeta, clearMeta } from "./contacts.svelte.js"; import { downloadsAllowed } from "./network.svelte.js"; import { note } from "./notice.svelte.js"; import { fmtOf, albumFmt, fmtBytes, basename, dirname, longDur, mmss, titleFromName } from "./util.js"; @@ -208,6 +209,16 @@ note(`Saving ${g.files.length} file${g.files.length === 1 ? "" : "s"} from ${g.username}`); } catch (e) { oops(e); } } + /* the contact editor's three fields go through the prompt sheet. The + username travels as a VALUE, the way the playlist prompts do: the + sheet switches kind under the prompt, so the contact block's + {@const} is a destroyed derived by the time Save lands. */ + function metaPrompt(u, field, title, placeholder, value, multiline = false) { + openHalf("prompt", { + title, value, placeholder, ok: "Save", multiline, allowEmpty: true, + onsubmit: (v) => { void setMeta(u, { [field]: v }); openHalf("contact", { username: u }); }, + }); + } /* the desktop's per-record cover refresh; the thumbnail is asked for again */ async function refetchCover(album) { try { @@ -501,9 +512,10 @@ {@const u = shown.arg.username} {@const g = shown.arg.group || null} {@const buddy = slsk.buddies.includes(u)} + {@const m = metaOf(u)}
- {u}{g ? (g.slot ? "slot free" : "queue " + g.queue) + " · " + speedLabel(g.speed) + " · " + g.folderName : "on Soulseek"} + {display(u)}{#if m.favorite}{/if}{#if m.trusted}{/if}{[realName(u), m.group, g ? (g.slot ? "slot free" : "queue " + g.queue) + " · " + speedLabel(g.speed) + " · " + g.folderName : "on Soulseek"].filter(Boolean).join(" · ")}
{#if g} @@ -519,6 +531,35 @@ {/if} {@render row("message", "Message", "", act(() => { openThread(u); push("thread", { username: u }); }))} {@render row(buddy ? "x" : "heart", buddy ? "Remove buddy" : "Add buddy", buddy ? "" : "buddies stay on the Community screen", act(() => (buddy ? removeBuddy : addBuddy)(u).catch(oops)))} +
+ {@render row("pencil", "Name and notes", "a petname, a group, a note — this phone only", () => openHalf("contact", { username: u }))} +
+ {:else if kind === "contact" && shown?.arg?.username} + {@const u = shown.arg.username} + {@const m = metaOf(u)} +
+ + {display(u)}{realName(u) ? realName(u) + " · " : ""}kept on this phone, published nowhere +
+
+ {@render row("pencil", "Petname", m.petname || "what you call them here", () => metaPrompt(u, "petname", "Petname", u, m.petname || ""))} + {@render row("tag", "Group", m.group || "a word to sort them by", () => metaPrompt(u, "group", "Group", "Traders", m.group || ""))} + {@render row("file-text", "Note", m.note || "a private note, for you only", () => metaPrompt(u, "note", "Note", "What you want to remember about them", m.note || "", true))} +
+ + + {#if m.petname || m.group || m.note || m.trusted || m.favorite} +
+ {@render row("x", "Clear these details", "everything above leaves the list", act(() => { void clearMeta(u); }))} + {/if}
{:else if (kind === "catalogAlbum-more" || kind === "catalogAlbum") && catPage} {@const al = catPage.album} @@ -634,7 +675,7 @@ {@const buddy = slsk.buddies.includes(u.username)} {/each} diff --git a/src/mobile/Mobile.svelte b/src/mobile/Mobile.svelte index b8b50c0..26d30c4 100644 --- a/src/mobile/Mobile.svelte +++ b/src/mobile/Mobile.svelte @@ -49,6 +49,7 @@ import { importer, bindImporter } from "./importer.svelte.js"; import { bindShare } from "./share.svelte.js"; import { downloadsAllowed, cellular } from "./network.svelte.js"; + import { contacts, loadContacts, display } from "./contacts.svelte.js"; import * as sources from "../lib/sources.js"; import { greeting, drag } from "./util.js"; @@ -68,7 +69,7 @@ case "library": return { title: "Your library", kicker: "Library" }; case "community": return { title: "Community", kicker: slsk.connection === "online" ? "online as " + slsk.username : "Soulseek" }; case "room": return { title: "#" + e.c.name, kicker: "Room", flush: true }; - case "thread": return { title: e.c.username, kicker: "Chat", flush: true }; + case "thread": return { title: display(e.c.username), kicker: "Chat", flush: true }; case "you": return { title: "You", kicker: "You" }; case "transfers": return { title: "Transfers", kicker: "Downloads" }; case "import": return { title: "Import", kicker: importer.draft?.source_name || "Playlist" }; @@ -188,13 +189,14 @@ bindHome(); bindImporter(); bindShare(); + loadContacts(); /* the desktop's library art prewarm: every cover cut to the cards' edge, one decode at a time behind the tiles' gate, a moment after boot so the first screen and a restored track come first */ setTimeout(() => { api.libraryArtPrewarm([360]).catch(() => {}); }, 8000); /* the probe handle — the desktop has __soulstream for the same reason: tests/mobile/smoke.mjs drives the real app through it */ - window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, slsk, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, pls, notice, importer, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch }; + window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, slsk, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, pls, notice, importer, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts }; return () => { if (unlisten) unlisten(); }; }); diff --git a/src/mobile/contacts.svelte.js b/src/mobile/contacts.svelte.js new file mode 100644 index 0000000..7de86a5 --- /dev/null +++ b/src/mobile/contacts.svelte.js @@ -0,0 +1,70 @@ +/* The local view of a person on the network: a petname, a group, a private + note and the two marks. All of it lives in this app's own config and is + published nowhere — Soulseek never learns it, and neither does the person + it describes. The desktop keeps the same index by username + (src/lib/community.svelte.js), and the same rule: the backend drops an + entry with nothing set, so the list it answers with is the one we take. */ +import * as api from "../lib/api.js"; +import { player } from "./player.svelte.js"; + +export const contacts = $state({ + /** username → { username, petname, group, note, trusted, favorite } */ + by: {}, + /** the list came back; false until it does, and after a failure */ + loaded: false, + error: "", +}); + +const BLANK = { petname: null, group: null, note: null, trusted: false, favorite: false }; +/** what the backend stores: a trimmed string, or nothing at all */ +const trimmed = (v) => { const s = typeof v === "string" ? v.trim() : v; return s ? s : null; }; + +function index(list) { + contacts.by = Object.fromEntries((list || []).map((c) => [c.username, c])); + contacts.loaded = true; + contacts.error = ""; +} + +let inflight = null; +/** Read the whole list — once at boot, and again whenever anyone asks. A + * failure is quiet: a petname is an enhancement, not the screen. */ +export function loadContacts() { + if (inflight) return inflight; + inflight = api + .contactsMetaList() + .then(index) + .catch((e) => { contacts.loaded = false; contacts.error = String(e); }) + .finally(() => { inflight = null; }); + return inflight; +} + +export const metaOf = (u) => contacts.by[u] ?? { username: u, ...BLANK }; +/** The name to show: the petname when there is one, the username otherwise. */ +export const display = (u) => contacts.by[u]?.petname || u; +/** The username, but only while a petname stands in front of it — a stranger + * is never quietly turned into someone you know. */ +export const realName = (u) => (contacts.by[u]?.petname ? u : ""); + +/** Upsert one person. The answer is the whole list after the write, so it is + * taken as it comes rather than patched into our own copy: that is how an + * entry with nothing left in it disappears. A failure goes to the bar. */ +export async function setMeta(username, patch) { + const m = { ...metaOf(username), ...patch }; + try { + index( + await api.contactMetaSet({ + username, + petname: trimmed(m.petname), + group: trimmed(m.group), + note: trimmed(m.note), + trusted: !!m.trusted, + favorite: !!m.favorite, + }), + ); + } catch (e) { + player.error = String(e); + } +} + +/** Everything blank: the entry leaves the list rather than being stored empty. */ +export const clearMeta = (u) => setMeta(u, { petname: "", group: "", note: "", trusted: false, favorite: false }); diff --git a/src/mobile/screens/Community.svelte b/src/mobile/screens/Community.svelte index 19c1ee1..bb358ad 100644 --- a/src/mobile/screens/Community.svelte +++ b/src/mobile/screens/Community.svelte @@ -7,6 +7,7 @@ import Identicon from "../../components/Identicon.svelte"; import { slsk, connect, connectStored, disconnect, refreshRooms, joinRoom, leaveRoom, openThread, unreadTotal } from "../slsk.svelte.js"; import { push, patch, openHalf } from "../nav.svelte.js"; + import { metaOf, display, realName } from "../contacts.svelte.js"; import { player } from "../player.svelte.js"; import { ago } from "../util.js"; @@ -36,7 +37,12 @@ .map(([u, c]) => ({ u, c, last: c.messages[c.messages.length - 1] })) .sort((a, b) => (b.last?.ts || 0) - (a.last?.ts || 0)), ); - const buddies = $derived(slsk.buddies.map((u) => ({ u, st: slsk.buddyState[u] || {} }))); + /* favourites at the top, then by the name you gave them */ + const buddies = $derived( + slsk.buddies + .map((u) => ({ u, st: slsk.buddyState[u] || {}, m: metaOf(u) })) + .sort((a, b) => (b.m.favorite ? 1 : 0) - (a.m.favorite ? 1 : 0) || display(a.u).localeCompare(display(b.u))), + ); /* the hub's browse-by-name: a typed name opens the peer sheet */ function lookUp() { openHalf("prompt", { title: "Look up a user", placeholder: "Soulseek username", ok: "Open", onsubmit: (v) => { const u = v.trim(); if (u) openHalf("peer", { username: u }); } }); @@ -157,8 +163,8 @@ @@ -173,11 +179,11 @@
No buddies on this account yet.Hold a name in a room to add one, or look one up above.
{/if} {#each buddies as b (b.u)} - diff --git a/src/mobile/screens/Room.svelte b/src/mobile/screens/Room.svelte index e453369..4a0c474 100644 --- a/src/mobile/screens/Room.svelte +++ b/src/mobile/screens/Room.svelte @@ -5,6 +5,7 @@ import { tick } from "svelte"; import Icon from "../../components/Icon.svelte"; import { slsk, sayRoom, leaveRoom, openThread, addBuddy } from "../slsk.svelte.js"; + import { display, realName } from "../contacts.svelte.js"; import { push, back, openHalf } from "../nav.svelte.js"; import { player } from "../player.svelte.js"; import { clock } from "../util.js"; @@ -52,7 +53,7 @@ {#each lines as l, i (i)} {@const me = l.username === slsk.username}
- {#if !me}{/if} + {#if !me}{/if}
{l.message}
{clock(l.ts)}
diff --git a/src/mobile/screens/Thread.svelte b/src/mobile/screens/Thread.svelte index b145feb..5c61735 100644 --- a/src/mobile/screens/Thread.svelte +++ b/src/mobile/screens/Thread.svelte @@ -4,6 +4,7 @@ import Icon from "../../components/Icon.svelte"; import Identicon from "../../components/Identicon.svelte"; import { slsk, sendDm, openThread } from "../slsk.svelte.js"; + import { display, realName } from "../contacts.svelte.js"; import { player } from "../player.svelte.js"; import { clock } from "../util.js"; @@ -36,11 +37,11 @@
- {st.status === 2 || st.status === "online" ? "online" : st.status === 1 || st.status === "away" ? "away" : "on Soulseek"}{st.file_count ? ` · ${st.file_count.toLocaleString()} files` : ""} + {[realName(username), st.status === 2 || st.status === "online" ? "online" : st.status === 1 || st.status === "away" ? "away" : "on Soulseek", st.file_count ? `${st.file_count.toLocaleString()} files` : ""].filter(Boolean).join(" · ")}
{#if !lines.length} -
Say something to {username}.
+
Say something to {display(username)}.
{/if} {#each lines as l, i (i)}
@@ -50,7 +51,7 @@ {/each}
- { if (e.key === "Enter") { e.preventDefault(); send(); } }} /> + { if (e.key === "Enter") { e.preventDefault(); send(); } }} />
diff --git a/src/mobile/shell.css b/src/mobile/shell.css index 5d2449f..7a7a56e 100644 --- a/src/mobile/shell.css +++ b/src/mobile/shell.css @@ -1369,6 +1369,14 @@ html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion=" /* Community: the room filter sits with the section, not the page's own field */ .sfield.roomfilter { margin-top: 2px; } +/* the two local marks beside a name: a star for a favourite, a shield for + someone trusted. Both are this phone's own; neither is published. */ +.nmark { display: inline-flex; vertical-align: -2px; margin-left: 5px; color: var(--text-muted); } +.nmark.fav { color: #e8a33d; } +.nmark.tr { color: #3ddc84; } +/* a room line whose author wears a petname keeps their own name beside it */ +.msg .who .real { margin-left: 5px; font-weight: 600; opacity: 0.65; } + /* the nav's badges: transfers in flight on You, unread chats on Community */ .nav button .gl .nb { position: absolute; top: -3px; right: -12px; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 999px; background: var(--accent); color: #04121a; font-size: 10px; font-weight: 800; line-height: 16px; text-align: center; box-shadow: 0 0 0 2px var(--nav-bg, #101014); } diff --git a/tests/mobile/smoke.mjs b/tests/mobile/smoke.mjs index 3db129f..7200209 100644 --- a/tests/mobile/smoke.mjs +++ b/tests/mobile/smoke.mjs @@ -160,6 +160,11 @@ const PROBE = `(function () { hwant: (function () { try { var n = __ss.nav; return (n.sheet ? 1 : 0) + (n.half ? 1 : 0) + (n.stacks[n.tab].length - 1) + (n.tab !== 'home' ? 1 : 0); } catch (e) { return -1; } })(), cardImgs: document.querySelectorAll('.rail .acard img').length, cardPhs: document.querySelectorAll('.rail .acard svg').length, + budNames: [].map.call(document.querySelectorAll('.screen .row.bud .t'), function (t) { return t.textContent.trim(); }).join('|'), + budSubs: [].map.call(document.querySelectorAll('.screen .row.bud .m'), function (t) { return t.textContent.trim(); }).join('|'), + halfSub: ((document.querySelector('.halfsheet .sheet-head .m') || {}).textContent || '').trim(), + halfSw: [].filter.call(document.querySelectorAll('.halfsheet .row'), function (r) { return !!r.querySelector('.sw.on'); }).map(function (r) { return ((r.querySelector('.t') || {}).textContent || '').trim(); }).join('|'), + contactsLoaded: (function () { try { return __ss.contacts.loaded ? 'yes' : (__ss.contacts.error ? 'err' : 'no'); } catch (e) { return ''; } })(), }; })()`; @@ -216,6 +221,18 @@ const MOCK_STEPS = [ /* a buddy's sheet: browse, message, remove */ { do: `__click('.segtabs button', 'Buddies')`, name: "buddies", wait: 600 }, { do: `document.querySelectorAll('.screen button.row')[1].dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, name: "buddy-sheet", wait: 600 }, + /* the contact editor behind the peer sheet: a petname, a note, the + favourite mark — then the buddy list wearing all three, ada's own + name still under it and favourites first */ + { do: `__click('.halfsheet .row', 'Name and notes')`, name: "contact-sheet", wait: 600 }, + { do: `__click('.halfsheet .row', 'Petname')`, wait: 500 }, + { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'Zola from Vinyl Rips'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 }, + { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "contact-petname", wait: 800 }, + { do: `__click('.halfsheet .row', 'Note')`, wait: 500 }, + { do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = 'trades vinyl rips, always has the gaps'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 }, + { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "contact-note", wait: 800 }, + { do: `__click('.halfsheet .row', 'Favorite')`, name: "contact-fav", wait: 800 }, + { do: `document.querySelector('.scrim').click()`, name: "buddy-petname", wait: 700 }, { do: `document.querySelector('.scrim').click()`, wait: 500 }, /* a user looked up by name: the peer sheet */ { do: `__click('.screen .row', 'Look up a user')`, wait: 600 }, @@ -229,7 +246,16 @@ const MOCK_STEPS = [ { do: `__click('.screen .btn', 'more rooms')`, name: "rooms-more", wait: 500 }, /* the fake network: a room with a few lines in it */ { do: `__click('.screen button.row', 'Lounge')`, name: "room", wait: 2400 }, + /* the room's roster wears the petname too, the real name beneath it */ + { do: `__click('.room-head .btn', null)`, name: "room-users", wait: 700 }, + { do: `document.querySelector('.scrim').click()`, wait: 500 }, { do: `history.back()`, wait: 700 }, + /* nothing set is nothing stored: cleared, the entry leaves the list and + a name goes back to being a name */ + { do: `__click('.segtabs button', 'Buddies')`, wait: 600 }, + { do: `document.querySelector('.screen .row.bud').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))`, wait: 500 }, + { do: `__click('.halfsheet .row', 'Name and notes')`, wait: 500 }, + { do: `__click('.halfsheet .row', 'Clear these details')`, name: "contact-cleared", wait: 900 }, /* a search answered by five peers; the best one's first file streams, the lane rising as the bytes arrive */ { do: `document.querySelectorAll('.nav button')[1].click()`, wait: 600 }, @@ -425,8 +451,18 @@ const MOCK_EXPECT = { tracks: (i) => i.title.startsWith("Your library"), community: (i) => i.title.startsWith("Community"), buddies: (i) => i.title.startsWith("Community"), - "buddy-sheet": (i) => i.half.includes("up") && /Browse their files/.test(i.halfText) && /Remove buddy/.test(i.halfText), - "user-lookup": (i) => i.half.includes("up") && /dusty_crates/.test(i.halfText) && /Browse their files/.test(i.halfText) && /Add buddy/.test(i.halfText), + "buddy-sheet": (i) => i.half.includes("up") && /Browse their files/.test(i.halfText) && /Remove buddy/.test(i.halfText) && /Name and notes/.test(i.halfText), + "user-lookup": (i) => i.half.includes("up") && /Dusty the digger/.test(i.halfText) && i.halfSub.startsWith("dusty_crates") && /Browse their files/.test(i.halfText) && /Add buddy/.test(i.halfText), + /* the local contact metadata: the editor, the petname reaching the + backend, the mark sticking, the buddy list rearranged — and the + cleared entry dropped rather than stored empty */ + "contact-sheet": (i) => i.half.includes("up") && /Petname/.test(i.halfText) && /Group/.test(i.halfText) && /Note/.test(i.halfText) && /Favorite/.test(i.halfText) && /Trusted/.test(i.halfText) && i.halfSub === "kept on this phone, published nowhere", + "contact-petname": (i) => i.half.includes("up") && /Zola from Vinyl Rips/.test(i.halfText) && i.halfSub.startsWith("ada · "), + "contact-note": (i) => i.half.includes("up") && /trades vinyl rips/.test(i.halfText), + "contact-fav": (i) => i.half.includes("up") && i.halfSw === "Favorite", + "buddy-petname": (i) => i.budNames === "Zola from Vinyl Rips|cy" && /ada/.test(i.budSubs) && !i.toast, + "room-users": (i) => i.half.includes("up") && /Zola from Vinyl Rips/.test(i.halfText) && /ada/.test(i.halfText) && /bo/.test(i.halfText), + "contact-cleared": (i) => !i.half.includes("up") && i.budNames === "ada|cy" && !i.toast, "home-more": (i) => i.half.includes("up"), you: (i) => i.title.startsWith("You") && i.norm === "on" && i.folderRow && i.srcMax === "No limit", "you-norm-off": (i) => i.norm === "off", @@ -653,6 +689,8 @@ const PHONE_STEPS = [ { do: `__click('.prefs[data-mode="albumart"] .chip', '1500 px')`, name: "you-artedge-back", wait: 900 }, { do: `document.querySelectorAll('.nav button')[2].click()`, wait: 700 }, { do: `document.querySelectorAll('.nav button')[3].click()`, name: "community", wait: 900 }, + /* the real config's contact list, read back over the real IPC */ + { do: `__ss.loadContacts()`, name: "contacts-read", wait: 800 }, { do: `document.querySelectorAll('.nav button')[2].click()`, wait: 600 }, { do: `document.querySelectorAll('.segtabs button')[1].click()`, wait: 600 }, { do: `__click('.screen button.row', null)`, name: "artist-cold", wait: 400 }, @@ -678,6 +716,7 @@ const PHONE_EXPECT = { search: (i) => i.title.startsWith("Search"), "hold-sheet": (i) => i.half.includes("up"), tracks: (i) => i.title.startsWith("Your library"), + "contacts-read": (i) => i.contactsLoaded === "yes" && !i.toast, "artist-cold": (i) => i.screen.includes("has-hero"), you: (i) => i.title.startsWith("You") && !!i.norm && !!i.srcMax, "you-norm-off": (i) => i.norm === "off" && !i.toast,