community: the soulseek link proof, one message to yourself links the account and one question links anyone who answers

This commit is contained in:
KeelrLeek committed 2026-09-02 14:27:54 -05:00
1 parent cf9558cf26
commit 6ed6f5b8f8
10 files changed
+1156 -593

No files matched your search

+5
View File
@@ -151,6 +151,11 @@ export const slskBrowseUser = (username) => invoke("slsk_browse_user", { usernam
export const slskUserInfo = (username) => invoke("slsk_user_info", { username });
export const slskUserInterests = (username) => invoke("slsk_user_interests", { username });
export const slskSimilarUsers = () => invoke("slsk_similar_users");
export const slskLinkStatus = () => invoke("slsk_link_status");
export const slskLinkSelf = () => invoke("slsk_link_self");
export const slskLinkUnlink = () => invoke("slsk_link_unlink");
export const slskLinkCheck = (username) => invoke("slsk_link_check", { username });
export const slskLinkForget = (username) => invoke("slsk_link_forget", { username });
export const buddyList = () => invoke("buddy_list");
export const buddyAdd = (username) => invoke("buddy_add", { username });
export const buddyRemove = (username) => invoke("buddy_remove", { username });
+55 -1
View File
@@ -59,6 +59,13 @@ export const community = $state({
interests: {},
/** The server's similar-users answer, or null before it was asked. */
similar: null,
/** slsk_link_status: our own verified link and everyone we verified. */
links: null,
/** username → the last failed ask's reason. */
linkErrors: {},
/** persona key → ProfileView | {none} | {error} | {pending}: other
* people's signed profiles, read once per session when surfaced. */
remoteProfiles: {},
/** key → true while an action runs (buttons disable on it). */
busy: {},
/** The last action's error, shown where it happened. */
@@ -303,7 +310,7 @@ export async function initCommunity() {
if (p.status === 1 || p.status === 2) noteSeen(p.username);
});
}
await Promise.all([refreshFreenet(), loadContacts()]);
await Promise.all([refreshFreenet(), loadContacts(), refreshLinks()]);
await refreshPersona();
}
@@ -337,6 +344,53 @@ export async function loadSimilar(force = false) {
}
}
// ---- the Soulseek link proof ----------------------------------------------------
export async function refreshLinks() {
try {
community.links = await api.slskLinkStatus();
} catch {
// stays null: the UI treats it as "not linked"
}
}
export const linkSelf = () =>
run("linkself", async () => {
community.links = await api.slskLinkSelf();
});
export const unlinkSelf = () =>
run("linkself", async () => {
community.links = await api.slskLinkUnlink();
});
/** Ask one person over Soulseek; success is remembered on the Rust side. */
export async function checkLink(username) {
community.busy["link:" + username] = true;
delete community.linkErrors[username];
try {
const k = await api.slskLinkCheck(username);
await refreshLinks();
return k;
} catch (e) {
community.linkErrors[username] = String(e?.message ?? e);
return null;
} finally {
delete community.busy["link:" + username];
}
}
export const linkFor = (username) => {
const n = (username ?? "").trim().toLowerCase();
return community.links?.others?.find((l) => l.username_normalized === n) ?? null;
};
export async function loadRemoteProfile(key, force = false) {
if (!key) return;
if (community.remoteProfiles[key] && !force) return;
community.remoteProfiles[key] = { pending: true };
try {
community.remoteProfiles[key] = (await api.profileGet(key)) ?? { none: true };
} catch (e) {
community.remoteProfiles[key] = { error: String(e?.message ?? e) };
}
}
// Dev handle beside `__soulstream` (state.svelte.js): probes reach the store
// through this, never through a bare import (which would be a second copy).
if (import.meta.env.DEV) globalThis.__community = community;
+74 -3
View File
@@ -58,6 +58,12 @@
personaKey,
loadUserInfo,
loadSimilar,
refreshLinks,
linkSelf,
unlinkSelf,
checkLink,
linkFor,
loadRemoteProfile,
} from "../lib/community.svelte.js";
onMount(() => {
@@ -100,6 +106,12 @@
const nodeUp = $derived(adapterConnected(community.freenet?.status));
const online = $derived(app.connection === "online");
const me = $derived(app.username ?? "");
const ownLink = $derived(community.links?.own ?? null);
/** The stored link is only "current" for the signed-in account and the
* persona this device holds now — a different login or key is unlinked. */
const ownLinkCurrent = $derived(
!!ownLink && !!key && !!me && ownLink.username_normalized === me.trim().toLowerCase() && ownLink.persona_key === key,
);
const display = (u) => community.contacts[u]?.petname || u;
const isContact = (u) => slsk.buddies.includes(u);
@@ -398,6 +410,12 @@
const drawerPhoto = $derived(
drawerInfo?.picture_png_base64 ? `data:image/png;base64,${drawerInfo.picture_png_base64}` : null,
);
const drawerLink = $derived(drawerUser ? linkFor(drawerUser) : null);
const drawerRemote = $derived(drawerLink ? community.remoteProfiles[drawerLink.persona_key] : null);
$effect(() => {
const lk = drawerLink;
if (lk) untrack(() => loadRemoteProfile(lk.persona_key));
});
// Discover's similar-users shelf asks the server once per session.
$effect(() => {
if (community.section === "discover" && online) untrack(() => loadSimilar());
@@ -460,6 +478,7 @@
<Icon name="globe" size={12} />
{#if me}<b>{me}</b> on Soulseek{:else}not signed in to Soulseek{/if}
<span class="chip {online ? 'ok' : 'off'}">{online ? "online" : "offline"}</span>
{#if ownLinkCurrent}<span class="chip acc" title="Verified through a Soulseek message on {new Date(ownLink.verified_at * 1000).toLocaleString()}"><Icon name="link" size={10} />linked</span>{/if}
</span>
</div>
</div>
@@ -771,6 +790,7 @@
</div>
<div class="sub">
<span class="src slsk"><Icon name="globe" size={10} />Soulseek</span>
{#if linkFor(p.u)}<span class="src ss" title="Verified SoulStream ID {fpShort(linkFor(p.u).persona_key)}"><Icon name="disc" size={10} />SoulStream</span>{/if}
{#if p.meta.group}<span>·</span><span>{p.meta.group}</span>{/if}
{#if p.st.file_count != null}<span>·</span><span>{fmtCount(p.st.file_count)} files</span>{/if}
{#if p.meta.note}<span>·</span><span class="note">{p.meta.note}</span>{/if}
@@ -1054,8 +1074,27 @@
<div class="t">Soulseek <span class="chip {online ? 'ok' : 'off'}">{online ? "online" : "offline"}</span></div>
<div class="s">
{#if me}Signed in as <b>{me}</b> · {fmtCount(network.shareFiles)} files shared.{:else}Not signed in — see Settings.{/if}
<br />Linking this username to your SoulStream identity by a signed proof is the next identity slice; until then the two are simply side by side.
<br />
{#if !key}
Create a SoulStream identity to link this account to it.
{:else if !me}
Sign in to Soulseek to link this account to your identity.
{:else if ownLinkCurrent}
<b>Linked to this identity</b> — proof verified {new Date(ownLink.verified_at * 1000).toLocaleString()} through a Soulseek message to yourself. Other SoulStream users who ask over Soulseek now get your signed ID; nothing is published.
{:else}
Not linked to your identity. Linking sends one message to yourself over Soulseek and verifies the signed answer — nothing is published, and from then on other SoulStream users who ask get your signed ID.
{/if}
</div>
{#if key && me}
<div class="noderow">
{#if ownLinkCurrent}
<button class="ax-ghost sm" onclick={() => unlinkSelf()} disabled={!!community.busy.linkself}>Unlink</button>
{:else}
<button class="ax-fill sm" onclick={() => linkSelf()} disabled={!online || !nodeUp || !!community.busy.linkself}><Icon name="link" size={12} /> {community.busy.linkself ? "Linking…" : "Link this account"}</button>
{#if !nodeUp}<span class="hint">needs the node connected</span>{/if}
{/if}
</div>
{/if}
</div>
</div>
<div class="netcard">
@@ -1201,8 +1240,37 @@
{/if}
</div>
<div class="dsec">
<h4>Signed SoulStream profile <span class="chip off">none presented</span></h4>
<p class="hint">No SoulStream profile has been presented for this person. That is normal — most Soulseek users have none, and the link between a Soulseek name and a SoulStream ID is a signed proof that lands with the next identity slice.</p>
{#if drawerLink}
<h4>Signed SoulStream profile <span class="chip ok"><Icon name="check" size={10} />verified {fmtWhen(drawerLink.verified_at * 1000)}</span></h4>
<div class="rp">
<Identicon seed={drawerLink.persona_key} size={44} photo={drawerRemote?.avatar_png_base64 ? `data:image/png;base64,${drawerRemote.avatar_png_base64}` : null} />
<div class="rptx">
{#if !drawerRemote || drawerRemote.pending}
<div class="nm">SoulStream ID {fpShort(drawerLink.persona_key)}</div>
<p class="hint"><span class="bl"></span> reading their profile from the network</p>
{:else if drawerRemote.error}
<div class="nm">SoulStream ID {fpShort(drawerLink.persona_key)}</div>
<p class="hint warn">Could not read their profile: {drawerRemote.error}</p>
{:else if drawerRemote.none}
<div class="nm">SoulStream ID {fpShort(drawerLink.persona_key)}</div>
<p class="hint">They proved their ID but have not published a profile yet.</p>
{:else}
<div class="nm">{drawerRemote.display_name || "(no name)"} <span class="src ss"><Icon name="disc" size={10} />SoulStream</span></div>
<div class="mono muted" style="font-size:11px">{fpShort(drawerLink.persona_key)} · revision {drawerRemote.sequence}</div>
{#if drawerRemote.bio}<p class="desc">{drawerRemote.bio}</p>{/if}
{#if drawerRemote.avatar_note && drawerRemote.avatar_note !== "none published"}<p class="hint">{drawerRemote.avatar_note}</p>{/if}
{/if}
</div>
</div>
<p class="hint" style="margin-top:8px">Their client answered a one-time question over Soulseek with a claim signed by this key — the server vouched for the sender, the signature for the key. <button class="lnk" onclick={() => checkLink(drawerUser)} disabled={!online || !!community.busy["link:" + drawerUser]}>ask again</button></p>
{:else}
<h4>Signed SoulStream profile <span class="chip off">not asked</span></h4>
<p class="hint">Ask their client, over Soulseek, for a signed SoulStream ID. One message goes out; a client that is not SoulStream sees a line saying it is safe to ignore.</p>
<div class="noderow">
<button class="ax-ghost sm" onclick={() => checkLink(drawerUser)} disabled={!online || !!community.busy["link:" + drawerUser]}><Icon name="link" size={12} /> {community.busy["link:" + drawerUser] ? "Asking… up to 30 s" : "Ask for their SoulStream ID"}</button>
</div>
{#if community.linkErrors[drawerUser]}<p class="hint" style="margin-top:6px">{community.linkErrors[drawerUser]}</p>{/if}
{/if}
</div>
<div class="dfoot">
A SoulStream badge means a compatible, signed profile was presented and verified against its key. It is not proof of who
@@ -1557,6 +1625,9 @@
.tag { font-size: 11px; font-weight: 700; padding: 3px 9px; border-radius: 99px; background: var(--surf-hi); color: var(--text-secondary); }
.tag.hate { text-decoration: line-through; opacity: 0.7; }
.desc { margin: 8px 0 0; font-size: 12.5px; line-height: 1.5; color: var(--text-secondary); white-space: pre-wrap; overflow-wrap: anywhere; max-height: 180px; overflow-y: auto; }
.rp { display: flex; gap: 12px; align-items: flex-start; }
.rptx { min-width: 0; flex: 1; }
.rp .nm { font-size: 15px; font-weight: 800; display: flex; align-items: center; gap: 8px; }
.dsec { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--hair); }
.dsec h4 { margin: 0 0 8px; font-size: 10.5px; font-weight: 900; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); display: flex; align-items: center; gap: 8px; }
.dsec h4 .src, .dsec h4 .chip { margin-left: auto; }