community: spaces, an owner-signed roster with epochs, a bounded window of signed lines, bodies sealed to a rotating key when private

This commit is contained in:
KeelrLeek committed 2026-09-02 15:50:18 -05:00
1 parent 4378c4c0cc
commit 8b511b20e7
14 files changed
+2913 -13

No files matched your search

+187 -8
View File
@@ -80,6 +80,14 @@
publishInbox,
checkInbox,
friendRequestByKey,
refreshSpaces,
openSpace,
createSpace,
postToSpace,
inviteToSpace,
removeFromSpace,
pinToSpace,
leaveSpace,
} from "../lib/community.svelte.js";
onMount(() => {
@@ -445,6 +453,53 @@
return () => clearInterval(t);
});
let addKey = $state("");
// ---- spaces ------------------------------------------------------------
let spaceName = $state("");
let spacePrivate = $state(true);
let spaceText = $state("");
let spaceInviteKey = $state("");
let spaceTab = $state("chat");
let spaceCreating = $state(false);
const sv = $derived(community.space);
const spaceOwner = $derived(sv?.my_role === "owner");
let spaceEl = $state(null);
$effect(() => {
const n = sv?.messages.length;
if (n && spaceEl) spaceEl.scrollTop = spaceEl.scrollHeight;
});
// The open Space is re-read every 30 s while the section is showing.
$effect(() => {
if (community.section !== "spaces" || !sv || !nodeUp) return;
const { owner, space_id } = sv;
const t = setInterval(() => void openSpace(owner, space_id), 30_000);
return () => clearInterval(t);
});
async function sayInSpace() {
const t = spaceText.trim();
if (!t || !sv) return;
await postToSpace(sv.owner, sv.space_id, t);
spaceText = "";
}
async function makeSpace() {
const n = spaceName.trim();
if (!n) return;
await createSpace(n, "", spacePrivate);
spaceName = "";
spaceCreating = false;
}
const whoIs = (m) => (m.is_me ? "you" : m.username ? display(m.username) : fpShort(m.key ?? m.author));
const pinnedPreview = $derived(sv?.pinned_uuid ? community.sharePreviews["pin:" + sv.pinned_uuid] : null);
$effect(() => {
const s0 = sv;
if (s0?.pinned_uuid && !community.sharePreviews["pin:" + s0.pinned_uuid]) {
untrack(() => sharePreview({ id: "pin:" + s0.pinned_uuid, owner_key: s0.pinned_owner, playlist_uuid: s0.pinned_uuid }));
}
});
async function importPinned() {
if (!sv?.pinned_uuid) return;
const id = await shareImport({ id: "pin:" + sv.pinned_uuid, owner_key: sv.pinned_owner, playlist_uuid: sv.pinned_uuid, name: pinnedPreview?.name ?? "Pinned playlist" });
if (id) goToView("playlist", { playlistId: id, label: pinnedPreview?.name ?? "Pinned playlist" });
}
const isMe = (u) => !!me && (u ?? "").trim().toLowerCase() === me.trim().toLowerCase();
$effect(() => {
const lk = drawerLink;
@@ -1005,15 +1060,120 @@
<!-- ── SPACES ─────────────────────────────────────────────────────── -->
{:else if community.section === "spaces"}
<div class="empty tight">
<span class="spico"><Icon name="sparkle" size={26} /></span>
<div class="big">No Spaces yet</div>
<div class="sub">
A Space is a persistent SoulStream community on Freenet with its own member keys — invitation only, encrypted
when private, and never a Soulseek Room in disguise. The research behind it is done; the Space contract itself
is the next piece of the Freenet track, so nothing here is live yet.
<div class="split" style="grid-template-columns:260px 1fr">
<div class="col">
<div class="grp">Your Spaces <span class="n">{community.spaces.length}</span><span class="ln"></span></div>
{#each community.spaces as sp0 (sp0.id)}
<button class="lrow" class:on={sv?.id === sp0.id} onclick={() => openSpace(sp0.owner, sp0.space_id)}>
<span class="spmini">{sp0.name.slice(0, 2).toUpperCase()}</span>
<span class="tx"><span class="l1"><span class="nm">{sp0.name}</span></span><span class="l2">{sp0.private ? "private" : "public"} · {sp0.my_role} · epoch {sp0.last_epoch}</span></span>
</button>
{/each}
{#if spaceCreating}
<form class="panel" style="margin-top:6px" onsubmit={(e) => { e.preventDefault(); void makeSpace(); }}>
<div class="field" style="margin-top:0"><label for="sp-name">Name</label><input id="sp-name" bind:value={spaceName} maxlength="64" placeholder="Late Night Ambient" /></div>
<label class="swrow" style="margin-top:10px"><button type="button" class="tog" class:on={spacePrivate} role="switch" aria-label="Private" aria-checked={spacePrivate} onclick={() => (spacePrivate = !spacePrivate)}></button><span>{spacePrivate ? "Private — bodies sealed to a key members receive" : "Public — anyone who finds it can read"}</span></label>
<div class="btns"><button class="ax-fill sm" type="submit" disabled={!spaceName.trim() || !nodeUp || !key || !!community.busy["space:create"]}>{community.busy["space:create"] ? "Creating…" : "Create"}</button><button class="ax-ghost sm" type="button" onclick={() => (spaceCreating = false)}>Cancel</button></div>
</form>
{:else}
<button class="lrow" style="color:var(--text-secondary)" onclick={() => (spaceCreating = true)} disabled={!nodeUp || !key}>
<span class="av" style="border:1px dashed rgba(255,255,255,0.25);display:grid;place-items:center;width:40px;height:40px;border-radius:50%">{@html ""}<Icon name="plus" size={16} /></span>
<span class="tx"><span class="l1"><span class="nm">Create a Space</span></span><span class="l2">{key ? "on Freenet, under your ID" : "needs a SoulStream identity"}</span></span>
</button>
{/if}
<div class="panel" style="margin-top:14px"><h3>What a Space is</h3><p class="hint">A persistent SoulStream community on Freenet. The owner's signed checkpoint is the roster; a removal is a new epoch, and in a private Space every removal rotates the key. Not a Soulseek Room, and never mirrored to one. Invitations travel sealed through the encrypted inbox — the person you invite needs one.</p></div>
</div>
<span class="chip up" style="margin-top:6px"><Icon name="sparkle" size={10} />beta · not live</span>
{#if sv}
<div>
<div class="sphead">
<div class="spico">{sv.name.slice(0, 2).toUpperCase()}</div>
<div>
<div class="nm">{sv.name} <span class="src space"><Icon name="sparkle" size={10} />Space</span></div>
<div class="sub">
<span>{#if sv.private}<Icon name="lock" size={12} /> private · sealed{:else}public{/if}</span><span>·</span>
<span>{sv.members.length} {sv.members.length === 1 ? "member" : "members"}</span><span>·</span>
<span>epoch {sv.epoch}</span><span>·</span>
<span>not a Soulseek Room</span>
{#if !sv.reachable}<span>·</span><span class="chip warn">not on this node yet</span>{:else}<span>·</span><span class="chip ok"><Icon name="check" size={10} />read from the network</span>{/if}
</div>
</div>
<div class="r" style="margin-left:auto;display:flex;gap:6px">
<button class="ax-ghost sm" onclick={() => openSpace(sv.owner, sv.space_id)} disabled={!!community.busy["space:" + sv.space_id]}><Icon name="refresh" size={12} /></button>
{#if !spaceOwner}<button class="ax-ghost sm" onclick={() => leaveSpace(sv.owner, sv.space_id)}>Leave</button>{/if}
</div>
</div>
<div class="sptabs">
<button class="subtab" class:on={spaceTab === "chat"} onclick={() => (spaceTab = "chat")}>Chat<b>{sv.messages.length}</b></button>
<button class="subtab" class:on={spaceTab === "members"} onclick={() => (spaceTab = "members")}>Members<b>{sv.members.length}</b></button>
<button class="subtab" class:on={spaceTab === "about"} onclick={() => (spaceTab = "about")}>About</button>
<span class="subtab" style="opacity:0.55" title="The F7 proposal board is the next piece">Proposals · next</span>
</div>
{#if community.spaceError}<p class="hint warn">{community.spaceError}</p>{/if}
{#if spaceTab === "chat"}
{#if sv.pinned_uuid}
<div class="panel" style="margin-bottom:12px"><h3><Icon name="pin" size={13} /> Pinned playlist {#if pinnedPreview && !pinnedPreview.pending && !pinnedPreview.error}<span class="chip acc"><Icon name="disc" size={10} />revision {pinnedPreview.revision_seq}</span>{/if}</h3>
<div class="pinned" style="margin-top:8px">
<span class="mosaic-ph"><Icon name="playlist" size={22} /></span>
<div>
<div class="t">{pinnedPreview?.name ?? "Reading…"}</div>
<div class="s">{#if pinnedPreview?.entries}{pinnedPreview.entries.length} tracks · signed by {fpShort(sv.pinned_owner)}{:else if pinnedPreview?.error}{pinnedPreview.error}{:else if pinnedPreview?.withdrawn}withdrawn by its owner{:else}fetching through your node{/if}</div>
<div class="btns"><button class="ax-fill sm" onclick={importPinned} disabled={!pinnedPreview?.entries?.length || !!community.busy["import:pin:" + sv.pinned_uuid]}><Icon name="download" size={12} /> Import</button>{#if spaceOwner}<button class="ax-ghost sm" onclick={() => pinToSpace(sv.owner, sv.space_id, null, null)}>Unpin</button>{/if}</div>
</div>
</div>
</div>
{:else if spaceOwner && community.published.some((p) => !p.withdrawn)}
<div class="noderow" style="margin:0 0 12px"><span class="hint">Pin one of your public playlists:</span>{#each community.published.filter((p) => !p.withdrawn) as p (p.playlist_id)}<button class="fchip" onclick={() => pinToSpace(sv.owner, sv.space_id, key, p.uuid)}>{p.name}</button>{/each}</div>
{/if}
<div class="thread" style="height:auto;min-height:360px">
<div class="chat" bind:this={spaceEl} style="max-height:420px">
{#if !sv.messages.length}<div class="cm sys"><span class="tm"></span><span class="body">Nothing said yet. The last {sv.message_cap} lines are kept; older ones fall away for everyone.</span></div>{/if}
{#each sv.messages as m (m.id)}
<div class="cm"><span class="tm">{fmtClock(m.created_at * 1000)}</span><span class="body"><span class="whoin" class:mine={m.is_me} class:c={!m.is_me && !!m.username}>{whoIs(m)}</span>{#if m.text != null}{m.text}{:else}<span class="muted"><Icon name="lock" size={10} /> sealed to a key this device does not hold (epoch {m.epoch})</span>{/if}</span></div>
{/each}
</div>
<form class="compose" onsubmit={(e) => { e.preventDefault(); void sayInSpace(); }}>
<div class="qbox grow"><input placeholder={sv.can_post ? `Say something in ${sv.name}` : sv.has_key ? "You are not on the roster" : "No key for this epoch yet"} bind:value={spaceText} disabled={!sv.can_post || !nodeUp} /></div>
<span class="via">{sv.private ? "sealed · members only" : "public · signed"} · last {sv.message_cap} kept</span>
<button class="send" type="submit" disabled={!sv.can_post || !nodeUp || !spaceText.trim() || !!community.busy["space:post"]} title="Post"><Icon name="send" size={15} /></button>
</form>
</div>
{:else if spaceTab === "members"}
<div class="panel">
<div class="members">
{#each sv.members as m (m.key)}
<div class="mem">
<button class="avbtn" onclick={() => m.username && (community.drawer = m.username)}><Identicon seed={m.key} size={56} /></button>
<span class="nm">{m.is_me ? "you" : m.username ? display(m.username) : fpShort(m.key)}</span>
<span class="role" class:own={m.role === "owner"}>{m.role}{#if m.since_epoch > 1} · since {m.since_epoch}{/if}</span>
{#if spaceOwner && !m.is_me}<button class="lnk" onclick={() => removeFromSpace(sv.owner, sv.space_id, m.key)} disabled={!!community.busy["space:remove"]}>remove</button>{/if}
</div>
{/each}
</div>
{#if spaceOwner}
<form class="inl" style="margin-top:14px" onsubmit={(e) => { e.preventDefault(); const k = spaceInviteKey.trim(); if (k) { void inviteToSpace(sv.owner, sv.space_id, k); spaceInviteKey = ""; } }}>
<input placeholder="Invite by SoulStream ID (they need an encrypted inbox)" bind:value={spaceInviteKey} />
<button class="ax-fill sm" type="submit" disabled={!spaceInviteKey.trim() || !nodeUp || !!community.busy["space:invite"]}>{community.busy["space:invite"] ? "Inviting…" : "Invite"}</button>
</form>
{#if community.friends.some((f) => f.state === "friends")}<p class="hint" style="margin-top:8px">Friends: {#each community.friends.filter((f) => f.state === "friends") as f (f.id)}<button class="lnk" onclick={() => (spaceInviteKey = f.persona_key)}>{f.username || fpShort(f.persona_key)}</button> {/each}</p>{/if}
{/if}
<p class="hint" style="margin-top:12px">Members are their SoulStream identities here — a joined Space is a linked identity. Removing someone is a new epoch{#if sv.private}, and in this private Space it also rotates the key: those who stay receive the new one sealed{/if}.</p>
</div>
{:else}
<div class="panel">
<h3>About</h3>
<dl class="kv"><dt>Owner</dt><dd class="mono">{sv.owner}</dd><dt>Space id</dt><dd class="mono">{sv.space_id}</dd><dt>Mode</dt><dd>{sv.private ? "private — message bodies sealed to a per-epoch key" : "public — anyone who resolves the contract can read"}</dd><dt>Window</dt><dd>the last {sv.message_cap} messages</dd><dt>Epoch</dt><dd>{sv.epoch}</dd></dl>
<p class="hint" style="margin-top:10px">What the network can see: that the Space exists, its owner, its roster keys, and — for a private Space — how many sealed lines it holds. Leaving does not erase copies others hold.</p>
{#if sv.description}<p class="desc">{sv.description}</p>{/if}
</div>
{/if}
</div>
{:else}
<div class="empty tight">
<span class="spico"><Icon name="sparkle" size={26} /></span>
<div class="big">{community.spaces.length ? "Pick a Space" : "No Spaces yet"}</div>
<div class="sub">A Space is a persistent SoulStream community on Freenet with its own roster — invitation only, sealed when private, never a Soulseek Room in disguise.</div>
</div>
{/if}
</div>
<!-- ── YOU ────────────────────────────────────────────────────────── -->
@@ -1694,6 +1854,25 @@
.emptyshelf b { color: var(--text-secondary); }
.emptyshelf .ax-ghost { margin-left: auto; }
/* ── spaces ── */
.spmini { width: 40px; height: 40px; border-radius: 10px; flex: none; display: grid; place-items: center; background: color-mix(in srgb, var(--space) 16%, transparent); color: #d9a6ff; font-size: 13px; font-weight: 900; }
.sphead { display: flex; align-items: center; gap: 16px; padding-bottom: 14px; border-bottom: 1px solid var(--hair); }
.spico { width: 56px; height: 56px; border-radius: 16px; display: grid; place-items: center; flex: none; background: color-mix(in srgb, var(--space) 16%, transparent); color: #d9a6ff; font-size: 20px; font-weight: 900; }
.sphead .nm { font-size: 20px; font-weight: 800; display: flex; align-items: center; gap: 10px; }
.sphead .sub { font-size: 12px; color: var(--text-muted); margin-top: 4px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; max-width: none; }
.src.space { background: color-mix(in srgb, var(--space) 14%, transparent); color: #d9a6ff; }
.sptabs { display: flex; gap: 2px; margin: 12px 0 14px; align-items: center; }
.subtab { padding: 6px 13px; border-radius: 8px; font-size: 12px; font-weight: 800; color: var(--text-secondary); display: inline-flex; gap: 6px; align-items: center; }
.subtab.on { background: var(--surf-hi); color: #fff; }
.subtab b { font-size: 10px; }
.pinned { display: flex; gap: 14px; align-items: center; }
.pinned .t { font-size: 15px; font-weight: 800; }
.pinned .s { font-size: 12px; color: var(--text-secondary); margin-top: 2px; }
.members { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 12px 8px; }
.mem { display: flex; flex-direction: column; align-items: center; gap: 5px; text-align: center; }
.mem .nm { font-size: 11.5px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 96px; }
.mem .role { font-size: 9.5px; font-weight: 900; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); }
.mem .role.own { color: #d9a6ff; }
/* ── you ── */
/* The You section sized by the PANE (container query), never the window:
three columns from ~1240px of pane, two down to ~820, then one. */