android: a name of your own for anyone on the network
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.
This commit is contained in:
1 parent
f792d7955d
commit
7eae0ee945
10 files changed
+211
-16
No files matched your search
+14
@@ -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/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/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/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 |
|
| `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/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) |
|
| `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
|
them at once cost an audio dropout. The phone walk browses the first peer
|
||||||
of its network search.
|
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
|
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
|
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,
|
row of the queue face opens the track's sheet with Play now, Move to next,
|
||||||
|
|||||||
@@ -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 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 PEOPLE = ["ada", "bo", "cy", "dee"];
|
||||||
const LINES = ["anyone got the 2024 remaster?", "the vinyl rip is warmer imo", "that folder is complete btw", "brb"];
|
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 = [
|
const PEERS = [
|
||||||
{ username: "hifi_hank", slot: true, speed: 2400, queue: 0, ext: "flac", depth: 24, rate: 96000 },
|
{ 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 },
|
{ 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_list") return Promise.resolve(["ada", "cy"]);
|
||||||
if (cmd === "buddy_add") return Promise.resolve(["ada", "cy", args?.username].filter(Boolean));
|
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 === "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") {
|
if (cmd === "slsk_room_list") {
|
||||||
later(200, () => mockEmit("soulseek:room-list", ROOMS.map(([name, users]) => ({ name, users }))));
|
later(200, () => mockEmit("soulseek:room-list", ROOMS.map(([name, users]) => ({ name, users }))));
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import { lib, albumById, albumsBy, tracksOf, loadLibrary, refreshPlaylists, refreshRecents } from "./lib.svelte.js";
|
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 { lyr, nudge, searchCandidates, closeCandidates, chooseCandidate, forgetLyrics, pasteLyrics } from "./lyrics.svelte.js";
|
||||||
import { art, wantArt } from "./art.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 { downloadsAllowed } from "./network.svelte.js";
|
||||||
import { note } from "./notice.svelte.js";
|
import { note } from "./notice.svelte.js";
|
||||||
import { fmtOf, albumFmt, fmtBytes, basename, dirname, longDur, mmss, titleFromName } from "./util.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}`);
|
note(`Saving ${g.files.length} file${g.files.length === 1 ? "" : "s"} from ${g.username}`);
|
||||||
} catch (e) { oops(e); }
|
} 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 */
|
/* the desktop's per-record cover refresh; the thumbnail is asked for again */
|
||||||
async function refetchCover(album) {
|
async function refetchCover(album) {
|
||||||
try {
|
try {
|
||||||
@@ -501,9 +512,10 @@
|
|||||||
{@const u = shown.arg.username}
|
{@const u = shown.arg.username}
|
||||||
{@const g = shown.arg.group || null}
|
{@const g = shown.arg.group || null}
|
||||||
{@const buddy = slsk.buddies.includes(u)}
|
{@const buddy = slsk.buddies.includes(u)}
|
||||||
|
{@const m = metaOf(u)}
|
||||||
<div class="sheet-head">
|
<div class="sheet-head">
|
||||||
<span class="art idc"><Identicon seed={u} size={44} /></span>
|
<span class="art idc"><Identicon seed={u} size={44} /></span>
|
||||||
<span class="b"><span class="t">{u}</span><span class="m">{g ? (g.slot ? "slot free" : "queue " + g.queue) + " · " + speedLabel(g.speed) + " · " + g.folderName : "on Soulseek"}</span></span>
|
<span class="b"><span class="t">{display(u)}{#if m.favorite}<span class="nmark fav"><Icon name="star" size={12} /></span>{/if}{#if m.trusted}<span class="nmark tr"><Icon name="shield" size={12} /></span>{/if}</span><span class="m">{[realName(u), m.group, g ? (g.slot ? "slot free" : "queue " + g.queue) + " · " + speedLabel(g.speed) + " · " + g.folderName : "on Soulseek"].filter(Boolean).join(" · ")}</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sheet-rows">
|
<div class="sheet-rows">
|
||||||
{#if g}
|
{#if g}
|
||||||
@@ -519,6 +531,35 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{@render row("message", "Message", "", act(() => { openThread(u); push("thread", { username: u }); }))}
|
{@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(buddy ? "x" : "heart", buddy ? "Remove buddy" : "Add buddy", buddy ? "" : "buddies stay on the Community screen", act(() => (buddy ? removeBuddy : addBuddy)(u).catch(oops)))}
|
||||||
|
<div class="sheet-hair"></div>
|
||||||
|
{@render row("pencil", "Name and notes", "a petname, a group, a note — this phone only", () => openHalf("contact", { username: u }))}
|
||||||
|
</div>
|
||||||
|
{:else if kind === "contact" && shown?.arg?.username}
|
||||||
|
{@const u = shown.arg.username}
|
||||||
|
{@const m = metaOf(u)}
|
||||||
|
<div class="sheet-head">
|
||||||
|
<span class="art idc"><Identicon seed={u} size={44} /></span>
|
||||||
|
<span class="b"><span class="t">{display(u)}</span><span class="m">{realName(u) ? realName(u) + " · " : ""}kept on this phone, published nowhere</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="sheet-rows">
|
||||||
|
{@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))}
|
||||||
|
<div class="sheet-hair"></div>
|
||||||
|
<button class="row" onclick={() => setMeta(u, { favorite: !m.favorite })}>
|
||||||
|
<span class="lead"><Icon name="star" size={20} /></span>
|
||||||
|
<span class="b"><span class="t">Favorite</span><span class="m">kept at the top of the buddy list</span></span>
|
||||||
|
<span class="sw" class:on={m.favorite}><span></span></span>
|
||||||
|
</button>
|
||||||
|
<button class="row" onclick={() => setMeta(u, { trusted: !m.trusted })}>
|
||||||
|
<span class="lead"><Icon name="shield" size={20} /></span>
|
||||||
|
<span class="b"><span class="t">Trusted</span><span class="m">someone you take at their word</span></span>
|
||||||
|
<span class="sw" class:on={m.trusted}><span></span></span>
|
||||||
|
</button>
|
||||||
|
{#if m.petname || m.group || m.note || m.trusted || m.favorite}
|
||||||
|
<div class="sheet-hair"></div>
|
||||||
|
{@render row("x", "Clear these details", "everything above leaves the list", act(() => { void clearMeta(u); }))}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if (kind === "catalogAlbum-more" || kind === "catalogAlbum") && catPage}
|
{:else if (kind === "catalogAlbum-more" || kind === "catalogAlbum") && catPage}
|
||||||
{@const al = catPage.album}
|
{@const al = catPage.album}
|
||||||
@@ -634,7 +675,7 @@
|
|||||||
{@const buddy = slsk.buddies.includes(u.username)}
|
{@const buddy = slsk.buddies.includes(u.username)}
|
||||||
<button class="row" onclick={act(() => { openThread(u.username); push("thread", { username: u.username }); })} oncontextmenu={(e) => { e.preventDefault(); openHalf("peer", { username: u.username }); }}>
|
<button class="row" onclick={act(() => { openThread(u.username); push("thread", { username: u.username }); })} oncontextmenu={(e) => { e.preventDefault(); openHalf("peer", { username: u.username }); }}>
|
||||||
<span class="lead"><span class="dot" class:off={u.status === 0} class:warn={u.status === 1}></span></span>
|
<span class="lead"><span class="dot" class:off={u.status === 0} class:warn={u.status === 1}></span></span>
|
||||||
<span class="b"><span class="t">{u.username}{#if buddy} <span class="tag hi">BUDDY</span>{/if}</span><span class="m">{u.file_count ? u.file_count.toLocaleString() + " files" : ""}</span></span>
|
<span class="b"><span class="t">{display(u.username)}{#if buddy} <span class="tag hi">BUDDY</span>{/if}</span><span class="m">{[realName(u.username), u.file_count ? u.file_count.toLocaleString() + " files" : ""].filter(Boolean).join(" · ")}</span></span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
import { importer, bindImporter } from "./importer.svelte.js";
|
import { importer, bindImporter } from "./importer.svelte.js";
|
||||||
import { bindShare } from "./share.svelte.js";
|
import { bindShare } from "./share.svelte.js";
|
||||||
import { downloadsAllowed, cellular } from "./network.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 * as sources from "../lib/sources.js";
|
||||||
import { greeting, drag } from "./util.js";
|
import { greeting, drag } from "./util.js";
|
||||||
|
|
||||||
@@ -68,7 +69,7 @@
|
|||||||
case "library": return { title: "Your library", kicker: "Library" };
|
case "library": return { title: "Your library", kicker: "Library" };
|
||||||
case "community": return { title: "Community", kicker: slsk.connection === "online" ? "online as " + slsk.username : "Soulseek" };
|
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 "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 "you": return { title: "You", kicker: "You" };
|
||||||
case "transfers": return { title: "Transfers", kicker: "Downloads" };
|
case "transfers": return { title: "Transfers", kicker: "Downloads" };
|
||||||
case "import": return { title: "Import", kicker: importer.draft?.source_name || "Playlist" };
|
case "import": return { title: "Import", kicker: importer.draft?.source_name || "Playlist" };
|
||||||
@@ -188,13 +189,14 @@
|
|||||||
bindHome();
|
bindHome();
|
||||||
bindImporter();
|
bindImporter();
|
||||||
bindShare();
|
bindShare();
|
||||||
|
loadContacts();
|
||||||
/* the desktop's library art prewarm: every cover cut to the cards'
|
/* 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
|
edge, one decode at a time behind the tiles' gate, a moment after
|
||||||
boot so the first screen and a restored track come first */
|
boot so the first screen and a restored track come first */
|
||||||
setTimeout(() => { api.libraryArtPrewarm([360]).catch(() => {}); }, 8000);
|
setTimeout(() => { api.libraryArtPrewarm([360]).catch(() => {}); }, 8000);
|
||||||
/* the probe handle — the desktop has __soulstream for the same reason:
|
/* the probe handle — the desktop has __soulstream for the same reason:
|
||||||
tests/mobile/smoke.mjs drives the real app through it */
|
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(); };
|
return () => { if (unlisten) unlisten(); };
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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 });
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
import Identicon from "../../components/Identicon.svelte";
|
import Identicon from "../../components/Identicon.svelte";
|
||||||
import { slsk, connect, connectStored, disconnect, refreshRooms, joinRoom, leaveRoom, openThread, unreadTotal } from "../slsk.svelte.js";
|
import { slsk, connect, connectStored, disconnect, refreshRooms, joinRoom, leaveRoom, openThread, unreadTotal } from "../slsk.svelte.js";
|
||||||
import { push, patch, openHalf } from "../nav.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 { player } from "../player.svelte.js";
|
||||||
import { ago } from "../util.js";
|
import { ago } from "../util.js";
|
||||||
|
|
||||||
@@ -36,7 +37,12 @@
|
|||||||
.map(([u, c]) => ({ u, c, last: c.messages[c.messages.length - 1] }))
|
.map(([u, c]) => ({ u, c, last: c.messages[c.messages.length - 1] }))
|
||||||
.sort((a, b) => (b.last?.ts || 0) - (a.last?.ts || 0)),
|
.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 */
|
/* the hub's browse-by-name: a typed name opens the peer sheet */
|
||||||
function lookUp() {
|
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 }); } });
|
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 @@
|
|||||||
<button class="row" onclick={() => thread(t.u)}>
|
<button class="row" onclick={() => thread(t.u)}>
|
||||||
<span class="art round idc"><Identicon seed={t.u} size={46} /></span>
|
<span class="art round idc"><Identicon seed={t.u} size={46} /></span>
|
||||||
<span class="b">
|
<span class="b">
|
||||||
<span class="t">{t.u}{#if t.c.unread} <span class="unread">{t.c.unread}</span>{/if}</span>
|
<span class="t">{display(t.u)}{#if t.c.unread} <span class="unread">{t.c.unread}</span>{/if}</span>
|
||||||
<span class="m">{t.last ? (t.last.me ? "you: " : "") + t.last.text : ""}</span>
|
<span class="m">{realName(t.u) ? realName(t.u) + " · " : ""}{t.last ? (t.last.me ? "you: " : "") + t.last.text : ""}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="r">{t.last ? ago(t.last.ts / 1000) : ""}</span>
|
<span class="r">{t.last ? ago(t.last.ts / 1000) : ""}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -173,11 +179,11 @@
|
|||||||
<div class="stub"><b>No buddies on this account yet.</b>Hold a name in a room to add one, or look one up above.</div>
|
<div class="stub"><b>No buddies on this account yet.</b>Hold a name in a room to add one, or look one up above.</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#each buddies as b (b.u)}
|
{#each buddies as b (b.u)}
|
||||||
<button class="row" onclick={() => thread(b.u)} oncontextmenu={(e) => { e.preventDefault(); openHalf("peer", { username: b.u }); }}>
|
<button class="row bud" onclick={() => thread(b.u)} oncontextmenu={(e) => { e.preventDefault(); openHalf("peer", { username: b.u }); }}>
|
||||||
<span class="art round idc"><Identicon seed={b.u} size={46} /></span>
|
<span class="art round idc"><Identicon seed={b.u} size={46} /></span>
|
||||||
<span class="b">
|
<span class="b">
|
||||||
<span class="t">{b.u}</span>
|
<span class="t">{display(b.u)}{#if b.m.favorite}<span class="nmark fav"><Icon name="star" size={12} /></span>{/if}{#if b.m.trusted}<span class="nmark tr"><Icon name="shield" size={12} /></span>{/if}</span>
|
||||||
<span class="m">{status(b.st)}{b.st.file_count ? ` · ${b.st.file_count.toLocaleString()} files` : ""}</span>
|
<span class="m">{[realName(b.u), status(b.st), b.m.group, b.st.file_count ? `${b.st.file_count.toLocaleString()} files` : ""].filter(Boolean).join(" · ")}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="r"><span class="dot" class:off={status(b.st) === "offline"} class:warn={status(b.st) === "away"}></span></span>
|
<span class="r"><span class="dot" class:off={status(b.st) === "offline"} class:warn={status(b.st) === "away"}></span></span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
import Icon from "../../components/Icon.svelte";
|
import Icon from "../../components/Icon.svelte";
|
||||||
import { slsk, sayRoom, leaveRoom, openThread, addBuddy } from "../slsk.svelte.js";
|
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 { push, back, openHalf } from "../nav.svelte.js";
|
||||||
import { player } from "../player.svelte.js";
|
import { player } from "../player.svelte.js";
|
||||||
import { clock } from "../util.js";
|
import { clock } from "../util.js";
|
||||||
@@ -52,7 +53,7 @@
|
|||||||
{#each lines as l, i (i)}
|
{#each lines as l, i (i)}
|
||||||
{@const me = l.username === slsk.username}
|
{@const me = l.username === slsk.username}
|
||||||
<div class="msg" class:me>
|
<div class="msg" class:me>
|
||||||
{#if !me}<button class="who" onclick={() => person(l.username)}>{l.username}</button>{/if}
|
{#if !me}<button class="who" onclick={() => person(l.username)}>{display(l.username)}{#if realName(l.username)}<span class="real">{realName(l.username)}</span>{/if}</button>{/if}
|
||||||
<div class="bub">{l.message}</div>
|
<div class="bub">{l.message}</div>
|
||||||
<div class="at">{clock(l.ts)}</div>
|
<div class="at">{clock(l.ts)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import Icon from "../../components/Icon.svelte";
|
import Icon from "../../components/Icon.svelte";
|
||||||
import Identicon from "../../components/Identicon.svelte";
|
import Identicon from "../../components/Identicon.svelte";
|
||||||
import { slsk, sendDm, openThread } from "../slsk.svelte.js";
|
import { slsk, sendDm, openThread } from "../slsk.svelte.js";
|
||||||
|
import { display, realName } from "../contacts.svelte.js";
|
||||||
import { player } from "../player.svelte.js";
|
import { player } from "../player.svelte.js";
|
||||||
import { clock } from "../util.js";
|
import { clock } from "../util.js";
|
||||||
|
|
||||||
@@ -36,11 +37,11 @@
|
|||||||
|
|
||||||
<div class="room-head">
|
<div class="room-head">
|
||||||
<span class="art round idc"><Identicon seed={username} size={40} /></span>
|
<span class="art round idc"><Identicon seed={username} size={40} /></span>
|
||||||
<span class="b"><span class="t-sub">{st.status === 2 || st.status === "online" ? "online" : st.status === 1 || st.status === "away" ? "away" : "on Soulseek"}{st.file_count ? ` · ${st.file_count.toLocaleString()} files` : ""}</span></span>
|
<span class="b"><span class="t-sub">{[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(" · ")}</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat">
|
<div class="chat">
|
||||||
{#if !lines.length}
|
{#if !lines.length}
|
||||||
<div class="sec-note" style="padding: 20px 0">Say something to {username}.</div>
|
<div class="sec-note" style="padding: 20px 0">Say something to {display(username)}.</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#each lines as l, i (i)}
|
{#each lines as l, i (i)}
|
||||||
<div class="msg" class:me={l.me}>
|
<div class="msg" class:me={l.me}>
|
||||||
@@ -50,7 +51,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="composer">
|
<div class="composer">
|
||||||
<input class="field" bind:this={inputEl} bind:value={text} placeholder="Message {username}…" autocomplete="off" enterkeyhint="send" onkeydown={(e) => { if (e.key === "Enter") { e.preventDefault(); send(); } }} />
|
<input class="field" bind:this={inputEl} bind:value={text} placeholder="Message {display(username)}…" autocomplete="off" enterkeyhint="send" onkeydown={(e) => { if (e.key === "Enter") { e.preventDefault(); send(); } }} />
|
||||||
<button class="disc sm" onclick={send} aria-label="Send" disabled={!text.trim()}><Icon name="send" size={16} /></button>
|
<button class="disc sm" onclick={send} aria-label="Send" disabled={!text.trim()}><Icon name="send" size={16} /></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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 */
|
/* Community: the room filter sits with the section, not the page's own field */
|
||||||
.sfield.roomfilter { margin-top: 2px; }
|
.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 */
|
/* 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); }
|
.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); }
|
||||||
|
|
||||||
|
|||||||
+41
-2
@@ -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; } })(),
|
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,
|
cardImgs: document.querySelectorAll('.rail .acard img').length,
|
||||||
cardPhs: document.querySelectorAll('.rail .acard svg').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 */
|
/* a buddy's sheet: browse, message, remove */
|
||||||
{ do: `__click('.segtabs button', 'Buddies')`, name: "buddies", wait: 600 },
|
{ 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 },
|
{ 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 },
|
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
|
||||||
/* a user looked up by name: the peer sheet */
|
/* a user looked up by name: the peer sheet */
|
||||||
{ do: `__click('.screen .row', 'Look up a user')`, wait: 600 },
|
{ 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 },
|
{ do: `__click('.screen .btn', 'more rooms')`, name: "rooms-more", wait: 500 },
|
||||||
/* the fake network: a room with a few lines in it */
|
/* the fake network: a room with a few lines in it */
|
||||||
{ do: `__click('.screen button.row', 'Lounge')`, name: "room", wait: 2400 },
|
{ 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 },
|
{ 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,
|
/* a search answered by five peers; the best one's first file streams,
|
||||||
the lane rising as the bytes arrive */
|
the lane rising as the bytes arrive */
|
||||||
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 600 },
|
{ do: `document.querySelectorAll('.nav button')[1].click()`, wait: 600 },
|
||||||
@@ -425,8 +451,18 @@ const MOCK_EXPECT = {
|
|||||||
tracks: (i) => i.title.startsWith("Your library"),
|
tracks: (i) => i.title.startsWith("Your library"),
|
||||||
community: (i) => i.title.startsWith("Community"),
|
community: (i) => i.title.startsWith("Community"),
|
||||||
buddies: (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),
|
"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_crates/.test(i.halfText) && /Browse their files/.test(i.halfText) && /Add buddy/.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"),
|
"home-more": (i) => i.half.includes("up"),
|
||||||
you: (i) => i.title.startsWith("You") && i.norm === "on" && i.folderRow && i.srcMax === "No limit",
|
you: (i) => i.title.startsWith("You") && i.norm === "on" && i.folderRow && i.srcMax === "No limit",
|
||||||
"you-norm-off": (i) => i.norm === "off",
|
"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: `__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')[2].click()`, wait: 700 },
|
||||||
{ do: `document.querySelectorAll('.nav button')[3].click()`, name: "community", wait: 900 },
|
{ 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('.nav button')[2].click()`, wait: 600 },
|
||||||
{ do: `document.querySelectorAll('.segtabs button')[1].click()`, wait: 600 },
|
{ do: `document.querySelectorAll('.segtabs button')[1].click()`, wait: 600 },
|
||||||
{ do: `__click('.screen button.row', null)`, name: "artist-cold", wait: 400 },
|
{ do: `__click('.screen button.row', null)`, name: "artist-cold", wait: 400 },
|
||||||
@@ -678,6 +716,7 @@ const PHONE_EXPECT = {
|
|||||||
search: (i) => i.title.startsWith("Search"),
|
search: (i) => i.title.startsWith("Search"),
|
||||||
"hold-sheet": (i) => i.half.includes("up"),
|
"hold-sheet": (i) => i.half.includes("up"),
|
||||||
tracks: (i) => i.title.startsWith("Your library"),
|
tracks: (i) => i.title.startsWith("Your library"),
|
||||||
|
"contacts-read": (i) => i.contactsLoaded === "yes" && !i.toast,
|
||||||
"artist-cold": (i) => i.screen.includes("has-hero"),
|
"artist-cold": (i) => i.screen.includes("has-hero"),
|
||||||
you: (i) => i.title.startsWith("You") && !!i.norm && !!i.srcMax,
|
you: (i) => i.title.startsWith("You") && !!i.norm && !!i.srcMax,
|
||||||
"you-norm-off": (i) => i.norm === "off" && !i.toast,
|
"you-norm-off": (i) => i.norm === "off" && !i.toast,
|
||||||
|
|||||||
Reference in new issue
Block a user