community: the encrypted inbox, friend requests and shares travel sealed to a box key when the other side has published one

This commit is contained in:
KeelrLeek committed 2026-09-02 15:26:30 -05:00
1 parent 13c44f731a
commit 4378c4c0cc
14 files changed
+1387 -145

No files matched your search

+4
View File
@@ -158,6 +158,7 @@ export const slskLinkCheck = (username) => invoke("slsk_link_check", { username
export const slskLinkForget = (username) => invoke("slsk_link_forget", { username });
export const friendsList = () => invoke("friends_list");
export const friendRequest = (username) => invoke("friend_request", { username });
export const friendRequestByKey = (personaKey) => invoke("friend_request_by_key", { personaKey });
export const friendAccept = (id) => invoke("friend_accept", { id });
export const friendDecline = (id) => invoke("friend_decline", { id });
export const friendRemove = (id) => invoke("friend_remove", { id });
@@ -170,6 +171,9 @@ export const sharedPlaylistImport = (owner, uuid) => invoke("shared_playlist_imp
export const shareSend = (username, playlistId) => invoke("share_send", { username, playlistId });
export const sharesInbox = () => invoke("shares_inbox");
export const shareDismiss = (id) => invoke("share_dismiss", { id });
export const inboxStatus = () => invoke("inbox_status");
export const inboxPublish = () => invoke("inbox_publish");
export const inboxCheck = () => invoke("inbox_check");
export const buddyList = () => invoke("buddy_list");
export const buddyAdd = (username) => invoke("buddy_add", { username });
export const buddyRemove = (username) => invoke("buddy_remove", { username });
+32 -2
View File
@@ -71,6 +71,10 @@ export const community = $state({
sharePreviews: {},
/** shared_playlists_mine: what this device has published. */
published: [],
/** inbox_status: the encrypted inbox's state on this device. */
inbox: null,
/** What the last check found ({new_items, quarantined, pruned}). */
inboxLast: null,
/** persona key → ProfileView | {none} | {error} | {pending}: other
* people's signed profiles, read once per session when surfaced. */
remoteProfiles: {},
@@ -323,7 +327,7 @@ export async function initCommunity() {
if (p.status === 1 || p.status === 2) noteSeen(p.username);
});
}
await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished()]);
await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished(), refreshInbox()]);
await refreshPersona();
}
@@ -489,7 +493,33 @@ export const shareImport = (card) =>
});
// ---- the encrypted inbox --------------------------------------------------------------
export async function refreshInbox() {
try {
community.inbox = await api.inboxStatus();
} catch {
// stays as it was
}
}
export const publishInbox = () =>
run("inbox", async () => {
community.inbox = await api.inboxPublish();
});
/** Read our inbox through the node, act on sealed cargo, prune it. */
export const checkInbox = () =>
run("inboxcheck", async () => {
community.inboxLast = await api.inboxCheck();
await Promise.all([refreshInbox(), refreshFriends(), refreshShares()]);
return community.inboxLast;
});
export const friendRequestByKey = (personaKey) => friendAction("friend:" + personaKey, () => api.friendRequestByKey(personaKey));
export const friendshipForKey = (key) => {
const mine = community.friends.filter((f) => f.persona_key === key);
return mine.find((f) => f.state === "friends") ?? mine[0] ?? null;
};
// 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;
if (import.meta.env.DEV) globalThis.__communityActions = { friendRequest, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished };
if (import.meta.env.DEV) globalThis.__communityActions = { friendRequest, friendRequestByKey, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished, publishInbox, checkInbox, refreshInbox };
+37 -4
View File
@@ -77,6 +77,9 @@
shareDismiss,
sharedPublish,
sharedWithdraw,
publishInbox,
checkInbox,
friendRequestByKey,
} from "../lib/community.svelte.js";
onMount(() => {
@@ -432,11 +435,24 @@
const id = await shareImport(card);
if (id) goToView("playlist", { playlistId: id, label: card.name });
}
// The inbox is polled while this page is open and the node is up: on
// arrival and every 90 s. Sealed cargo becomes friend requests and share
// cards through the same stores the PM carry feeds.
$effect(() => {
if (!nodeUp || !key || !community.inbox?.published) return;
untrack(() => void checkInbox());
const t = setInterval(() => void checkInbox(), 90_000);
return () => clearInterval(t);
});
let addKey = $state("");
const isMe = (u) => !!me && (u ?? "").trim().toLowerCase() === me.trim().toLowerCase();
$effect(() => {
const lk = drawerLink;
if (lk) untrack(() => loadRemoteProfile(lk.persona_key));
});
$effect(() => {
for (const f of pendingIn()) if (!f.username) untrack(() => loadRemoteProfile(f.persona_key));
});
// Discover's similar-users shelf asks the server once per session.
$effect(() => {
if (community.section === "discover" && online) untrack(() => loadSimilar());
@@ -564,9 +580,9 @@
<div class="top">
<button class="avbtn" onclick={() => (community.drawer = f.username)}><Identicon seed={f.persona_key} size={44} presence={presenceOf(f.username)} /></button>
<div>
<div class="t">{display(f.username)} wants to be friends</div>
<div class="s">{f.username} · SoulStream ID {fpShort(f.persona_key)} · asked {fmtWhen(f.updated_at * 1000)}</div>
<div class="prov"><span class="src ss"><Icon name="disc" size={10} />SoulStream</span><span>signed request · their ID proven over Soulseek</span><span>·</span><span>open until {fmtWhen(f.expires_at * 1000)}</span></div>
<div class="t">{f.username ? display(f.username) : community.remoteProfiles[f.persona_key]?.display_name || `SoulStream ID ${fpShort(f.persona_key)}`} wants to be friends</div>
<div class="s">{f.username ? `${f.username} · ` : ""}SoulStream ID {fpShort(f.persona_key)} · asked {fmtWhen(f.updated_at * 1000)}</div>
<div class="prov"><span class="src ss"><Icon name="disc" size={10} />SoulStream</span><span>{f.carry === "inbox" ? "sealed · via your encrypted inbox · signed by their key" : "signed request · their ID proven over Soulseek"}</span><span>·</span><span>open until {fmtWhen(f.expires_at * 1000)}</span></div>
</div>
</div>
<div class="bot">
@@ -587,10 +603,11 @@
<span class="mosaic-ph"><Icon name="playlist" size={22} /></span>
<div>
<div class="t">{c.name}</div>
<div class="s">{c.entry_count} {c.entry_count === 1 ? "track" : "tracks"} · playlist · shared by <button class="lnkname" onclick={() => (community.drawer = c.from_username)}>{display(c.from_username)}</button> {fmtWhen(c.received_at * 1000)}</div>
<div class="s">{c.entry_count} {c.entry_count === 1 ? "track" : "tracks"} · playlist · shared by {#if c.from_username}<button class="lnkname" onclick={() => (community.drawer = c.from_username)}>{display(c.from_username)}</button>{:else}SoulStream ID {fpShort(c.from_key)}{/if} {fmtWhen(c.received_at * 1000)}</div>
<div class="prov">
<span class="src ss"><Icon name="disc" size={10} />SoulStream</span>
<span>signed by {fpShort(c.owner_key)}</span>
{#if c.carry === "inbox"}<span>·</span><span><Icon name="lock" size={9} /> sealed · via your encrypted inbox</span>{/if}
{#if pv && !pv.pending && !pv.error && !pv.withdrawn}<span>·</span><span class="chip ok"><Icon name="check" size={9} />verified · revision {pv.revision_seq}</span>{/if}
{#if pv?.withdrawn}<span>·</span><span class="chip warn">withdrawn by the owner</span>{/if}
{#if pv?.error}<span>·</span><span class="chip err">{pv.error}</span>{/if}
@@ -837,6 +854,10 @@
<button class="fchip" class:on={peopleGroup === "favorite"} onclick={() => (peopleGroup = "favorite")}>Favorites</button>
<button class="fchip" class:on={peopleGroup === "trusted"} onclick={() => (peopleGroup = "trusted")}>Trusted</button>
<span class="sp"></span>
<form class="addrow" onsubmit={(e) => { e.preventDefault(); const k = addKey.trim(); if (k) { void friendRequestByKey(k); addKey = ""; } }}>
<div class="qbox slim"><Icon name="link" size={13} /><input placeholder="Friend by SoulStream ID" bind:value={addKey} disabled={!nodeUp || !key} title="Sends a sealed friend request through their encrypted inbox — they must have published one" /></div>
<button class="ax-ghost sm" type="submit" disabled={!nodeUp || !key || !addKey.trim() || !!community.busy["friend:" + addKey.trim()]}>Ask</button>
</form>
<form class="addrow" onsubmit={(e) => { e.preventDefault(); void addContact(); }}>
<div class="qbox slim"><Icon name="plus" size={13} /><input placeholder="Add by Soulseek username" bind:value={addName} disabled={!online} /></div>
<button class="ax-ghost sm" type="submit" disabled={!online || !addName.trim() || adding}>Add</button>
@@ -1237,6 +1258,18 @@
<span>Load remote profile images</span>
<span class="hint r">verified, resized, cached by hash</span>
</label>
{#if key}
<div class="noderow">
{#if community.inbox?.published}
<span class="chip ok"><Icon name="lock" size={10} />encrypted inbox · key {community.inbox.box_key_id}</span>
<button class="ax-ghost sm" onclick={() => checkInbox()} disabled={!nodeUp || !!community.busy.inboxcheck}>{community.busy.inboxcheck ? "Checking…" : "Check now"}</button>
<span class="hint">{community.inbox.last_check ? `checked ${fmtWhen(community.inbox.last_check * 1000)} · ` : ""}friend requests and shares arrive sealed to a key kept on this device; a persona restored elsewhere publishes a new one</span>
{:else}
<button class="ax-fill sm" onclick={() => publishInbox()} disabled={!nodeUp || !!community.busy.inbox}><Icon name="lock" size={12} /> {community.busy.inbox ? "Publishing…" : "Publish an encrypted inbox"}</button>
<span class="hint">lets other SoulStream users send you friend requests and shares sealed, without Soulseek in between</span>
{/if}
</div>
{/if}
{/if}
</div>
<button class="tog big" class:on={freenetOn} role="switch" aria-checked={freenetOn} title={freenetOn ? "Turn the Freenet layer off" : "Turn the Freenet layer on"} onclick={() => setFreenetEnabled(!freenetOn)} disabled={!!community.busy.freenet}></button>