forked from Keelrleek/SoulStream
community: the soulseek link proof, one message to yourself links the account and one question links anyone who answers
This commit is contained in:
1 parent
cf9558cf26
commit
6ed6f5b8f8
10 files changed
+1156
-593
No files matched your search
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user