community: the library overlap shelf, their share folders against your albums on this device after a browse

This commit is contained in:
KeelrLeek committed 2026-09-02 17:08:25 -05:00
1 parent d7bc92fda7
commit 0846b39d45
6 files changed
+451 -7

No files matched your search

+3
View File
@@ -185,6 +185,9 @@ export const spaceLeave = (owner, space) => invoke("space_leave", { owner, space
export const spaceBoard = (owner, space) => invoke("space_board", { owner, space });
export const spacePropose = (owner, space, base, ops, note) => invoke("space_propose", { owner, space, base, ops, note });
export const spaceDecide = (owner, space, proposal, accept, reason) => invoke("space_decide", { owner, space, proposal, accept, reason });
export const overlapList = () => invoke("overlap_list");
export const overlapCompute = (username) => invoke("overlap_compute", { username });
export const overlapForget = (username) => invoke("overlap_forget", { username });
export const buddyList = () => invoke("buddy_list");
export const buddyAdd = (username) => invoke("buddy_add", { username });
export const buddyRemove = (username) => invoke("buddy_remove", { username });
+29 -2
View File
@@ -83,6 +83,8 @@ export const community = $state({
/** space_board: the proposal board beside the open Space's pinned playlist, or null. */
board: null,
boardError: "",
/** overlap_list: library comparisons computed on this device, best first. */
overlap: [],
/** persona key → ProfileView | {none} | {error} | {pending}: other
* people's signed profiles, read once per session when surfaced. */
remoteProfiles: {},
@@ -335,11 +337,12 @@ export async function initCommunity() {
void refreshShares();
void refreshPublished();
});
api.onEvent("community:overlap", () => void refreshOverlap());
api.onEvent("soulseek:user-state", (p) => {
if (p.status === 1 || p.status === 2) noteSeen(p.username);
});
}
await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished(), refreshInbox(), refreshSpaces()]);
await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished(), refreshInbox(), refreshSpaces(), refreshOverlap()]);
await refreshPersona();
}
@@ -581,6 +584,30 @@ export const leaveSpace = (owner, space) =>
await refreshSpaces();
});
// ---- library overlap ------------------------------------------------------------
export async function refreshOverlap() {
try {
community.overlap = await api.overlapList();
} catch {
// stays as it was
}
}
export const overlapFor = (username) => {
const k = (username ?? "").trim().toLowerCase();
return community.overlap.find((o) => o.username.toLowerCase() === k) ?? null;
};
/** Browse them on purpose and compare — nothing is uploaded to do it. */
export const computeOverlap = (username) =>
run("overlap:" + username, async () => {
await api.overlapCompute(username);
await refreshOverlap();
});
export const forgetOverlap = (username) =>
run("overlap:" + username, async () => {
await api.overlapForget(username);
await refreshOverlap();
});
// ---- the proposal board ---------------------------------------------------------
/** Read the board beside the Space's pinned playlist; the playlist's owner also
* brings its roster in line with the members here. */
@@ -621,4 +648,4 @@ export const decideProposal = (owner, space, proposal, accept, reason) =>
// 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, friendRequestByKey, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished, publishInbox, checkInbox, refreshInbox, refreshSpaces, openSpace, createSpace, postToSpace, inviteToSpace, removeFromSpace, pinToSpace, leaveSpace, openBoard, proposeToBoard, decideProposal };
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, refreshSpaces, openSpace, createSpace, postToSpace, inviteToSpace, removeFromSpace, pinToSpace, leaveSpace, openBoard, proposeToBoard, decideProposal, refreshOverlap, computeOverlap, forgetOverlap };
+56 -5
View File
@@ -91,6 +91,9 @@
openBoard,
proposeToBoard,
decideProposal,
overlapFor,
computeOverlap,
forgetOverlap,
} from "../lib/community.svelte.js";
onMount(() => {
@@ -583,6 +586,7 @@
const decideOn = (p, accept) =>
decideProposal(sv.owner, sv.space_id, p.id, accept, boardReason[p.id] || (!accept && p.stale ? "stale base — re-submit against the current revision" : "")).catch(() => {});
const whoProposed = (p) => (p.is_me ? "you" : p.username ? display(p.username) : `SoulStream ID ${fpShort(p.author)}`);
const drawerOverlap = $derived(drawerUser ? overlapFor(drawerUser) : null);
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" });
@@ -1135,11 +1139,29 @@
<h2 class="ax-title">Library overlap</h2>
<span class="prov"><Icon name="library" size={12} /> computed on this device, after an explicit browse</span>
</div>
<div class="emptyshelf">
<Icon name="folder" size={22} />
<div><b>Nothing computed yet.</b> Browse a person's shares and SoulStream compares them with your library locally — nothing is uploaded to do it. The comparison itself is the next piece of this shelf.</div>
<button class="ax-ghost sm" onclick={() => setSection("people")}>Browse someone</button>
</div>
{#if community.overlap.length}
<div class="track">
{#each community.overlap.slice(0, 24) as o (o.username)}
<button class="pcard" onclick={() => (community.drawer = o.username)}>
<span class="face" style:background="hsl({hueOf(o.username)} 30% 14%)"><Identicon seed={o.username} size={84} presence={presenceOf(o.username)} /></span>
<span class="nm">{display(o.username)}</span>
<span class="why">
{#if o.albums_in_common_count}shares <b>{o.albums_in_common_count}</b> {o.albums_in_common_count === 1 ? "album" : "albums"} you own {#if o.gaps_count}· <b>{o.gaps_count}</b> you lack{/if}
{:else if o.artists_in_common_count}<b>{o.artists_in_common_count}</b> {o.artists_in_common_count === 1 ? "artist" : "artists"} in common · <b>{o.gaps_count}</b> albums you lack
{:else if o.their_albums}nothing in common · {fmtCount(o.their_albums)} albums browsed
{:else}no album folders found{/if}
</span>
</button>
{/each}
</div>
<p class="hint pad">Names compared, not files: a folder counts as yours when your library has that album under that artist. Browse someone again to refresh their line.</p>
{:else}
<div class="emptyshelf">
<Icon name="folder" size={22} />
<div><b>Nothing computed yet.</b> Browse a person's shares and SoulStream compares them with your library locally — nothing is uploaded to do it.</div>
<button class="ax-ghost sm" onclick={() => setSection("people")}>Browse someone</button>
</div>
{/if}
</div>
<div class="hint wide">
There is no global directory here and no "people you may know". Every shelf says where its people came from. Freenet
@@ -1667,6 +1689,28 @@
</div>
<textarea class="note-in" rows="2" placeholder="A private note — only you see it" bind:value={noteDraft} onchange={() => saveMeta()}></textarea>
<div class="dsec">
<h4>Library overlap <span class="src slsk"><Icon name="library" size={10} />this device</span></h4>
{#if drawerOverlap}
<p class="hint">
{#if drawerOverlap.their_albums}<b>{drawerOverlap.albums_in_common_count}</b> of their {fmtCount(drawerOverlap.their_albums)} albums are in your library · <b>{drawerOverlap.artists_in_common_count}</b> {drawerOverlap.artists_in_common_count === 1 ? "artist" : "artists"} in common · <b>{drawerOverlap.gaps_count}</b> you lack{:else}No album folders among {fmtCount(drawerOverlap.audio_files)} audio files.{/if}
· compared {fmtWhen(drawerOverlap.computed_at * 1000)}
</p>
{#if drawerOverlap.albums_in_common.length}
<div class="ovl"><span class="lbl">In common</span>{#each drawerOverlap.albums_in_common.slice(0, 8) as a, i (i)}<span class="ov"><b>{a.album}</b> · {a.artist}</span>{/each}{#if drawerOverlap.albums_in_common_count > 8}<span class="ov more">+{drawerOverlap.albums_in_common_count - 8} more</span>{/if}</div>
{/if}
{#if drawerOverlap.gaps.length}
<div class="ovl"><span class="lbl">You lack</span>{#each drawerOverlap.gaps.slice(0, 8) as a, i (i)}<span class="ov gap"><b>{a.album}</b> · {a.artist}</span>{/each}{#if drawerOverlap.gaps_count > 8}<span class="ov more">+{drawerOverlap.gaps_count - 8} more</span>{/if}</div>
{/if}
<div class="bbtns">
<button class="ax-ghost sm" onclick={() => computeOverlap(drawerUser)} disabled={!online || !!community.busy["overlap:" + drawerUser]}><Icon name="refresh" size={12} /> {community.busy["overlap:" + drawerUser] ? "Browsing…" : "Compare again"}</button>
<button class="lnk" onclick={() => forgetOverlap(drawerUser)} disabled={!!community.busy["overlap:" + drawerUser]}>forget</button>
</div>
{:else}
<p class="hint">Not compared yet. Their share folders against your albums, on this device — nothing is uploaded to do it.</p>
<div class="bbtns"><button class="ax-ghost sm" onclick={() => computeOverlap(drawerUser)} disabled={!online || !!community.busy["overlap:" + drawerUser]}><Icon name="library" size={12} /> {community.busy["overlap:" + drawerUser] ? "Browsing their shares…" : "Compare with my library"}</button></div>
{/if}
</div>
<div class="dsec">
<h4>Native Soulseek profile <span class="src slsk"><Icon name="globe" size={10} />Soulseek</span></h4>
<dl class="kv">
@@ -2045,6 +2089,13 @@
.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; }
/* library overlap in the drawer */
.ovl { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; align-items: center; }
.ovl .lbl { font-size: 10px; font-weight: 900; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); margin-right: 4px; }
.ovl .ov { font-size: 11px; padding: 3px 8px; border-radius: 99px; background: var(--surf-hi); color: var(--text-secondary); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovl .ov b { color: var(--text-primary); font-weight: 700; }
.ovl .ov.gap { background: color-mix(in srgb, var(--warn) 10%, transparent); }
.ovl .ov.more { background: none; color: var(--text-muted); }
/* the proposal board */
.bbtns { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 10px; }
.dlist { list-style: none; margin: 10px 0; padding: 0; display: flex; flex-direction: column; gap: 2px; max-height: 320px; overflow: auto; }