community: declare what you like on soulseek, so the similar-users shelf finally has something to rank on

This commit is contained in:
KeelrLeek committed 2026-09-03 15:05:23 -05:00
1 parent 8093adc89e
commit 7b7f48ebc6
8 files changed
+5671 -5519

No files matched your search

+13
View File
@@ -625,6 +625,19 @@ impl Client {
}
}
/// Declare, or take back, one interest on the server. Fire and forget:
/// the server sends nothing back, and reading our own interests is how you
/// confirm it landed.
pub fn declare_interest(&self, thing: &str, like: bool, on: bool) -> Result<()> {
let msg = match (like, on) {
(true, true) => server::add_thing_i_like(thing),
(true, false) => server::remove_thing_i_like(thing),
(false, true) => server::add_thing_i_hate(thing),
(false, false) => server::remove_thing_i_hate(thing),
};
self.inner.send_server(msg)
}
/// Users the server ranks as similar to us by shared interests. Spawn-shaped.
pub fn similar_users(
&self,
+26
View File
@@ -31,7 +31,11 @@ pub mod code {
pub const PING: u32 = 32;
pub const SHARED_FOLDERS_AND_FILES: u32 = 35;
pub const GET_USER_STATS: u32 = 36;
pub const ADD_THING_I_LIKE: u32 = 51;
pub const REMOVE_THING_I_LIKE: u32 = 52;
pub const USER_INTERESTS: u32 = 57;
pub const ADD_THING_I_HATE: u32 = 117;
pub const REMOVE_THING_I_HATE: u32 = 118;
pub const SIMILAR_USERS: u32 = 110;
pub const ROOM_LIST: u32 = 64;
pub const HAVE_NO_PARENTS: u32 = 71;
@@ -138,6 +142,28 @@ pub fn get_user_stats(username: &str) -> Vec<u8> {
.finish_u32(code::GET_USER_STATS)
}
/// Declare a like (code 51). These are what the server matches on when it
/// ranks similar users, so a client that never sends one is invisible to that
/// ranking however much it reads.
pub fn add_thing_i_like(thing: &str) -> Vec<u8> {
Writer::new().string(thing).finish_u32(code::ADD_THING_I_LIKE)
}
/// Take a like back (code 52).
pub fn remove_thing_i_like(thing: &str) -> Vec<u8> {
Writer::new().string(thing).finish_u32(code::REMOVE_THING_I_LIKE)
}
/// Declare a dislike (code 117).
pub fn add_thing_i_hate(thing: &str) -> Vec<u8> {
Writer::new().string(thing).finish_u32(code::ADD_THING_I_HATE)
}
/// Take a dislike back (code 118).
pub fn remove_thing_i_hate(thing: &str) -> Vec<u8> {
Writer::new().string(thing).finish_u32(code::REMOVE_THING_I_HATE)
}
/// A user's declared likes and hates (code 57).
pub fn user_interests(username: &str) -> Vec<u8> {
Writer::new()
+5571 -5517
View File
File diff suppressed because it is too large. Load diff
+8
View File
@@ -148,6 +148,14 @@ pub struct AppConfig {
/// their status and stats stream in while connected.
#[serde(default)]
pub buddies: Vec<String>,
/// What we have told the Soulseek server we like, and what we do not.
/// Kept here because the server's copy is per account, not per client:
/// signing in from anywhere else can change it under us, so this device's
/// list is re-declared on every login.
#[serde(default)]
pub interests_like: Vec<String>,
#[serde(default)]
pub interests_hate: Vec<String>,
/// The experimental Freenet social layer (crates/freenet-adapter).
/// None → false: strictly opt-in, and flipping it off is the kill
/// switch — the adapter drops its connection and stops retrying. A
+2
View File
@@ -132,6 +132,8 @@ pub fn run() {
commands::slsk_user_info,
commands::slsk_user_interests,
commands::slsk_similar_users,
commands::slsk_interests_mine,
commands::slsk_interest_set,
slsk_link::slsk_link_status,
slsk_link::slsk_link_self,
slsk_link::slsk_link_unlink,
+2
View File
@@ -151,6 +151,8 @@ 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 slskInterestsMine = () => invoke("slsk_interests_mine");
export const slskInterestSet = (thing, like, on) => invoke("slsk_interest_set", { thing, like, on });
export const slskLinkStatus = () => invoke("slsk_link_status");
export const slskLinkSelf = () => invoke("slsk_link_self");
export const slskLinkUnlink = () => invoke("slsk_link_unlink");
+21 -1
View File
@@ -60,6 +60,8 @@ export const community = $state({
interests: {},
/** The server's similar-users answer, or null before it was asked. */
similar: null,
/** What this device has declared on Soulseek — the shelf's only input. */
interests: null,
/** slsk_link_status: our own verified link and everyone we verified. */
links: null,
/** username → the last failed ask's reason. */
@@ -419,6 +421,24 @@ export async function loadUserInfo(username) {
.catch((e) => (community.interests[username] = { error: String(e) }));
}
}
/** What we have told the server we like. The shelf ranks on exactly this. */
export async function refreshInterests() {
try {
community.interests = await api.slskInterestsMine();
} catch {
// not connected; the panel says so
}
}
/** Declare an interest, or take it back, then ask the server again — the
* ranking only moves when its input does. */
export const setInterest = (thing, like, on) =>
run("interest", async () => {
community.interests = await api.slskInterestSet(thing, like, on);
community.similar = null;
await loadSimilar(true);
});
export async function loadSimilar(force = false) {
if (community.similar && !force) return;
community.busy.similar = true;
@@ -706,4 +726,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, refreshOverlap, computeOverlap, forgetOverlap };
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, refreshInterests, setInterest };
+28 -1
View File
@@ -58,6 +58,8 @@
personaKey,
loadUserInfo,
loadSimilar,
refreshInterests,
setInterest,
refreshLinks,
linkSelf,
unlinkSelf,
@@ -603,9 +605,13 @@
$effect(() => {
for (const f of pendingIn()) if (!f.username) untrack(() => loadRemoteProfile(f.persona_key));
});
let likeDraft = $state("");
// Discover's similar-users shelf asks the server once per session.
$effect(() => {
if (community.section === "discover" && online) untrack(() => loadSimilar());
if (community.section === "discover" && online) {
untrack(() => loadSimilar());
untrack(() => refreshInterests());
}
});
function saveMeta(patch = {}) {
if (!drawerUser) return;
@@ -1121,6 +1127,17 @@
<h2 class="ax-title">Similar Soulseek interests</h2>
<span class="prov"><Icon name="heart" size={12} /> the server's match on declared likes · not follows</span>
</div>
{#if online}
<div class="likes">
{#each community.interests?.likes ?? [] as thing (thing)}
<span class="chip acc">{thing}<button class="x" title="Stop declaring {thing}" onclick={() => void setInterest(thing, true, false)} disabled={!!community.busy.interest}>×</button></span>
{/each}
<form class="inl" onsubmit={(e) => { e.preventDefault(); const t = likeDraft.trim(); if (t) { void setInterest(t, true, true); likeDraft = ""; } }}>
<input placeholder="Declare a like — an artist, a label, a genre" maxlength="64" bind:value={likeDraft} />
<button class="ax-ghost sm" type="submit" disabled={!likeDraft.trim() || !!community.busy.interest}>{community.busy.interest ? "Telling the server…" : "Declare"}</button>
</form>
</div>
{/if}
{#if community.similar?.users?.length}
<div class="track">
{#each community.similar.users.slice(0, 24) as su (su.username)}
@@ -2177,6 +2194,16 @@
.field label span.over { color: var(--err); }
.field input, .field textarea { background: var(--surf-hi); border: 1px solid transparent; border-radius: 8px; color: #fff; padding: 8px 12px; font-size: 13px; outline: none; resize: none; font-family: inherit; }
.field input:focus, .field textarea:focus { border-color: rgba(255, 255, 255, 0.28); }
.likes { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 2px 0 12px; }
.likes .inl { flex: 1; min-width: 240px; }
.likes .chip .x {
all: unset;
cursor: pointer;
margin-left: 6px;
opacity: 0.65;
line-height: 1;
}
.likes .chip .x:hover { opacity: 1; }
.inl { display: flex; gap: 8px; align-items: center; }
.inl input { flex: 1; min-width: 0; }
.chk { display: flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--text-muted); }