community: friends, two signed answers on one request carried over soulseek, one answer is only ever pending

This commit is contained in:
KeelrLeek committed 2026-09-02 14:40:33 -05:00
1 parent 6ed6f5b8f8
commit 984738135a
9 files changed
+967 -4

No files matched your search

+77 -1
View File
@@ -64,6 +64,14 @@
checkLink,
linkFor,
loadRemoteProfile,
friendRequest,
friendAccept,
friendDecline,
friendRemove,
friendForget,
friendshipFor,
friendCount,
pendingIn,
} from "../lib/community.svelte.js";
onMount(() => {
@@ -296,6 +304,8 @@
? true
: peopleGroup === "trusted"
? p.meta.trusted
: peopleGroup === "friends"
? friendshipFor(p.u)?.state === "friends"
: peopleGroup === "favorite"
? p.meta.favorite
: p.meta.group === peopleGroup,
@@ -412,6 +422,8 @@
);
const drawerLink = $derived(drawerUser ? linkFor(drawerUser) : null);
const drawerRemote = $derived(drawerLink ? community.remoteProfiles[drawerLink.persona_key] : null);
const drawerFriend = $derived(drawerUser ? friendshipFor(drawerUser) : null);
const isMe = (u) => !!me && (u ?? "").trim().toLowerCase() === me.trim().toLowerCase();
$effect(() => {
const lk = drawerLink;
if (lk) untrack(() => loadRemoteProfile(lk.persona_key));
@@ -493,7 +505,7 @@
{:else}
<span class="chip off">SoulStream identity off</span>
{/if}
<span class="hint">{slsk.buddies.length} contacts · {joinedNames.length} rooms · {convos.length} conversations</span>
<span class="hint">{friendCount()} friends · {slsk.buddies.length} contacts · {joinedNames.length} rooms · {convos.length} conversations</span>
</div>
<button class="ax-ghost" onclick={() => setSection("you")}>
<Icon name={key ? "pencil" : "sparkle"} size={13} />
@@ -536,6 +548,27 @@
<!-- ── INBOX ──────────────────────────────────────────────────────── -->
{:else if community.section === "inbox"}
{#if pendingIn().length}
<div class="freqs">
{#each pendingIn() as f (f.id)}
<div class="card inv">
<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>
</div>
<div class="bot">
<button class="ax-fill sm" onclick={() => friendAccept(f.id)} disabled={!online || !nodeUp || !!community.busy["friend:" + f.id]}>Accept</button>
<button class="ax-ghost sm" onclick={() => friendDecline(f.id)} disabled={!!community.busy["friend:" + f.id]}>Decline</button>
<span class="hint">friends are two signed answers — yours goes back to them over Soulseek</span>
</div>
</div>
{/each}
</div>
{/if}
{#if !convos.length}
<div class="empty">
<Icon name="inbox" size={30} />
@@ -762,6 +795,7 @@
{#each groups as g (g)}
<button class="fchip" class:on={peopleGroup === g} onclick={() => (peopleGroup = g)}>{g}</button>
{/each}
<button class="fchip" class:on={peopleGroup === "friends"} onclick={() => (peopleGroup = "friends")}>Friends</button>
<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>
@@ -787,6 +821,7 @@
{#if p.meta.petname}<span class="real">{p.u}</span>{/if}
{#if p.meta.favorite}<span class="mark fav" title="Favorite"><Icon name="star" size={11} /></span>{/if}
{#if p.meta.trusted}<span class="mark tr" title="Trusted — a local permission choice, not a verified identity"><Icon name="shield" size={11} /></span>{/if}
{#if friendshipFor(p.u)?.state === "friends"}<span class="mark fr" title="Friend — a signed, reciprocal SoulStream relationship"><Icon name="link" size={11} /></span>{/if}
</div>
<div class="sub">
<span class="src slsk"><Icon name="globe" size={10} />Soulseek</span>
@@ -1170,6 +1205,7 @@
<input class="pet" placeholder={drawerUser} bind:value={petDraft} onchange={() => saveMeta()} title="Your private petname for this person" />
{#if drawerMeta?.favorite}<span class="mark fav"><Icon name="star" size={12} /></span>{/if}
{#if drawerMeta?.trusted}<span class="mark tr"><Icon name="shield" size={12} /></span>{/if}
{#if drawerFriend?.state === "friends"}<span class="mark fr" title="Friend"><Icon name="link" size={12} /></span>{/if}
</div>
<div class="sub">
{#if petDraft.trim()}<span>{drawerUser}</span><span>·</span>{/if}
@@ -1263,6 +1299,35 @@
</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>
{#if !isMe(drawerUser)}
<div class="friendrow">
{#if !drawerFriend || drawerFriend.state === "expired" || drawerFriend.state === "revoked" && drawerFriend.direction === "in"}
<button class="ax-fill sm" onclick={() => friendRequest(drawerUser)} disabled={!online || !nodeUp || !key || !!community.busy["friend:" + drawerUser]}><Icon name="link" size={12} /> {community.busy["friend:" + drawerUser] ? "Sending…" : "Add as friend"}</button>
<span class="hint">{drawerFriend?.state === "revoked" ? "the earlier friendship was ended — a new request starts over" : "a signed request goes to them over Soulseek; friends are two reciprocal signed answers"}</span>
{:else if drawerFriend.state === "friends"}
<span class="chip ok"><Icon name="link" size={10} />friends since {fmtWhen(drawerFriend.since * 1000)}</span>
<button class="ax-ghost sm" onclick={() => friendRemove(drawerFriend.id)} disabled={!!community.busy["friend:" + drawerFriend.id]}>Unfriend</button>
{:else if drawerFriend.state === "pending-in"}
<span class="chip acc">they asked to be friends</span>
<button class="ax-fill sm" onclick={() => friendAccept(drawerFriend.id)} disabled={!online || !nodeUp || !!community.busy["friend:" + drawerFriend.id]}>Accept</button>
<button class="ax-ghost sm" onclick={() => friendDecline(drawerFriend.id)} disabled={!!community.busy["friend:" + drawerFriend.id]}>Decline</button>
{:else if drawerFriend.state === "pending-out"}
<span class="chip acc"><span class="bl"></span>request sent · waiting for their answer</span>
<button class="lnk" onclick={() => friendForget(drawerFriend.id)}>withdraw</button>
{:else if drawerFriend.state === "accepted-waiting"}
<span class="chip acc"><span class="bl"></span>you accepted · waiting for their signed answer</span>
{:else if drawerFriend.state === "declined"}
<span class="chip off">{drawerFriend.direction === "out" ? "they declined" : "you declined"}</span>
<button class="lnk" onclick={() => friendForget(drawerFriend.id)}>forget</button>
{:else if drawerFriend.state === "revoked"}
<span class="chip warn">friendship ended</span>
<button class="ax-ghost sm" onclick={() => friendRequest(drawerUser)} disabled={!online || !nodeUp}>Ask again</button>
{:else if drawerFriend.state === "reacceptance-required"}
<span class="chip warn">their key changed — must re-accept</span>
<button class="ax-ghost sm" onclick={() => friendRequest(drawerUser)} disabled={!online || !nodeUp}>Ask again</button>
{/if}
</div>
{/if}
{: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>
@@ -1443,6 +1508,15 @@
.sub { color: var(--text-secondary); font-size: 12.5px; max-width: 460px; line-height: 1.6; }
.spico { width: 56px; height: 56px; border-radius: 16px; display: grid; place-items: center; background: color-mix(in srgb, var(--space) 16%, transparent); color: #d9a6ff; }
/* friend requests — the invitation card, parsed and signed, never pasted text */
.freqs { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
.card { border-radius: 12px; background: var(--surf); border: 1px solid var(--hair); overflow: hidden; max-width: 560px; }
.card .top { display: flex; gap: 12px; padding: 12px; align-items: center; }
.card .t { font-size: 14px; font-weight: 800; }
.card .s { font-size: 11.5px; color: var(--text-secondary); margin-top: 2px; }
.card .prov { font-size: 10.5px; color: var(--text-muted); margin-top: 6px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.card .bot { display: flex; gap: 8px; padding: 0 12px 12px; align-items: center; flex-wrap: wrap; }
.card .bot .hint { margin-left: auto; font-size: 10.5px; }
/* ── thread / room ── */
.thread { display: flex; flex-direction: column; height: calc(100vh - 330px); min-height: 480px; }
.thead-bar, .roomhead { display: flex; align-items: center; gap: 12px; padding: 4px 4px 10px; border-bottom: 1px solid var(--hair); }
@@ -1628,6 +1702,8 @@
.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; }
.friendrow { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
.mark.fr { color: var(--accent-hover); }
.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; }