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.
196 lines
11 KiB
Svelte
196 lines
11 KiB
Svelte
<script>
|
|
/* Community — the Soulseek side: rooms, chat, buddies. Signed out it is
|
|
the sign-in card; signed in it is the hub, the desktop's seats drawn
|
|
with the phone's components. The private layer (Spaces, holdings, the
|
|
sealed inbox) waits for the Freenet story on the phone. */
|
|
import Icon from "../../components/Icon.svelte";
|
|
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";
|
|
|
|
let { entry, screenEl = null } = $props();
|
|
|
|
const tab = $derived(entry.c.tab || "rooms");
|
|
let user = $state("");
|
|
let pass = $state("");
|
|
let showPass = $state(false);
|
|
$effect(() => { if (!user && slsk.savedUser) user = slsk.savedUser; });
|
|
|
|
const online = $derived(slsk.connection === "online");
|
|
const joinedNames = $derived(Object.keys(slsk.joined));
|
|
/* the server's rooms: filtered by name, the biggest first, 80 at a
|
|
time with a way to the rest */
|
|
let roomQ = $state("");
|
|
let roomCap = $state(80);
|
|
const roomsMatching = $derived.by(() => {
|
|
const k = roomQ.trim().toLowerCase();
|
|
const list = slsk.rooms.filter((r) => !slsk.joined[r.name]);
|
|
return k ? list.filter((r) => r.name.toLowerCase().includes(k)) : list;
|
|
});
|
|
const otherRooms = $derived(roomsMatching.slice(0, roomCap));
|
|
$effect(() => { void roomQ; roomCap = 80; });
|
|
const threads = $derived(
|
|
Object.entries(slsk.chats)
|
|
.map(([u, c]) => ({ u, c, last: c.messages[c.messages.length - 1] }))
|
|
.sort((a, b) => (b.last?.ts || 0) - (a.last?.ts || 0)),
|
|
);
|
|
/* 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 }); } });
|
|
}
|
|
|
|
async function signIn() {
|
|
if (!user.trim() || !pass) return;
|
|
await connect(user, pass);
|
|
if (slsk.connection !== "offline") pass = "";
|
|
}
|
|
async function open(room) {
|
|
if (!slsk.joined[room]) {
|
|
try { await joinRoom(room); } catch (e) { player.error = String(e); return; }
|
|
}
|
|
push("room", { name: room }, screenEl);
|
|
}
|
|
function thread(username) {
|
|
openThread(username);
|
|
push("thread", { username }, screenEl);
|
|
}
|
|
function status(st) {
|
|
if (st.status === 2 || st.status === "online") return "online";
|
|
if (st.status === 1 || st.status === "away") return "away";
|
|
return "offline";
|
|
}
|
|
</script>
|
|
|
|
<div class="phead">
|
|
<div class="row">
|
|
<h1>Community</h1>
|
|
{#if online}
|
|
<div class="acts">
|
|
<button class="iconbtn" onclick={refreshRooms} aria-label="Refresh rooms"><Icon name="refresh" size={21} /></button>
|
|
<button class="iconbtn muted" onclick={() => openHalf("community")} aria-label="More"><Icon name="more" size={21} /></button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{#if online}
|
|
<div class="online-line"><span class="dot"></span><span class="t-sub" style="font-weight:650">online as <b>{slsk.username}</b> · Soulseek</span></div>
|
|
{:else if slsk.connection === "connecting"}
|
|
<div class="online-line"><span class="dot warn"></span><span class="t-sub" style="font-weight:650">signing in…</span></div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if !online}
|
|
<div class="signin">
|
|
<div class="signin-t">Sign in to Soulseek</div>
|
|
<div class="signin-m">The network's rooms, its people and, next, its music. Your account is kept on this phone only.</div>
|
|
<input class="field" bind:value={user} placeholder="Username" autocomplete="username" autocapitalize="off" spellcheck="false" enterkeyhint="next" />
|
|
<div class="field-row">
|
|
<input class="field" type={showPass ? "text" : "password"} bind:value={pass} placeholder="Password" autocomplete="current-password" enterkeyhint="go" onkeydown={(e) => { if (e.key === "Enter") signIn(); }} />
|
|
<button class="iconbtn sm muted" onclick={() => (showPass = !showPass)} aria-label="Show password"><Icon name="eye" size={18} /></button>
|
|
</div>
|
|
{#if slsk.loginError}<div class="signin-err">{slsk.loginError}</div>{/if}
|
|
<button class="btn btn-fill wide" style="height:46px" disabled={slsk.connection === "connecting" || !user.trim() || !pass} onclick={signIn}>
|
|
{slsk.connection === "connecting" ? "Signing in…" : "Sign in"}
|
|
</button>
|
|
{#if slsk.savedUser && slsk.hasPassword && slsk.connection === "offline"}
|
|
<button class="btn btn-ghost wide" style="margin-top:8px" onclick={connectStored}>Reconnect as {slsk.savedUser}</button>
|
|
{/if}
|
|
<div class="sec-note" style="padding: 12px 0 0">No account? Soulseek makes one from the first sign-in with a new name.</div>
|
|
</div>
|
|
<div class="stub">
|
|
<b>Spaces, holdings and the sealed inbox come later on the phone.</b>
|
|
They live on the private layer; the network side is here first.
|
|
</div>
|
|
{:else}
|
|
<div class="segtabs" style="padding-top:12px">
|
|
{#each [["rooms", "Rooms"], ["chat", "Chat"], ["buddies", "Buddies"]] as [k, l] (k)}
|
|
<button class:on={tab === k} onclick={() => patch({ tab: k })}>
|
|
{l}{#if k === "chat" && unreadTotal()} <span class="count">{unreadTotal()}</span>{/if}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
|
|
{#if tab === "rooms"}
|
|
{#if joinedNames.length}
|
|
<div class="sec" style="padding-top:8px"><h2>Joined <em>· {joinedNames.length}</em></h2></div>
|
|
{#each joinedNames as name (name)}
|
|
{@const r = slsk.joined[name]}
|
|
{@const last = r.messages[r.messages.length - 1]}
|
|
<button class="row" onclick={() => open(name)} oncontextmenu={(e) => { e.preventDefault(); leaveRoom(name); }}>
|
|
<span class="art hash"><Icon name="hash" size={20} /></span>
|
|
<span class="b">
|
|
<span class="t">{name}</span>
|
|
<span class="m">{last ? `${last.username}: ${last.message}` : `${r.users.length} here`}</span>
|
|
</span>
|
|
<span class="r"><span class="n">{r.users.length}</span><Icon name="chevron-right" size={17} /></span>
|
|
</button>
|
|
{/each}
|
|
{/if}
|
|
<div class="sec" style="padding-top:{joinedNames.length ? 18 : 8}px"><h2>Rooms <em>· {slsk.rooms.length} on the server</em></h2></div>
|
|
{#if !slsk.rooms.length}
|
|
<div class="sec-note">Asking the server for its rooms…</div>
|
|
{:else if slsk.rooms.length > 8}
|
|
<div class="sfield roomfilter">
|
|
<Icon name="search" size={18} />
|
|
<input value={roomQ} oninput={(e) => (roomQ = e.currentTarget.value)} type="search" placeholder="Find a room" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Find a room" />
|
|
{#if roomQ}<button class="iconbtn sm" onclick={() => (roomQ = "")} aria-label="Clear"><Icon name="x" size={18} /></button>{/if}
|
|
</div>
|
|
{#if roomQ}<div class="sec-note">{roomsMatching.length} of {slsk.rooms.length} rooms</div>{/if}
|
|
{/if}
|
|
{#each otherRooms as r (r.name)}
|
|
<button class="row" onclick={() => open(r.name)}>
|
|
<span class="art hash"><Icon name="hash" size={20} /></span>
|
|
<span class="b"><span class="t">{r.name}</span><span class="m">{r.users} in the room</span></span>
|
|
<span class="r"><Icon name="plus" size={18} /></span>
|
|
</button>
|
|
{/each}
|
|
{#if roomsMatching.length > roomCap}
|
|
<div style="padding: 4px var(--pad) 8px"><button class="btn btn-ghost wide" onclick={() => (roomCap += 200)}>+ {roomsMatching.length - roomCap} more rooms</button></div>
|
|
{/if}
|
|
{:else if tab === "chat"}
|
|
{#if !threads.length}
|
|
<div class="stub"><b>No conversations yet.</b>A name in a room, or a buddy, opens one.</div>
|
|
{/if}
|
|
{#each threads as t (t.u)}
|
|
<button class="row" onclick={() => thread(t.u)}>
|
|
<span class="art round idc"><Identicon seed={t.u} size={46} /></span>
|
|
<span class="b">
|
|
<span class="t">{display(t.u)}{#if t.c.unread} <span class="unread">{t.c.unread}</span>{/if}</span>
|
|
<span class="m">{realName(t.u) ? realName(t.u) + " · " : ""}{t.last ? (t.last.me ? "you: " : "") + t.last.text : ""}</span>
|
|
</span>
|
|
<span class="r">{t.last ? ago(t.last.ts / 1000) : ""}</span>
|
|
</button>
|
|
{/each}
|
|
{:else if tab === "buddies"}
|
|
<button class="row" onclick={lookUp}>
|
|
<span class="lead"><Icon name="search" size={20} /></span>
|
|
<span class="b"><span class="t">Look up a user</span><span class="m">a Soulseek name: browse their files, message them, add them</span></span>
|
|
<span class="r"><Icon name="chevron-right" size={17} /></span>
|
|
</button>
|
|
{#if !buddies.length}
|
|
<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}
|
|
{#each buddies as b (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="b">
|
|
<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">{[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 class="r"><span class="dot" class:off={status(b.st) === "offline"} class:warn={status(b.st) === "away"}></span></span>
|
|
</button>
|
|
{/each}
|
|
{/if}
|
|
<div style="padding: 18px var(--pad) 0"><button class="btn btn-ghost wide" onclick={disconnect}>Sign out</button></div>
|
|
{/if}
|
|
|
|
<div class="endpad"></div>
|