community: declare what you like on soulseek, so the similar-users shelf finally has something to rank on
This commit is contained in:
1 parent
8093adc89e
commit
7b7f48ebc6
8 files changed
+155
-3
No files matched your search
@@ -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.
|
/// Users the server ranks as similar to us by shared interests. Spawn-shaped.
|
||||||
pub fn similar_users(
|
pub fn similar_users(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ pub mod code {
|
|||||||
pub const PING: u32 = 32;
|
pub const PING: u32 = 32;
|
||||||
pub const SHARED_FOLDERS_AND_FILES: u32 = 35;
|
pub const SHARED_FOLDERS_AND_FILES: u32 = 35;
|
||||||
pub const GET_USER_STATS: u32 = 36;
|
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 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 SIMILAR_USERS: u32 = 110;
|
||||||
pub const ROOM_LIST: u32 = 64;
|
pub const ROOM_LIST: u32 = 64;
|
||||||
pub const HAVE_NO_PARENTS: u32 = 71;
|
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)
|
.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).
|
/// A user's declared likes and hates (code 57).
|
||||||
pub fn user_interests(username: &str) -> Vec<u8> {
|
pub fn user_interests(username: &str) -> Vec<u8> {
|
||||||
Writer::new()
|
Writer::new()
|
||||||
|
|||||||
@@ -499,9 +499,19 @@ pub async fn connect_and_login(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Watch the buddy list so status/stats stream in for the Users tab.
|
// Watch the buddy list so status/stats stream in for the Users tab.
|
||||||
for buddy in &config::load(&app).buddies {
|
let stored_now = config::load(&app);
|
||||||
|
for buddy in &stored_now.buddies {
|
||||||
let _ = client.watch_user(buddy);
|
let _ = client.watch_user(buddy);
|
||||||
}
|
}
|
||||||
|
// Say again what we like. The server ranks similar users on these, and a
|
||||||
|
// client that declares nothing is invisible to that ranking however much
|
||||||
|
// it reads — which is why the Discover shelf had no input.
|
||||||
|
for thing in &stored_now.interests_like {
|
||||||
|
let _ = client.declare_interest(thing, true, true);
|
||||||
|
}
|
||||||
|
for thing in &stored_now.interests_hate {
|
||||||
|
let _ = client.declare_interest(thing, false, true);
|
||||||
|
}
|
||||||
|
|
||||||
*state.client.lock().await = Some(client);
|
*state.client.lock().await = Some(client);
|
||||||
*state.connected_as.lock().await = Some(username.clone());
|
*state.connected_as.lock().await = Some(username.clone());
|
||||||
@@ -1159,6 +1169,50 @@ pub async fn slsk_user_interests(
|
|||||||
Ok(InterestsDto { likes, hates })
|
Ok(InterestsDto { likes, hates })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What this device has declared. The server's copy is per ACCOUNT, so this
|
||||||
|
/// is what we re-assert on login rather than the authority on it.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn slsk_interests_mine(app: tauri::AppHandle) -> InterestsDto {
|
||||||
|
let cfg = config::load(&app);
|
||||||
|
InterestsDto { likes: cfg.interests_like, hates: cfg.interests_hate }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Declare an interest, or take it back. Kept here as well as sent, because
|
||||||
|
/// the account's list can be changed by any other client signed in elsewhere.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn slsk_interest_set(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
thing: String,
|
||||||
|
like: bool,
|
||||||
|
on: bool,
|
||||||
|
) -> Result<InterestsDto, String> {
|
||||||
|
let thing = thing.trim().to_string();
|
||||||
|
if thing.is_empty() {
|
||||||
|
return Err("nothing to declare".into());
|
||||||
|
}
|
||||||
|
if thing.chars().count() > 64 {
|
||||||
|
return Err("an interest is at most 64 characters".into());
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let guard = state.client.lock().await;
|
||||||
|
let client = guard.as_ref().ok_or("not connected to Soulseek")?;
|
||||||
|
client.declare_interest(&thing, like, on).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
let mut cfg = config::load(&app);
|
||||||
|
let list = if like { &mut cfg.interests_like } else { &mut cfg.interests_hate };
|
||||||
|
let at = list.iter().position(|t| t.eq_ignore_ascii_case(&thing));
|
||||||
|
match (on, at) {
|
||||||
|
(true, None) => list.push(thing),
|
||||||
|
(false, Some(i)) => {
|
||||||
|
list.remove(i);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
config::save(&app, &cfg);
|
||||||
|
Ok(InterestsDto { likes: cfg.interests_like, hates: cfg.interests_hate })
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct SimilarUserDto {
|
pub struct SimilarUserDto {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
|||||||
@@ -148,6 +148,14 @@ pub struct AppConfig {
|
|||||||
/// their status and stats stream in while connected.
|
/// their status and stats stream in while connected.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub buddies: Vec<String>,
|
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).
|
/// The experimental Freenet social layer (crates/freenet-adapter).
|
||||||
/// None → false: strictly opt-in, and flipping it off is the kill
|
/// None → false: strictly opt-in, and flipping it off is the kill
|
||||||
/// switch — the adapter drops its connection and stops retrying. A
|
/// switch — the adapter drops its connection and stops retrying. A
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ pub fn run() {
|
|||||||
commands::slsk_user_info,
|
commands::slsk_user_info,
|
||||||
commands::slsk_user_interests,
|
commands::slsk_user_interests,
|
||||||
commands::slsk_similar_users,
|
commands::slsk_similar_users,
|
||||||
|
commands::slsk_interests_mine,
|
||||||
|
commands::slsk_interest_set,
|
||||||
slsk_link::slsk_link_status,
|
slsk_link::slsk_link_status,
|
||||||
slsk_link::slsk_link_self,
|
slsk_link::slsk_link_self,
|
||||||
slsk_link::slsk_link_unlink,
|
slsk_link::slsk_link_unlink,
|
||||||
|
|||||||
@@ -151,6 +151,8 @@ export const slskBrowseUser = (username) => invoke("slsk_browse_user", { usernam
|
|||||||
export const slskUserInfo = (username) => invoke("slsk_user_info", { username });
|
export const slskUserInfo = (username) => invoke("slsk_user_info", { username });
|
||||||
export const slskUserInterests = (username) => invoke("slsk_user_interests", { username });
|
export const slskUserInterests = (username) => invoke("slsk_user_interests", { username });
|
||||||
export const slskSimilarUsers = () => invoke("slsk_similar_users");
|
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 slskLinkStatus = () => invoke("slsk_link_status");
|
||||||
export const slskLinkSelf = () => invoke("slsk_link_self");
|
export const slskLinkSelf = () => invoke("slsk_link_self");
|
||||||
export const slskLinkUnlink = () => invoke("slsk_link_unlink");
|
export const slskLinkUnlink = () => invoke("slsk_link_unlink");
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ export const community = $state({
|
|||||||
interests: {},
|
interests: {},
|
||||||
/** The server's similar-users answer, or null before it was asked. */
|
/** The server's similar-users answer, or null before it was asked. */
|
||||||
similar: null,
|
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. */
|
/** slsk_link_status: our own verified link and everyone we verified. */
|
||||||
links: null,
|
links: null,
|
||||||
/** username → the last failed ask's reason. */
|
/** username → the last failed ask's reason. */
|
||||||
@@ -419,6 +421,24 @@ export async function loadUserInfo(username) {
|
|||||||
.catch((e) => (community.interests[username] = { error: String(e) }));
|
.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) {
|
export async function loadSimilar(force = false) {
|
||||||
if (community.similar && !force) return;
|
if (community.similar && !force) return;
|
||||||
community.busy.similar = true;
|
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
|
// Dev handle beside `__soulstream` (state.svelte.js): probes reach the store
|
||||||
// through this, never through a bare import (which would be a second copy).
|
// 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.__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 };
|
||||||
@@ -58,6 +58,8 @@
|
|||||||
personaKey,
|
personaKey,
|
||||||
loadUserInfo,
|
loadUserInfo,
|
||||||
loadSimilar,
|
loadSimilar,
|
||||||
|
refreshInterests,
|
||||||
|
setInterest,
|
||||||
refreshLinks,
|
refreshLinks,
|
||||||
linkSelf,
|
linkSelf,
|
||||||
unlinkSelf,
|
unlinkSelf,
|
||||||
@@ -603,9 +605,13 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
for (const f of pendingIn()) if (!f.username) untrack(() => loadRemoteProfile(f.persona_key));
|
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.
|
// Discover's similar-users shelf asks the server once per session.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (community.section === "discover" && online) untrack(() => loadSimilar());
|
if (community.section === "discover" && online) {
|
||||||
|
untrack(() => loadSimilar());
|
||||||
|
untrack(() => refreshInterests());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
function saveMeta(patch = {}) {
|
function saveMeta(patch = {}) {
|
||||||
if (!drawerUser) return;
|
if (!drawerUser) return;
|
||||||
@@ -1121,6 +1127,17 @@
|
|||||||
<h2 class="ax-title">Similar Soulseek interests</h2>
|
<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>
|
<span class="prov"><Icon name="heart" size={12} /> the server's match on declared likes · not follows</span>
|
||||||
</div>
|
</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}
|
{#if community.similar?.users?.length}
|
||||||
<div class="track">
|
<div class="track">
|
||||||
{#each community.similar.users.slice(0, 24) as su (su.username)}
|
{#each community.similar.users.slice(0, 24) as su (su.username)}
|
||||||
@@ -2177,6 +2194,16 @@
|
|||||||
.field label span.over { color: var(--err); }
|
.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, .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); }
|
.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 { display: flex; gap: 8px; align-items: center; }
|
||||||
.inl input { flex: 1; min-width: 0; }
|
.inl input { flex: 1; min-width: 0; }
|
||||||
.chk { display: flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--text-muted); }
|
.chk { display: flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--text-muted); }
|
||||||
|
|||||||
Reference in new issue
Block a user