10 Commits
Author SHA1 Message Date
KeelrLeek 67b7444f82 settings: each section heading carries the same icon as its index entry 2026-09-08 16:28:18 -05:00
KeelrLeek 48ae01c753 settings: Appearance gets a real icon — "display" was a CSS property that had leaked into my list of icon names 2026-09-08 16:16:40 -05:00
KeelrLeek a0528c6d3f settings: each index entry carries its section's icon, lit in the accent when it is the one on screen 2026-09-08 15:45:42 -05:00
KeelrLeek 07ac3d3631 settings: the long lists use the app's own chooser — the sort menu's popover under a pill — instead of native selects the webview draws with the OS's text 2026-09-08 15:43:13 -05:00
KeelrLeek 7e291cfccc settings: one long page of section cards behind a sticky index, segments and switches for the choices, and the Networks card moved in from Community 2026-09-08 15:39:19 -05:00
KeelrLeek 6c44f3e2ab community: a chat thread stretches with the pane too — no inner width cap remains 2026-09-08 14:53:05 -05:00
KeelrLeek f006c1cd5c community: the page fills the pane edge to edge instead of sitting in a centred 1520px column 2026-09-08 12:55:10 -05:00
KeelrLeek b8e6fb7396 who has this: a gap card says who holds it, the sheet and the open album name them, a Space grows a Holdings tab, and Get it is the hub's own grab or a wait for their presence 2026-09-08 12:10:49 -05:00
KeelrLeek a98c750908 holdings: a member of a Space publishes what they hold as room-salted album keys, sealed to the room, so "who has this" is answered while they are offline 2026-09-08 12:10:49 -05:00
KeelrLeek 08ca09586a community: Discover ranks the shelves it already has into one, and every card still says where it came from 2026-09-08 09:48:07 -05:00
28 changed files with 4458 additions and 836 deletions

No files matched your search

+1449
View File
File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "soulstream-holdings-contract"
version = "0.1.0"
edition = "2021"
description = "The SoulStream holdings contract — one member's album list in one Space, a thin shell over persona_state::holdings. Built to wasm32-unknown-unknown and bundled into the app; its code hash is a commitment (see src-tauri/assets)."
[lib]
crate-type = ["cdylib"]
[dependencies]
freenet-stdlib = { version = "=0.8.5" }
persona-state = { path = "../persona-state" }
bincode = "1"
blake3 = "1"
[features]
default = ["freenet-main-contract", "contract"]
freenet-main-contract = []
contract = ["freenet-stdlib/contract"]
trace = ["freenet-stdlib/trace"]
[profile.release]
strip = true
lto = true
# Its own workspace: this crate targets wasm32 and must never join the
# app's host build.
[workspace]
+132
View File
@@ -0,0 +1,132 @@
//! The SoulStream holdings contract. Single writer: one member's latest
//! signed album list in one Space, at the address (space id, member key).
//! Merge is the pointer contract's total order, (sequence, blake3) —
//! `persona_state::holdings::merge` decides, so every delivery order
//! converges, and a stale replay cannot bring a withdrawn list back.
//!
//! The body is opaque here: sealed to the room's epoch key in a private
//! Space, and the contract only ever checks the envelope and its signature.
//!
//! Build: `cargo build --release --target wasm32-unknown-unknown` in this
//! directory; copy `target/wasm32-unknown-unknown/release/
//! soulstream_holdings_contract.wasm` to `src-tauri/assets/holdings_contract.wasm`
//! and re-pin the code hash in `src-tauri/src/holdings.rs`.
use freenet_stdlib::prelude::*;
use persona_state::holdings as t;
pub struct Contract;
fn parts(parameters: &Parameters<'static>) -> Result<([u8; 16], [u8; 32]), ContractError> {
t::params_parts(parameters.as_ref())
.ok_or_else(|| ContractError::Other("params are not family+space+member".into()))
}
fn summary_of(bytes: &[u8], s: &t::HoldingsState) -> Vec<u8> {
let (seq, hash) = t::rank(bytes, s);
let mut out = Vec::with_capacity(40);
out.extend_from_slice(&seq.to_le_bytes());
out.extend_from_slice(&hash);
out
}
#[contract]
impl ContractInterface for Contract {
fn validate_state(
parameters: Parameters<'static>,
state: State<'static>,
_related: RelatedContracts<'static>,
) -> Result<ValidateResult, ContractError> {
let (space_id, member) = parts(&parameters)?;
if state.as_ref().is_empty() {
return Ok(ValidateResult::Valid);
}
match t::validate(&space_id, &member, state.as_ref()) {
Ok(_) => Ok(ValidateResult::Valid),
Err(e) => Err(ContractError::Other(format!("invalid holdings: {e}"))),
}
}
fn update_state(
parameters: Parameters<'static>,
state: State<'static>,
data: Vec<UpdateData<'static>>,
) -> Result<UpdateModification<'static>, ContractError> {
let (space_id, member) = parts(&parameters)?;
let mut candidates: Vec<Vec<u8>> = Vec::new();
if !state.as_ref().is_empty() {
candidates.push(state.as_ref().to_vec());
}
let mut incoming_valid = 0usize;
for ud in &data {
let mut push = |bytes: &[u8]| {
if bytes.is_empty() {
return;
}
if t::validate(&space_id, &member, bytes).is_ok() {
incoming_valid += 1;
}
candidates.push(bytes.to_vec());
};
match ud {
UpdateData::State(s) => push(s.as_ref()),
UpdateData::Delta(d) => push(d.as_ref()),
UpdateData::StateAndDelta { state, delta } => {
push(state.as_ref());
push(delta.as_ref());
}
_ => {}
}
}
if incoming_valid == 0 && !data.is_empty() {
return Err(ContractError::InvalidUpdate);
}
match t::merge(&space_id, &member, candidates.iter().map(|v| v.as_slice())) {
Some(bytes) => Ok(UpdateModification::valid(State::from(bytes))),
None => Ok(UpdateModification::valid(state)),
}
}
fn summarize_state(
parameters: Parameters<'static>,
state: State<'static>,
) -> Result<StateSummary<'static>, ContractError> {
let (space_id, member) = parts(&parameters)?;
if state.as_ref().is_empty() {
return Ok(StateSummary::from(Vec::new()));
}
let s = t::validate(&space_id, &member, state.as_ref())
.map_err(|e| ContractError::Other(format!("invalid holdings: {e}")))?;
Ok(StateSummary::from(summary_of(state.as_ref(), &s)))
}
fn get_state_delta(
parameters: Parameters<'static>,
state: State<'static>,
summary: StateSummary<'static>,
) -> Result<StateDelta<'static>, ContractError> {
let (space_id, member) = parts(&parameters)?;
if state.as_ref().is_empty() {
return Ok(StateDelta::from(Vec::new()));
}
let s = t::validate(&space_id, &member, state.as_ref())
.map_err(|e| ContractError::Other(format!("invalid holdings: {e}")))?;
let ours = t::rank(state.as_ref(), &s);
let theirs = {
let b = summary.as_ref();
if b.len() == 40 {
let seq = u64::from_le_bytes(b[..8].try_into().unwrap());
let mut h = [0u8; 32];
h.copy_from_slice(&b[8..40]);
Some((seq, h))
} else {
None
}
};
let newer = match theirs {
Some(t) => ours > t,
None => true,
};
Ok(StateDelta::from(if newer { state.as_ref().to_vec() } else { Vec::new() }))
}
}
+8
View File
@@ -811,6 +811,14 @@ impl Library {
Ok(rows.filter_map(|r| r.ok()).collect()) Ok(rows.filter_map(|r| r.ok()).collect())
} }
/// Album id → MusicBrainz release id, for the albums that have one.
pub fn album_mbids(&self) -> Result<std::collections::BTreeMap<i64, String>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT id, mbid FROM albums WHERE mbid IS NOT NULL AND mbid != ''")?;
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
Ok(rows.filter_map(|r| r.ok()).collect())
}
/// Per-album aggregates for the Library page: what the FILES add up to. /// Per-album aggregates for the Library page: what the FILES add up to.
/// One call for the whole library, because the page derives its stat row, /// One call for the whole library, because the page derives its stat row,
/// its Format grouping and four of its sorts (length, size, bitrate, /// its Format grouping and four of its sorts (length, size, bitrate,
+446
View File
@@ -0,0 +1,446 @@
//! Holdings — what a member of a Space holds, as album keys and never as
//! files. One contract per (space, member): the member's latest signed list,
//! sealed to the room's epoch key in a private Space. Merge is the pointer
//! contract's total order, (sequence, blake3): a withdrawal is just a newer
//! sequence with an empty body, and a stale replay cannot resurrect a list.
//!
//! An album is named by a key, not a name: blake3 over the room's id and the
//! same normalised (artist, album) the library-overlap comparison uses, so a
//! holding matches exactly when overlap would have said "you own this". The
//! room id salts it — the same album hashes differently in every room, so
//! two rooms' lists cannot be joined. A member can still learn whether you
//! hold any album they can NAME, by hashing it and looking; that is the
//! feature, and the switch's disclosure says so.
//!
//! Nothing here reads a library or a network; the app does both.
use serde::{Deserialize, Serialize};
pub const SCHEMA_VERSION: u32 = 1;
pub const PARAMS_FAMILY: &[u8] = b"soulstream.holdings.v1";
/// The Space family's domain, not one of its own: the identity delegate
/// signs only under an allowlist, and a new domain would be a delegate
/// generation (a migration event) for the one case — an owner signing their
/// own list — that needs it. The signed payload's shape keeps a holdings
/// signature from ever reading as a Space message or a checkpoint.
pub const SIGN_DOMAIN: &str = crate::space::SIGN_DOMAIN;
/// The contract's gate: envelope plus body, sealed or not.
pub const MAX_STATE_BYTES: usize = 1024 * 1024;
/// Entries per list. A `Holding` is at most 66 bytes on the wire, so this
/// keeps a full list — with its username and the envelope — under the gate
/// with room to spare. A library above it publishes its newest entries.
pub const MAX_ENTRIES: usize = 14_000;
pub const MAX_USERNAME_BYTES: usize = 64;
pub const TIER_UNKNOWN: u8 = 0;
pub const TIER_LOSSY: u8 = 1;
pub const TIER_LOSSLESS: u8 = 2;
/// One album, as the room sees it.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Holding {
/// `album_key` of the normalised (artist, album), salted with the room.
pub key: [u8; 32],
/// `mbid_key` when the library knows the release's MusicBrainz id.
pub mbid: Option<[u8; 32]>,
/// One word about quality: lossless, lossy, or unknown. Never a format,
/// a bitrate, or a size.
pub tier: u8,
}
/// The plaintext a list carries. Sealed in a private Space, so the Soulseek
/// name inside it is readable by members of that epoch only — it is what
/// lets "who has this" become "get it from them".
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct HoldingsBody {
pub schema_version: u32,
pub slsk_username: Option<String>,
/// Sorted by key, no duplicates — `encode_body` enforces it.
pub entries: Vec<Holding>,
}
/// The contract state: the member's latest list, signed by the key they
/// wear in the room.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HoldingsState {
pub schema_version: u32,
pub space_id: [u8; 16],
pub member: [u8; 32],
pub sequence: u64,
/// The epoch whose key sealed `body`; readers look their key up by it.
pub epoch: u64,
/// False in a public Space: `body` is the plain encoding.
pub sealed: bool,
pub published_at: u64,
/// Withdrawn: the list is gone from current state (`body` empty). Copies
/// members already read stay with them — never claim erasure.
pub withdrawn: bool,
pub body: Vec<u8>,
pub sig: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Invalid {
Oversized(usize),
Malformed,
WrongSchema(u32),
WrongAddress,
BadSignature,
TooManyEntries(usize),
FieldTooLong(&'static str, usize),
}
impl core::fmt::Display for Invalid {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Invalid::Oversized(n) => write!(f, "{n} bytes is over the cap"),
Invalid::Malformed => write!(f, "does not decode"),
Invalid::WrongSchema(v) => write!(f, "schema {v} is not {SCHEMA_VERSION}"),
Invalid::WrongAddress => write!(f, "names another room or member"),
Invalid::BadSignature => write!(f, "the signature does not verify"),
Invalid::TooManyEntries(n) => write!(f, "{n} entries is over the cap of {MAX_ENTRIES}"),
Invalid::FieldTooLong(what, n) => write!(f, "{what} is {n} bytes, over its cap"),
}
}
}
// ---------------------------------------------------------------- params
pub fn make_params(space_id: &[u8; 16], member: &[u8; 32]) -> Vec<u8> {
let mut p = Vec::with_capacity(PARAMS_FAMILY.len() + 48);
p.extend_from_slice(PARAMS_FAMILY);
p.extend_from_slice(space_id);
p.extend_from_slice(member);
p
}
pub fn params_parts(params: &[u8]) -> Option<([u8; 16], [u8; 32])> {
let t = PARAMS_FAMILY.len();
if params.len() != t + 48 || &params[..t] != PARAMS_FAMILY {
return None;
}
Some((params[t..t + 16].try_into().unwrap(), params[t + 16..t + 48].try_into().unwrap()))
}
// ---------------------------------------------------------------- keys
/// The key an album is held under in ONE room. `artist` and `album` are
/// the overlap normalisation's output (lowercase alphanumerics, brackets
/// gone, a leading "the" and a filing year dropped) — the caller's job, so
/// a wasm build of this module never carries the normaliser.
pub fn album_key(space_id: &[u8; 16], artist: &str, album: &str) -> [u8; 32] {
let mut h = blake3::Hasher::new_derive_key("soulstream.holding.v1");
h.update(space_id);
h.update(artist.as_bytes());
h.update(&[0]);
h.update(album.as_bytes());
*h.finalize().as_bytes()
}
/// The same album by its MusicBrainz release id, for the day identity is
/// anchored rather than named.
pub fn mbid_key(space_id: &[u8; 16], mbid: &str) -> [u8; 32] {
let mut h = blake3::Hasher::new_derive_key("soulstream.holding-mbid.v1");
h.update(space_id);
h.update(mbid.trim().to_lowercase().as_bytes());
*h.finalize().as_bytes()
}
// ---------------------------------------------------------------- body
/// Encode a list for the wire: sorted by key, duplicates folded (the better
/// tier wins), caps enforced.
pub fn encode_body(mut b: HoldingsBody) -> Result<Vec<u8>, Invalid> {
if let Some(u) = &b.slsk_username {
if u.len() > MAX_USERNAME_BYTES {
return Err(Invalid::FieldTooLong("slsk_username", u.len()));
}
}
b.schema_version = SCHEMA_VERSION;
b.entries.sort_by(|x, y| x.key.cmp(&y.key).then(y.tier.cmp(&x.tier)));
b.entries.dedup_by(|later, earlier| {
if later.key != earlier.key {
return false;
}
// The first of a run has the higher tier already; keep its mbid if
// the later one had none.
if earlier.mbid.is_none() {
earlier.mbid = later.mbid;
}
true
});
if b.entries.len() > MAX_ENTRIES {
return Err(Invalid::TooManyEntries(b.entries.len()));
}
bincode::serialize(&b).map_err(|_| Invalid::Malformed)
}
pub fn decode_body(bytes: &[u8]) -> Option<HoldingsBody> {
let b: HoldingsBody = bincode::deserialize(bytes).ok()?;
if b.schema_version != SCHEMA_VERSION || b.entries.len() > MAX_ENTRIES {
return None;
}
Some(b)
}
// ---------------------------------------------------------------- sealing
fn body_key(space_key: &[u8; 32]) -> [u8; 32] {
blake3::derive_key("soulstream.holdings-body.v1", space_key)
}
/// The AAD binds a body to its envelope: another member's sealed bytes
/// under this address, or this member's older sequence, do not open.
fn aad(s: &HoldingsState) -> Vec<u8> {
let mut v = Vec::with_capacity(64);
v.extend_from_slice(&s.space_id);
v.extend_from_slice(&s.member);
v.extend_from_slice(&s.sequence.to_le_bytes());
v.extend_from_slice(&s.epoch.to_le_bytes());
v
}
/// Unique per (member, sequence) under one room — a sequence is never
/// published twice by the same key, which is the nonce's whole contract.
fn nonce(s: &HoldingsState) -> [u8; 12] {
blake3::hash(&aad(s)).as_bytes()[..12].try_into().unwrap()
}
/// Seal `s.body` (the plain encoding going in) under the room's epoch key.
/// Call BEFORE signing; the signature covers the sealed bytes.
pub fn seal(space_key: &[u8; 32], s: &HoldingsState) -> Vec<u8> {
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
let key = body_key(space_key);
ChaCha20Poly1305::new(Key::from_slice(&key))
.encrypt(Nonce::from_slice(&nonce(s)), Payload { msg: &s.body, aad: &aad(s) })
.expect("seal")
}
/// Open a sealed body. None = wrong key for this epoch, or tampered.
pub fn open(space_key: &[u8; 32], s: &HoldingsState) -> Option<Vec<u8>> {
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
let key = body_key(space_key);
ChaCha20Poly1305::new(Key::from_slice(&key))
.decrypt(Nonce::from_slice(&nonce(s)), Payload { msg: &s.body, aad: &aad(s) })
.ok()
}
// ---------------------------------------------------------------- signing
#[derive(Serialize)]
struct SigBody<'a> {
schema_version: u32,
space_id: &'a [u8; 16],
member: &'a [u8; 32],
sequence: u64,
epoch: u64,
sealed: bool,
published_at: u64,
withdrawn: bool,
body: &'a [u8],
}
/// What the member signs: blake3 of the envelope, the sealed body included.
pub fn sign_payload(s: &HoldingsState) -> Vec<u8> {
let body = bincode::serialize(&SigBody {
schema_version: s.schema_version,
space_id: &s.space_id,
member: &s.member,
sequence: s.sequence,
epoch: s.epoch,
sealed: s.sealed,
published_at: s.published_at,
withdrawn: s.withdrawn,
body: &s.body,
})
.expect("sig body serializes");
blake3::hash(&body).as_bytes().to_vec()
}
/// Sign as the key worn in the room (a room-local member key, or an
/// owner's persona through the delegate — the app decides which).
pub fn sign_with(secret: &[u8; 32], s: &HoldingsState) -> Vec<u8> {
crate::sign_local(SIGN_DOMAIN, secret, &sign_payload(s))
}
// ---------------------------------------------------------------- validate / merge
/// Strict, in the plan's order: size → parse → schema → address → caps →
/// signature. A state that passes is exactly what its member published.
pub fn validate(space_id: &[u8; 16], member: &[u8; 32], bytes: &[u8]) -> Result<HoldingsState, Invalid> {
if bytes.len() > MAX_STATE_BYTES {
return Err(Invalid::Oversized(bytes.len()));
}
let s: HoldingsState = bincode::deserialize(bytes).map_err(|_| Invalid::Malformed)?;
if s.schema_version != SCHEMA_VERSION {
return Err(Invalid::WrongSchema(s.schema_version));
}
if &s.space_id != space_id || &s.member != member {
return Err(Invalid::WrongAddress);
}
if s.withdrawn && !s.body.is_empty() {
return Err(Invalid::Malformed);
}
if !crate::verify_sig_in(SIGN_DOMAIN, member, &sign_payload(&s), &s.sig) {
return Err(Invalid::BadSignature);
}
Ok(s)
}
/// The order every replica agrees on: highest sequence, then the byte hash.
pub fn rank(bytes: &[u8], s: &HoldingsState) -> (u64, [u8; 32]) {
(s.sequence, *blake3::hash(bytes).as_bytes())
}
/// The winning candidate's bytes, or None when nothing valid was offered.
pub fn merge<'a>(space_id: &[u8; 16], member: &[u8; 32], candidates: impl IntoIterator<Item = &'a [u8]>) -> Option<Vec<u8>> {
let mut best: Option<((u64, [u8; 32]), &[u8])> = None;
for bytes in candidates {
let Ok(s) = validate(space_id, member, bytes) else { continue };
let r = rank(bytes, &s);
if best.as_ref().is_none_or(|(b, _)| r > *b) {
best = Some((r, bytes));
}
}
best.map(|(_, b)| b.to_vec())
}
#[cfg(test)]
mod tests {
use super::*;
fn secret(n: u8) -> [u8; 32] {
[n; 32]
}
fn state(secret: &[u8; 32], seq: u64, body: Vec<u8>, withdrawn: bool) -> HoldingsState {
let mut s = HoldingsState {
schema_version: SCHEMA_VERSION,
space_id: [3; 16],
member: crate::public_of(secret),
sequence: seq,
epoch: 4,
sealed: false,
published_at: 1_700_000_000,
withdrawn,
body,
sig: vec![],
};
s.sig = sign_with(secret, &s);
s
}
fn bytes(s: &HoldingsState) -> Vec<u8> {
bincode::serialize(s).unwrap()
}
fn holding(n: u8, tier: u8) -> Holding {
Holding { key: [n; 32], mbid: None, tier }
}
#[test]
fn a_list_round_trips_and_refuses_tampering() {
let k = secret(7);
let member = crate::public_of(&k);
let body = encode_body(HoldingsBody { schema_version: 0, slsk_username: Some("zoocone".into()), entries: vec![holding(2, TIER_LOSSY), holding(1, TIER_LOSSLESS)] }).unwrap();
let s = state(&k, 1, body.clone(), false);
let b = bytes(&s);
let back = validate(&[3; 16], &member, &b).unwrap();
assert_eq!(back, s);
let d = decode_body(&back.body).unwrap();
assert_eq!(d.entries.iter().map(|h| h.key[0]).collect::<Vec<_>>(), [1, 2], "sorted by key");
assert_eq!(d.slsk_username.as_deref(), Some("zoocone"));
// another room, another member, a flipped byte, a foreign signature
assert_eq!(validate(&[4; 16], &member, &b), Err(Invalid::WrongAddress));
assert_eq!(validate(&[3; 16], &crate::public_of(&secret(8)), &b), Err(Invalid::WrongAddress));
let mut t = s.clone();
t.sequence = 9;
assert_eq!(validate(&[3; 16], &member, &bytes(&t)), Err(Invalid::BadSignature));
let mut f = s.clone();
f.sig = sign_with(&secret(8), &f);
assert_eq!(validate(&[3; 16], &member, &bytes(&f)), Err(Invalid::BadSignature));
let mut w = state(&k, 2, body, true);
w.sig = sign_with(&k, &w);
assert_eq!(validate(&[3; 16], &member, &bytes(&w)), Err(Invalid::Malformed), "withdrawn keeps no body");
}
#[test]
fn merge_is_by_sequence_then_hash_and_a_replay_cannot_resurrect() {
let k = secret(5);
let member = crate::public_of(&k);
let one = bytes(&state(&k, 1, vec![1], false));
let two = bytes(&state(&k, 2, vec![2], false));
let gone = bytes(&state(&k, 3, vec![], true));
let stranger = bytes(&state(&secret(6), 9, vec![9], false));
let sid = [3; 16];
assert_eq!(merge(&sid, &member, [one.as_slice(), two.as_slice()]), Some(two.clone()));
assert_eq!(merge(&sid, &member, [two.as_slice(), one.as_slice()]), Some(two.clone()));
assert_eq!(merge(&sid, &member, [gone.as_slice(), two.as_slice(), one.as_slice()]), Some(gone.clone()));
assert_eq!(merge(&sid, &member, [stranger.as_slice()]), None);
assert_eq!(merge(&sid, &member, [stranger.as_slice(), one.as_slice()]), Some(one.clone()));
// same sequence twice: the hash breaks the tie the same way in any order
let a = bytes(&state(&k, 4, vec![1, 1], false));
let b = bytes(&state(&k, 4, vec![2, 2], false));
let ab = merge(&sid, &member, [a.as_slice(), b.as_slice()]);
let ba = merge(&sid, &member, [b.as_slice(), a.as_slice()]);
assert_eq!(ab, ba);
assert!(ab == Some(a.clone()) || ab == Some(b.clone()));
}
#[test]
fn a_private_list_opens_only_with_the_epoch_key_and_only_in_its_envelope() {
let k = secret(2);
let room_key = [11u8; 32];
let plain = encode_body(HoldingsBody { schema_version: 0, slsk_username: None, entries: vec![holding(1, TIER_LOSSLESS)] }).unwrap();
let mut s = state(&k, 1, plain.clone(), false);
s.sealed = true;
s.body = seal(&room_key, &s);
s.sig = sign_with(&k, &s);
assert_ne!(s.body, plain);
assert_eq!(open(&room_key, &s), Some(plain.clone()));
assert_eq!(open(&[12u8; 32], &s), None);
// the same ciphertext under another sequence is not this list
let mut moved = s.clone();
moved.sequence = 2;
assert_eq!(open(&room_key, &moved), None);
// validate does not need the key: the envelope stands on its own
assert!(validate(&[3; 16], &crate::public_of(&k), &bytes(&s)).is_ok());
}
#[test]
fn keys_are_salted_by_room_and_the_body_folds_duplicates_to_the_better_tier() {
let a = album_key(&[1; 16], "broadcast", "tenderbuttons");
let b = album_key(&[2; 16], "broadcast", "tenderbuttons");
assert_ne!(a, b);
assert_eq!(a, album_key(&[1; 16], "broadcast", "tenderbuttons"));
assert_ne!(a, album_key(&[1; 16], "broadcasttender", "buttons"), "the separator keeps the two names apart");
assert_ne!(mbid_key(&[1; 16], "abc"), mbid_key(&[2; 16], "ABC"));
assert_eq!(mbid_key(&[1; 16], " abc "), mbid_key(&[1; 16], "ABC"));
let body = encode_body(HoldingsBody {
schema_version: 0,
slsk_username: None,
entries: vec![
Holding { key: [1; 32], mbid: None, tier: TIER_LOSSY },
Holding { key: [1; 32], mbid: Some([9; 32]), tier: TIER_LOSSLESS },
Holding { key: [1; 32], mbid: None, tier: TIER_UNKNOWN },
],
})
.unwrap();
let d = decode_body(&body).unwrap();
assert_eq!(d.entries, vec![Holding { key: [1; 32], mbid: Some([9; 32]), tier: TIER_LOSSLESS }]);
let too_many = HoldingsBody { schema_version: 0, slsk_username: None, entries: (0..MAX_ENTRIES + 1).map(|i| Holding { key: blake3::hash(&i.to_le_bytes()).into(), mbid: None, tier: 0 }).collect() };
assert!(matches!(encode_body(too_many), Err(Invalid::TooManyEntries(_))));
let long = HoldingsBody { schema_version: 0, slsk_username: Some("x".repeat(65)), entries: vec![] };
assert!(matches!(encode_body(long), Err(Invalid::FieldTooLong("slsk_username", 65))));
}
#[test]
fn params_round_trip() {
let p = make_params(&[3; 16], &[4; 32]);
assert_eq!(params_parts(&p), Some(([3; 16], [4; 32])));
assert_eq!(params_parts(&p[..p.len() - 1]), None);
assert_eq!(params_parts(&crate::space::make_params(&[4; 32], &[3; 16])), None);
}
}
+1
View File
@@ -21,6 +21,7 @@ pub mod playlist;
pub mod inbox; pub mod inbox;
pub mod space; pub mod space;
pub mod proposals; pub mod proposals;
pub mod holdings;
pub const SCHEMA_VERSION: u32 = 1; pub const SCHEMA_VERSION: u32 = 1;
pub const SIGN_DOMAIN: &str = "soulstream.profile/v1"; pub const SIGN_DOMAIN: &str = "soulstream.profile/v1";
+1 -1
View File
@@ -7,7 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"tauri": "tauri", "tauri": "tauri",
"test": "node --test \"tests/preview/*.test.mjs\"" "test": "node --test \"tests/preview/*.test.mjs\" \"tests/discover/*.test.mjs\""
}, },
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
Binary file not shown.
+809
View File
@@ -0,0 +1,809 @@
//! Holdings — "who has this, and get it from them".
//!
//! Each member of a Space may publish what they HOLD: the albums of their
//! library as room-salted keys (`persona_state::holdings::album_key` over
//! `overlap::norm`), one quality word each, and their Soulseek name — sealed
//! to the room's epoch key in a private Space, signed by the key they wear
//! there, one contract per (room, member). Never files, paths, sizes or
//! bitrates: the same rule the overlap shelf lives by, and the switch's
//! disclosure says exactly what a member can learn.
//!
//! Reading a room caches each member's list on disk (`holdings.json`, the
//! overlap ledger's pattern), so the ASK is local and instant and works with
//! the holders offline: hash the album per room, look it up, name the
//! members. "Get it" is the hub's album grab aimed at that member's share —
//! `find_folder` walks their browse tree with overlap's `pair_of` and hands
//! the files back for the frontend to queue; a holder who is offline goes on
//! a local wait list the frontend drains when their presence flips.
//!
//! Nothing is ever pushed to a holder. The only new network object is the
//! list, and only the room's members can read it.
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use freenet_adapter::FreenetAdapter;
use persona_state::holdings as hd;
use serde::{Deserialize, Serialize};
use tauri::{Emitter, Manager};
use crate::pipeline::LibraryState;
use crate::state::AppState;
use crate::{ledger, overlap, persona, slsk_link, spaces};
const HOLDINGS_WASM: &[u8] = include_bytes!("../assets/holdings_contract.wasm");
/// The ledger changed (a publish, a read, a wait): the page re-reads.
pub const EVENT: &str = "community:holdings";
const LEDGER_FILE: &str = "holdings.json";
/// A member's cached list is re-read after this long, or on Refresh.
const FRESH_SECS: u64 = 10 * 60;
/// A member with no list is asked again after this long: a GET for a
/// contract nobody put is a ring search that costs seconds.
const ABSENT_SECS: u64 = 60 * 60;
const LOSSLESS: &[&str] = &["flac", "alac", "wav", "aiff", "aif", "ape", "wv", "dsf", "dff", "tta", "shn"];
const LOSSY: &[&str] = &["mp3", "ogg", "oga", "opus", "m4a", "aac", "wma", "mpc"];
// ---------------------------------------------------------------- ledger
/// What this device last published into one room.
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct Mine {
pub sequence: u64,
pub epoch: u64,
pub published_at: u64,
pub count: u32,
/// blake3 over the sorted keys, so a library that has not changed does
/// not republish.
pub digest: String,
#[serde(default)]
pub withdrawn: bool,
}
/// One member's list as this device last read it.
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
struct Cached {
sequence: u64,
epoch: u64,
read_at: u64,
/// No contract under that address when last asked.
absent: bool,
/// Sealed to an epoch this device has no key for.
unreadable: bool,
withdrawn: bool,
slsk_username: Option<String>,
/// (key b58, mbid key b58, tier) — the same three the wire carries.
entries: Vec<(String, Option<String>, u8)>,
}
/// An album to fetch from someone the moment they come online.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Wait {
pub username: String,
pub artist: String,
pub album: String,
pub since: u64,
/// The room the holder was found in, for the row's provenance.
#[serde(default)]
pub via: Option<String>,
}
#[derive(Default, Serialize, Deserialize)]
struct Ledger {
#[serde(default)]
mine: BTreeMap<String, Mine>,
/// room record id → member key (b58) → the list.
#[serde(default)]
cache: BTreeMap<String, BTreeMap<String, Cached>>,
#[serde(default)]
waits: Vec<Wait>,
}
fn path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
Ok(crate::paths::app_data(app)?.join("freenet").join(LEDGER_FILE))
}
fn load(app: &tauri::AppHandle) -> Ledger {
path(app)
.ok()
.and_then(|p| std::fs::read(p).ok())
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
fn save(app: &tauri::AppHandle, l: &Ledger) -> Result<(), String> {
let p = path(app)?;
if let Some(dir) = p.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
std::fs::write(&p, serde_json::to_vec(l).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
let _ = app.emit(EVENT, ());
Ok(())
}
fn now_secs() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
fn b58(bytes: &[u8]) -> String {
bs58::encode(bytes).into_string()
}
// ---------------------------------------------------------------- my list
/// The tier a dominant extension speaks for.
fn tier_of(ext: &str) -> u8 {
let e = ext.to_lowercase();
if LOSSLESS.contains(&e.as_str()) {
hd::TIER_LOSSLESS
} else if LOSSY.contains(&e.as_str()) {
hd::TIER_LOSSY
} else {
hd::TIER_UNKNOWN
}
}
/// The library as one room would hold it: keyed, tiered, sorted. Reads the
/// database, so it runs off the async runtime.
fn my_list(lib: &library::Library, space_id: &[u8; 16]) -> Result<(Vec<hd::Holding>, u32), String> {
let albums = lib.list_albums().map_err(|e| e.to_string())?;
let mbids = lib.album_mbids().unwrap_or_default();
let tiers: BTreeMap<i64, u8> = lib
.album_stats(0)
.map_err(|e| e.to_string())?
.into_iter()
.map(|s| (s.album_id, s.formats.first().map(|f| tier_of(f)).unwrap_or(hd::TIER_UNKNOWN)))
.collect();
let total = albums.len() as u32;
// Newest first is the library's own order, so a list over the cap keeps
// what was added last.
let entries: Vec<hd::Holding> = albums
.iter()
.filter_map(|a| {
let (na, nt) = (overlap::norm(&a.artist), overlap::norm(&a.title));
if na.is_empty() || nt.is_empty() {
return None;
}
Some(hd::Holding {
key: hd::album_key(space_id, &na, &nt),
mbid: mbids.get(&a.id).map(|m| hd::mbid_key(space_id, m)),
tier: tiers.get(&a.id).copied().unwrap_or(hd::TIER_UNKNOWN),
})
})
.take(hd::MAX_ENTRIES)
.collect();
Ok((entries, total))
}
fn digest_of(entries: &[hd::Holding]) -> String {
let mut buf = Vec::with_capacity(entries.len() * 33);
for e in entries {
buf.extend_from_slice(&e.key);
buf.push(e.tier);
}
b58(&persona_state::content_hash(&buf))
}
// ---------------------------------------------------------------- network
fn adapter(app: &tauri::AppHandle) -> FreenetAdapter {
app.state::<AppState>().freenet.clone()
}
/// The list at one address, verified. None when nothing was ever put there.
async fn fetch(app: &tauri::AppHandle, space_id: &[u8; 16], member: &[u8; 32]) -> Result<Option<hd::HoldingsState>, String> {
let ids = FreenetAdapter::contract_ids(HOLDINGS_WASM.to_vec(), &hd::make_params(space_id, member));
match adapter(app).contract_get(&ids.instance, false).await {
Ok(bytes) if bytes.is_empty() => Ok(None),
Ok(bytes) => hd::validate(space_id, member, &bytes)
.map(Some)
.map_err(|e| format!("holdings failed verification: {e}")),
Err(e) if e.to_string().contains("not found") => Ok(None),
Err(e) => Err(e.to_string()),
}
}
async fn offer(app: &tauri::AppHandle, space_id: &[u8; 16], member: &[u8; 32], state: &hd::HoldingsState, first: bool) -> Result<(), String> {
let bytes = bincode::serialize(state).map_err(|e| e.to_string())?;
let params = hd::make_params(space_id, member);
let a = adapter(app);
if first {
a.contract_put(HOLDINGS_WASM.to_vec(), &params, bytes, false).await.map(|_| ())
} else {
a.get_then_update(HOLDINGS_WASM.to_vec(), &params, bytes).await.map(|_| ())
}
.map_err(|e| e.to_string())
}
/// Sign as the key worn in the room: the room secret for a member, the
/// delegate for an owner (their persona is their key there).
async fn sign(app: &tauri::AppHandle, me: &[u8; 32], worn: &[u8; 32], room_secret: Option<[u8; 32]>, s: &hd::HoldingsState) -> Result<Vec<u8>, String> {
match room_secret {
Some(secret) if worn != me => Ok(hd::sign_with(&secret, s)),
_ => persona::sign(&adapter(app), hd::SIGN_DOMAIN, hd::sign_payload(s)).await,
}
}
/// Everything a publish or a withdrawal needs to know about where it goes.
struct Seat {
me: [u8; 32],
worn: [u8; 32],
room_secret: Option<[u8; 32]>,
space_id: [u8; 16],
epoch: u64,
epoch_key: Option<[u8; 32]>,
}
async fn seat(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<Seat, String> {
let me = spaces::my_key(app).await?;
let owner = spaces::key_of(owner_b58)?;
let space_id = spaces::id_of(space_b58)?;
spaces::room_facts(app, owner_b58, space_b58).ok_or("not a Space this device knows")?;
let (worn, room_secret) = spaces::worn_identity(app, owner_b58, space_b58, &me);
let (state, _) = spaces::fetch_state(app, &owner, &space_id).await?;
let c = state.checkpoint.ok_or("the Space has not reached this node yet")?;
if !c.members.iter().any(|m| m.key == worn) {
return Err("you are not in this Space's roster".into());
}
let epoch_key = if c.private {
Some(spaces::epoch_key_for(app, owner_b58, space_b58, c.epoch).ok_or("no key for this epoch yet — the owner has to hand it to you")?)
} else {
None
};
Ok(Seat { me, worn, room_secret, space_id, epoch: c.epoch, epoch_key })
}
/// Publish this device's list into one room. Read-and-bump: the sequence
/// follows whatever is on the network, so a device that lost its ledger
/// cannot publish under a sequence the room has already seen.
pub async fn publish(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<Mine, String> {
let st = seat(app, owner_b58, space_b58).await?;
let lib = app.state::<LibraryState>().library.clone();
let sid = st.space_id;
let (entries, total) = tauri::async_runtime::spawn_blocking(move || my_list(&lib, &sid))
.await
.map_err(|e| e.to_string())??;
let count = entries.len() as u32;
let digest = digest_of(&entries);
let body = hd::encode_body(hd::HoldingsBody {
schema_version: hd::SCHEMA_VERSION,
slsk_username: slsk_link::own_linked_username(app),
entries,
})
.map_err(|e| e.to_string())?;
let rid = spaces::record_id(owner_b58, space_b58);
let on_net = fetch(app, &st.space_id, &st.worn).await?;
let known = load(app).mine.get(&rid).map(|m| m.sequence).unwrap_or(0);
let sequence = on_net.as_ref().map(|s| s.sequence).unwrap_or(0).max(known) + 1;
let mut s = hd::HoldingsState {
schema_version: hd::SCHEMA_VERSION,
space_id: st.space_id,
member: st.worn,
sequence,
epoch: st.epoch,
sealed: st.epoch_key.is_some(),
published_at: now_secs(),
withdrawn: false,
body,
sig: vec![],
};
if let Some(k) = &st.epoch_key {
s.body = hd::seal(k, &s);
}
s.sig = sign(app, &st.me, &st.worn, st.room_secret, &s).await?;
offer(app, &st.space_id, &st.worn, &s, on_net.is_none()).await?;
let mine = Mine { sequence, epoch: st.epoch, published_at: s.published_at, count, digest, withdrawn: false };
{
let _guard = ledger::hold(LEDGER_FILE).await;
let mut l = load(app);
l.mine.insert(rid, mine.clone());
save(app, &l)?;
}
log::info!("holdings: published {count} of {total} albums into {space_b58} as sequence {sequence} (epoch {})", st.epoch);
Ok(mine)
}
/// Take the list out of current state. Copies members already read stay
/// with them — never claim erasure.
pub async fn withdraw(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<(), String> {
let st = seat(app, owner_b58, space_b58).await?;
let rid = spaces::record_id(owner_b58, space_b58);
let on_net = fetch(app, &st.space_id, &st.worn).await?;
let Some(current) = on_net else {
// Nothing there to withdraw; forget what the ledger thought.
let _guard = ledger::hold(LEDGER_FILE).await;
let mut l = load(app);
l.mine.remove(&rid);
return save(app, &l);
};
if current.withdrawn {
return Ok(());
}
let sequence = current.sequence.max(load(app).mine.get(&rid).map(|m| m.sequence).unwrap_or(0)) + 1;
let mut s = hd::HoldingsState {
schema_version: hd::SCHEMA_VERSION,
space_id: st.space_id,
member: st.worn,
sequence,
epoch: st.epoch,
sealed: false,
published_at: now_secs(),
withdrawn: true,
body: vec![],
sig: vec![],
};
s.sig = sign(app, &st.me, &st.worn, st.room_secret, &s).await?;
offer(app, &st.space_id, &st.worn, &s, false).await?;
{
let _guard = ledger::hold(LEDGER_FILE).await;
let mut l = load(app);
l.mine.insert(rid, Mine { sequence, epoch: st.epoch, published_at: s.published_at, count: 0, digest: String::new(), withdrawn: true });
save(app, &l)?;
}
log::info!("holdings: withdrawn from {space_b58} as sequence {sequence}");
Ok(())
}
/// The switch. On: publish now. Off: withdraw. Either way the flag is set
/// first, so a publish that fails on the network is retried by the next
/// library change rather than forgotten.
pub async fn set_share(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, on: bool) -> Result<(), String> {
spaces::set_share_holdings_flag(app, owner_b58, space_b58, on).await?;
if on {
publish(app, owner_b58, space_b58).await.map(|_| ())
} else {
withdraw(app, owner_b58, space_b58).await
}
}
/// The library changed: every room with the switch on gets the new list,
/// unless the list is byte-for-byte what that room already has. Quiet per
/// room — a failed republish is logged and tried again next change.
pub async fn republish_all(app: &tauri::AppHandle) -> Result<u32, String> {
let rooms = spaces::rooms_sharing_holdings(app);
let mut n = 0;
for (owner, space) in rooms {
let stale = {
let lib = app.state::<LibraryState>().library.clone();
let sid = spaces::id_of(&space)?;
let (entries, _) = tauri::async_runtime::spawn_blocking(move || my_list(&lib, &sid))
.await
.map_err(|e| e.to_string())??;
let l = load(app);
l.mine.get(&spaces::record_id(&owner, &space)).map(|m| m.withdrawn || m.digest != digest_of(&entries)).unwrap_or(true)
};
if !stale {
continue;
}
match publish(app, &owner, &space).await {
Ok(_) => n += 1,
Err(e) => log::info!("holdings: could not republish into {space}: {e}"),
}
}
Ok(n)
}
// ---------------------------------------------------------------- reading a room
#[derive(Debug, Serialize)]
pub struct MemberHoldings {
pub key: String,
pub username: Option<String>,
pub is_me: bool,
/// "shared" | "none" | "sealed" (no key for its epoch) | "withdrawn" | "unread"
pub state: String,
pub count: u32,
pub sequence: u64,
pub read_at: u64,
}
#[derive(Debug, Serialize)]
pub struct RoomHoldings {
pub owner: String,
pub space_id: String,
pub name: String,
pub share_holdings: bool,
pub mine: Option<Mine>,
pub members: Vec<MemberHoldings>,
pub sharing: u32,
}
/// The room's lists as this device knows them, reading the network for
/// members whose cached copy is stale (or all of them, on `refresh`). The
/// reads are sequential ring searches — seconds each for a member who
/// never published — so this runs from a spawned task and the page hears
/// the result by event; it never blocks the Space opening.
pub async fn read_room(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, refresh: bool) -> Result<RoomHoldings, String> {
let me = spaces::my_key(app).await?;
let owner = spaces::key_of(owner_b58)?;
let space_id = spaces::id_of(space_b58)?;
let facts = spaces::room_facts(app, owner_b58, space_b58).ok_or("not a Space this device knows")?;
let (worn, _) = spaces::worn_identity(app, owner_b58, space_b58, &me);
let (state, _) = spaces::fetch_state(app, &owner, &space_id).await?;
let c = state.checkpoint.ok_or("the Space has not reached this node yet")?;
let rid = spaces::record_id(owner_b58, space_b58);
let now = now_secs();
let members: Vec<[u8; 32]> = c.members.iter().map(|m| m.key).filter(|k| *k != worn).collect();
for member in members {
let mb58 = b58(&member);
let cached = load(app).cache.get(&rid).and_then(|m| m.get(&mb58)).cloned();
let fresh = cached.as_ref().is_some_and(|c| {
let age = now.saturating_sub(c.read_at);
if c.absent { age < ABSENT_SECS } else { age < FRESH_SECS }
});
if fresh && !refresh {
continue;
}
let entry = match fetch(app, &space_id, &member).await {
Ok(None) => Cached { read_at: now, absent: true, ..Default::default() },
Ok(Some(s)) if s.withdrawn => Cached { sequence: s.sequence, epoch: s.epoch, read_at: now, withdrawn: true, ..Default::default() },
Ok(Some(s)) => {
let plain = if s.sealed {
match spaces::epoch_key_for(app, owner_b58, space_b58, s.epoch) {
Some(k) => hd::open(&k, &s),
None => None,
}
} else {
Some(s.body.clone())
};
match plain.as_deref().and_then(hd::decode_body) {
Some(b) => Cached {
sequence: s.sequence,
epoch: s.epoch,
read_at: now,
slsk_username: b.slsk_username,
entries: b.entries.iter().map(|h| (b58(&h.key), h.mbid.map(|m| b58(&m)), h.tier)).collect(),
..Default::default()
},
None => Cached { sequence: s.sequence, epoch: s.epoch, read_at: now, unreadable: true, ..Default::default() },
}
}
Err(e) => {
log::info!("holdings: could not read {mb58} in {space_b58}: {e}");
continue;
}
};
let _guard = ledger::hold(LEDGER_FILE).await;
let mut l = load(app);
l.cache.entry(rid.clone()).or_default().insert(mb58, entry);
save(app, &l)?;
}
Ok(room_view(app, owner_b58, space_b58, &facts, &c, &worn))
}
/// The room as the cache has it — no network.
pub fn room_now(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<RoomHoldings, String> {
let facts = spaces::room_facts(app, owner_b58, space_b58).ok_or("not a Space this device knows")?;
let l = load(app);
let rid = spaces::record_id(owner_b58, space_b58);
let mine = l.mine.get(&rid).cloned();
let cache = l.cache.get(&rid).cloned().unwrap_or_default();
let members = cache
.iter()
.map(|(k, c)| MemberHoldings {
key: k.clone(),
username: c.slsk_username.clone().or_else(|| spaces::username_behind(app, owner_b58, space_b58, k)),
is_me: false,
state: state_word(c).into(),
count: c.entries.len() as u32,
sequence: c.sequence,
read_at: c.read_at,
})
.collect::<Vec<_>>();
let sharing = members.iter().filter(|m| m.state == "shared").count() as u32 + u32::from(mine.as_ref().is_some_and(|m| !m.withdrawn));
Ok(RoomHoldings { owner: owner_b58.into(), space_id: space_b58.into(), name: facts.name, share_holdings: facts.share_holdings, mine, members, sharing })
}
fn state_word(c: &Cached) -> &'static str {
if c.absent {
"none"
} else if c.withdrawn {
"withdrawn"
} else if c.unreadable {
"sealed"
} else {
"shared"
}
}
fn room_view(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, facts: &spaces::RoomFacts, c: &persona_state::space::Checkpoint, worn: &[u8; 32]) -> RoomHoldings {
let l = load(app);
let rid = spaces::record_id(owner_b58, space_b58);
let mine = l.mine.get(&rid).cloned();
let cache = l.cache.get(&rid).cloned().unwrap_or_default();
let members: Vec<MemberHoldings> = c
.members
.iter()
.map(|m| {
let k = b58(&m.key);
let is_me = &m.key == worn;
let cached = cache.get(&k);
MemberHoldings {
username: cached.and_then(|c| c.slsk_username.clone()).or_else(|| spaces::username_behind(app, owner_b58, space_b58, &k)),
is_me,
state: if is_me {
match &mine {
Some(m) if !m.withdrawn => "shared",
_ => "none",
}
} else {
cached.map(state_word).unwrap_or("unread")
}
.into(),
count: if is_me { mine.as_ref().map(|m| m.count).unwrap_or(0) } else { cached.map(|c| c.entries.len() as u32).unwrap_or(0) },
sequence: if is_me { mine.as_ref().map(|m| m.sequence).unwrap_or(0) } else { cached.map(|c| c.sequence).unwrap_or(0) },
read_at: cached.map(|c| c.read_at).unwrap_or(0),
key: k,
}
})
.collect();
let sharing = members.iter().filter(|m| m.state == "shared").count() as u32;
RoomHoldings { owner: owner_b58.into(), space_id: space_b58.into(), name: facts.name.clone(), share_holdings: facts.share_holdings, mine, members, sharing }
}
// ---------------------------------------------------------------- the ask
#[derive(Debug, Deserialize)]
pub struct AskItem {
pub artist: String,
pub album: String,
#[serde(default)]
pub mbid: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Holder {
pub owner: String,
pub space_id: String,
pub space_name: String,
pub member: String,
pub username: Option<String>,
pub tier: u8,
/// How the holding matched: "name" | "mbid".
pub matched_by: &'static str,
}
#[derive(Debug, Serialize)]
pub struct Answer {
pub artist: String,
pub album: String,
pub holders: Vec<Holder>,
}
/// Who holds each album, across every room this device is in — from the
/// cache, no network. A person in two rooms is one holder per room; the
/// frontend folds by username when it wants to.
pub fn ask(app: &tauri::AppHandle, items: &[AskItem]) -> Vec<Answer> {
let l = load(app);
let rooms = spaces::rooms(app);
// Per room: member → (key set, mbid set, username, tier by key).
struct RoomIndex {
owner: String,
space_id: String,
name: String,
sid: [u8; 16],
members: Vec<(String, Option<String>, BTreeMap<String, u8>, BTreeSet<String>)>,
}
let index: Vec<RoomIndex> = rooms
.into_iter()
.filter_map(|(owner, space, name)| {
let sid = spaces::id_of(&space).ok()?;
let cache = l.cache.get(&spaces::record_id(&owner, &space))?;
let members = cache
.iter()
.filter(|(_, c)| !c.absent && !c.withdrawn && !c.unreadable && !c.entries.is_empty())
.map(|(k, c)| {
let mut keys = BTreeMap::new();
let mut mbids = BTreeSet::new();
for (key, mbid, tier) in &c.entries {
keys.insert(key.clone(), *tier);
if let Some(m) = mbid {
mbids.insert(m.clone());
}
}
(k.clone(), c.slsk_username.clone().or_else(|| spaces::username_behind(app, &owner, &space, k)), keys, mbids)
})
.collect();
Some(RoomIndex { owner, space_id: space, name, sid, members })
})
.collect();
items
.iter()
.map(|it| {
let (na, nt) = (overlap::norm(&it.artist), overlap::norm(&it.album));
let mut holders = Vec::new();
if !na.is_empty() && !nt.is_empty() {
for r in &index {
let key = b58(&hd::album_key(&r.sid, &na, &nt));
let mk = it.mbid.as_deref().filter(|m| !m.trim().is_empty()).map(|m| b58(&hd::mbid_key(&r.sid, m)));
for (member, username, keys, mbids) in &r.members {
let by_name = keys.get(&key).copied();
let by_mbid = mk.as_ref().filter(|m| mbids.contains(*m)).map(|_| hd::TIER_UNKNOWN);
if let Some((tier, how)) = by_name.map(|t| (t, "name")).or(by_mbid.map(|t| (t, "mbid"))) {
holders.push(Holder {
owner: r.owner.clone(),
space_id: r.space_id.clone(),
space_name: r.name.clone(),
member: member.clone(),
username: username.clone(),
tier,
matched_by: how,
});
}
}
}
}
// Lossless first, then those who can actually be asked.
holders.sort_by(|a, b| b.tier.cmp(&a.tier).then(b.username.is_some().cmp(&a.username.is_some())));
Answer { artist: it.artist.clone(), album: it.album.clone(), holders }
})
.collect()
}
// ---------------------------------------------------------------- getting it
#[derive(Debug, Serialize)]
pub struct FileRef {
pub path: String,
pub name: String,
pub size: u64,
}
#[derive(Debug, Serialize)]
pub struct FolderMatch {
pub username: String,
pub folder: String,
pub files: Vec<FileRef>,
pub tier: u8,
}
/// Browse a holder's share and find the folder that names this album — the
/// overlap walk, so what the room said they hold is what gets grabbed. Disc
/// subfolders come along. None when their share does not name it (they
/// moved it, or the room's word was a near-name).
pub async fn find_folder(app: &tauri::AppHandle, username: &str, artist: &str, album: &str) -> Result<Option<FolderMatch>, String> {
let (na, nt) = (overlap::norm(artist), overlap::norm(album));
if na.is_empty() || nt.is_empty() {
return Err("that album has no name to look for".into());
}
let fut = {
let state = app.state::<AppState>();
let guard = state.client.lock().await;
let client = guard.as_ref().ok_or("not connected to Soulseek")?;
client.browse(username)
};
let dirs = fut.await.map_err(|e| e.to_string())?;
overlap::note_browse(app, username, &dirs);
let mut files = Vec::new();
let mut folder: Option<String> = None;
for d in &dirs {
let Some((a, b)) = overlap::pair_of(&d.name) else { continue };
let (da, db) = (overlap::norm(&a), overlap::norm(&b));
let hit = da == na && (db == nt || (nt.len() >= 5 && db.len() >= 5 && (db.contains(nt.as_str()) || nt.contains(db.as_str()))));
if !hit {
continue;
}
let audio: Vec<&soulseek_proto::types::File> = d.files.iter().filter(|f| overlap::is_audio(f)).collect();
if audio.is_empty() {
continue;
}
if folder.is_none() {
folder = Some(d.name.clone());
}
for f in audio {
files.push(FileRef { path: format!("{}\\{}", d.name, f.filename), name: f.filename.clone(), size: f.size });
}
}
let Some(folder) = folder else { return Ok(None) };
let tier = files
.iter()
.map(|f| tier_of(f.name.rsplit('.').next().unwrap_or_default()))
.max()
.unwrap_or(hd::TIER_UNKNOWN);
Ok(Some(FolderMatch { username: username.to_string(), folder, files, tier }))
}
// ---------------------------------------------------------------- waits
pub async fn wait_add(app: &tauri::AppHandle, w: Wait) -> Result<Vec<Wait>, String> {
let _guard = ledger::hold(LEDGER_FILE).await;
let mut l = load(app);
let same = |x: &Wait| x.username.eq_ignore_ascii_case(&w.username) && overlap::norm(&x.artist) == overlap::norm(&w.artist) && overlap::norm(&x.album) == overlap::norm(&w.album);
if !l.waits.iter().any(same) {
l.waits.push(Wait { since: now_secs(), ..w });
save(app, &l)?;
}
Ok(l.waits)
}
pub async fn wait_remove(app: &tauri::AppHandle, username: &str, artist: &str, album: &str) -> Result<Vec<Wait>, String> {
let _guard = ledger::hold(LEDGER_FILE).await;
let mut l = load(app);
let (na, nt) = (overlap::norm(artist), overlap::norm(album));
l.waits.retain(|x| !(x.username.eq_ignore_ascii_case(username) && overlap::norm(&x.artist) == na && overlap::norm(&x.album) == nt));
save(app, &l)?;
Ok(l.waits)
}
// ---------------------------------------------------------------- commands
#[tauri::command]
pub async fn holdings_set_share(app: tauri::AppHandle, owner: String, space: String, on: bool) -> Result<RoomHoldings, String> {
set_share(&app, &owner, &space, on).await?;
room_now(&app, &owner, &space)
}
/// The room's lists. `refresh` re-reads every member; otherwise only stale
/// ones, and only from a spawned task so the answer is immediate — the
/// page hears the read finish by event.
#[tauri::command]
pub async fn holdings_room(app: tauri::AppHandle, owner: String, space: String, refresh: bool) -> Result<RoomHoldings, String> {
let now = room_now(&app, &owner, &space)?;
let app2 = app.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = read_room(&app2, &owner, &space, refresh).await {
log::info!("holdings: reading {space}: {e}");
}
});
Ok(now)
}
#[tauri::command]
pub fn holdings_ask(app: tauri::AppHandle, items: Vec<AskItem>) -> Vec<Answer> {
ask(&app, &items)
}
#[tauri::command]
pub async fn holdings_find_folder(app: tauri::AppHandle, username: String, artist: String, album: String) -> Result<Option<FolderMatch>, String> {
find_folder(&app, &username, &artist, &album).await
}
#[tauri::command]
pub async fn holdings_republish(app: tauri::AppHandle) -> Result<u32, String> {
republish_all(&app).await
}
#[tauri::command]
pub fn holdings_waits(app: tauri::AppHandle) -> Vec<Wait> {
load(&app).waits
}
#[tauri::command]
pub async fn holdings_wait_add(app: tauri::AppHandle, username: String, artist: String, album: String, via: Option<String>) -> Result<Vec<Wait>, String> {
wait_add(&app, Wait { username, artist, album, since: 0, via }).await
}
#[tauri::command]
pub async fn holdings_wait_remove(app: tauri::AppHandle, username: String, artist: String, album: String) -> Result<Vec<Wait>, String> {
wait_remove(&app, &username, &artist, &album).await
}
#[cfg(test)]
mod tests {
use super::*;
/// The bundled contract is a commitment: a changed wasm re-keys every
/// list on the network.
#[test]
fn contract_generation_is_pinned() {
let ids = FreenetAdapter::contract_ids(HOLDINGS_WASM.to_vec(), &hd::make_params(&[1u8; 16], &[7u8; 32]));
assert_eq!(ids.code_hash, "3roNW8VMniLcQVWSzrAZ9Vum8KvFY7DD2dFkrXy43h9Q");
}
#[test]
fn a_dominant_format_speaks_for_the_tier() {
assert_eq!(tier_of("flac"), hd::TIER_LOSSLESS);
assert_eq!(tier_of("FLAC"), hd::TIER_LOSSLESS);
assert_eq!(tier_of("mp3"), hd::TIER_LOSSY);
assert_eq!(tier_of("opus"), hd::TIER_LOSSY);
assert_eq!(tier_of("bin"), hd::TIER_UNKNOWN);
}
/// An older ledger loads, and a wait is one per (person, album) however
/// the names are spelled.
#[test]
fn the_ledger_reads_older_files_and_waits_fold_by_name() {
let l: Ledger = serde_json::from_str("{}").unwrap();
assert!(l.mine.is_empty() && l.cache.is_empty() && l.waits.is_empty());
let w = Wait { username: "Kraven".into(), artist: "Stars of the Lid".into(), album: "And Their Refinement of the Decline".into(), since: 1, via: None };
let same = Wait { username: "kraven".into(), artist: "stars of the lid".into(), album: "And Their Refinement Of The Decline (2007) [FLAC]".into(), since: 2, via: None };
assert_eq!(overlap::norm(&w.album), overlap::norm(&same.album));
assert!(w.username.eq_ignore_ascii_case(&same.username));
}
}
+9
View File
@@ -31,6 +31,7 @@ mod smtc;
mod spaces; mod spaces;
mod proposals; mod proposals;
mod overlap; mod overlap;
mod holdings;
mod state; mod state;
mod thumbbar; mod thumbbar;
mod vocal_sep; mod vocal_sep;
@@ -186,6 +187,14 @@ pub fn run() {
overlap::overlap_list, overlap::overlap_list,
overlap::overlap_compute, overlap::overlap_compute,
overlap::overlap_forget, overlap::overlap_forget,
holdings::holdings_set_share,
holdings::holdings_room,
holdings::holdings_ask,
holdings::holdings_find_folder,
holdings::holdings_republish,
holdings::holdings_waits,
holdings::holdings_wait_add,
holdings::holdings_wait_remove,
commands::buddy_list, commands::buddy_list,
commands::buddy_add, commands::buddy_add,
commands::buddy_remove, commands::buddy_remove,
+3 -3
View File
@@ -90,7 +90,7 @@ fn key(username: &str) -> String {
/// Bracketed chunks go — "(2019)", "[FLAC]", "{web}" — then only letters and /// Bracketed chunks go — "(2019)", "[FLAC]", "{web}" — then only letters and
/// digits remain, lowercased; a leading "the" goes too. "Sigur Rós" and /// digits remain, lowercased; a leading "the" goes too. "Sigur Rós" and
/// "sigur ros" are not the same string here, and that is a known miss. /// "sigur ros" are not the same string here, and that is a known miss.
fn norm(s: &str) -> String { pub(crate) fn norm(s: &str) -> String {
let mut out = String::with_capacity(s.len()); let mut out = String::with_capacity(s.len());
let mut depth = 0i32; let mut depth = 0i32;
for c in s.chars() { for c in s.chars() {
@@ -127,7 +127,7 @@ fn is_disc_folder(s: &str) -> bool {
false false
} }
fn is_audio(f: &soulseek_proto::types::File) -> bool { pub(crate) fn is_audio(f: &soulseek_proto::types::File) -> bool {
let ext = if f.extension.is_empty() { let ext = if f.extension.is_empty() {
f.filename.rsplit('.').next().unwrap_or_default() f.filename.rsplit('.').next().unwrap_or_default()
} else { } else {
@@ -138,7 +138,7 @@ fn is_audio(f: &soulseek_proto::types::File) -> bool {
} }
/// The (artist, album) a share folder names, with display spellings. /// The (artist, album) a share folder names, with display spellings.
fn pair_of(dir: &str) -> Option<(String, String)> { pub(crate) fn pair_of(dir: &str) -> Option<(String, String)> {
let segs: Vec<&str> = dir let segs: Vec<&str> = dir
.split(['\\', '/']) .split(['\\', '/'])
.map(str::trim) .map(str::trim)
+6
View File
@@ -293,6 +293,12 @@ pub(crate) fn remember(app: &tauri::AppHandle, link: KnownLink) {
/// The Soulseek account a verified persona key belongs to, if this device /// The Soulseek account a verified persona key belongs to, if this device
/// verified one (our own included). /// verified one (our own included).
/// The Soulseek name this device has VERIFIED as its own through the link
/// handshake, if any — not merely the account signed in right now.
pub(crate) fn own_linked_username(app: &tauri::AppHandle) -> Option<String> {
load(app).own.map(|o| o.username)
}
pub(crate) fn username_for_key(app: &tauri::AppHandle, persona_key: &str) -> Option<String> { pub(crate) fn username_for_key(app: &tauri::AppHandle, persona_key: &str) -> Option<String> {
let links = load(app); let links = load(app);
if let Some(o) = &links.own { if let Some(o) = &links.own {
+64 -5
View File
@@ -66,6 +66,11 @@ struct SpaceRecord {
/// room is not consent to be watched in it. /// room is not consent to be watched in it.
#[serde(default)] #[serde(default)]
share_listens: bool, share_listens: bool,
/// Whether this device publishes what it HOLDS into this Space — album
/// keys sealed to the room, never files (`holdings.rs`). Per Space and
/// off until asked, for the same reason as listens.
#[serde(default)]
share_holdings: bool,
/// The last listen posted here — what, and when. A reload or a restart /// The last listen posted here — what, and when. A reload or a restart
/// re-reports whatever is already past the player's threshold, and this /// re-reports whatever is already past the player's threshold, and this
/// is the only side that knows what a room was last told. /// is the only side that knows what a room was last told.
@@ -153,6 +158,8 @@ pub struct SpaceView {
pub can_post: bool, pub can_post: bool,
/// Whether THIS device posts what it plays here. /// Whether THIS device posts what it plays here.
pub share_listens: bool, pub share_listens: bool,
/// Whether THIS device publishes its holdings here.
pub share_holdings: bool,
pub has_key: bool, pub has_key: bool,
/// The contract exists on the network (a Space someone invited us to /// The contract exists on the network (a Space someone invited us to
/// may not have propagated to this node yet). /// may not have propagated to this node yet).
@@ -226,13 +233,13 @@ fn rand32() -> [u8; 32] {
rand::thread_rng().fill_bytes(&mut n); rand::thread_rng().fill_bytes(&mut n);
n n
} }
fn b58(bytes: &[u8]) -> String { pub(crate) fn b58(bytes: &[u8]) -> String {
bs58::encode(bytes).into_string() bs58::encode(bytes).into_string()
} }
fn key_of(s: &str) -> Result<[u8; 32], String> { pub(crate) fn key_of(s: &str) -> Result<[u8; 32], String> {
inbox::key_of(s) inbox::key_of(s)
} }
fn id_of(s: &str) -> Result<[u8; 16], String> { pub(crate) fn id_of(s: &str) -> Result<[u8; 16], String> {
bs58::decode(s) bs58::decode(s)
.into_vec() .into_vec()
.map_err(|e| format!("bad id: {e}"))? .map_err(|e| format!("bad id: {e}"))?
@@ -250,7 +257,7 @@ fn cap(s: &str, max: usize) -> String {
} }
s[..end].to_string() s[..end].to_string()
} }
fn record_id(owner: &str, space_id: &str) -> String { pub(crate) fn record_id(owner: &str, space_id: &str) -> String {
format!("{owner}:{space_id}") format!("{owner}:{space_id}")
} }
@@ -281,7 +288,7 @@ fn save(app: &tauri::AppHandle, l: &Ledger) -> Result<(), String> {
Ok(()) Ok(())
} }
async fn my_key(app: &tauri::AppHandle) -> Result<[u8; 32], String> { pub(crate) async fn my_key(app: &tauri::AppHandle) -> Result<[u8; 32], String> {
let adapter = app.state::<AppState>().freenet.clone(); let adapter = app.state::<AppState>().freenet.clone();
let info = persona::current(&adapter) let info = persona::current(&adapter)
.await? .await?
@@ -404,6 +411,7 @@ fn view_of(app: &tauri::AppHandle, rec: &SpaceRecord, state: &sp::SpaceState, me
listens: vec![], listens: vec![],
can_post: false, can_post: false,
share_listens: rec.share_listens, share_listens: rec.share_listens,
share_holdings: rec.share_holdings,
has_key: false, has_key: false,
reachable, reachable,
}; };
@@ -490,6 +498,7 @@ fn view_of(app: &tauri::AppHandle, rec: &SpaceRecord, state: &sp::SpaceState, me
.collect(), .collect(),
can_post: is_member && has_key, can_post: is_member && has_key,
share_listens: rec.share_listens, share_listens: rec.share_listens,
share_holdings: rec.share_holdings,
has_key, has_key,
reachable, reachable,
} }
@@ -563,6 +572,7 @@ pub async fn create(app: &tauri::AppHandle, name: &str, description: &str, priva
roster: BTreeMap::new(), roster: BTreeMap::new(),
invited: BTreeMap::new(), invited: BTreeMap::new(),
share_listens: false, share_listens: false,
share_holdings: false,
last_listen: None, last_listen: None,
join_sent_at: 0, join_sent_at: 0,
}; };
@@ -826,6 +836,53 @@ pub async fn set_pinned(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str
/// Turn posting-what-I-play on or off for one Space. Nothing is published /// Turn posting-what-I-play on or off for one Space. Nothing is published
/// by flipping it; it only decides what the next track does. /// by flipping it; it only decides what the next track does.
/// What `holdings.rs` needs to know about a room, without the record.
pub(crate) struct RoomFacts {
pub name: String,
pub private: bool,
pub my_role: String,
pub share_holdings: bool,
}
pub(crate) fn room_facts(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Option<RoomFacts> {
let l = load(app);
let rec = l.spaces.get(&record_id(owner_b58, space_b58))?;
Some(RoomFacts { name: rec.name.clone(), private: rec.private, my_role: rec.my_role.clone(), share_holdings: rec.share_holdings })
}
/// The room's key for one epoch, if this device was handed it.
pub(crate) fn epoch_key_for(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, epoch: u64) -> Option<[u8; 32]> {
let l = load(app);
let rec = l.spaces.get(&record_id(owner_b58, space_b58))?;
rec.keys.get(&epoch.to_string()).and_then(|k| key_of(k).ok())
}
/// Every room this device is in: (owner, space id, name).
pub(crate) fn rooms(app: &tauri::AppHandle) -> Vec<(String, String, String)> {
load(app).spaces.values().map(|r| (r.owner.clone(), r.space_id.clone(), r.name.clone())).collect()
}
/// The rooms this device publishes its holdings into.
pub(crate) fn rooms_sharing_holdings(app: &tauri::AppHandle) -> Vec<(String, String)> {
load(app).spaces.values().filter(|r| r.share_holdings).map(|r| (r.owner.clone(), r.space_id.clone())).collect()
}
/// The Soulseek name behind a room key as this device knows it: through the
/// owner's private roster, or the key itself when it is a persona (an owner's).
pub(crate) fn username_behind(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, member_b58: &str) -> Option<String> {
let l = load(app);
let rec = l.spaces.get(&record_id(owner_b58, space_b58))?;
slsk_link::username_for_key(app, rec.roster.get(member_b58).map(String::as_str).unwrap_or(member_b58))
}
pub(crate) async fn set_share_holdings_flag(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, on: bool) -> Result<(), String> {
let _ledger = ledger::hold(LEDGER_FILE).await;
let mut l = load(app);
let rec = l.spaces.get_mut(&record_id(owner_b58, space_b58)).ok_or("not a Space this device knows")?;
rec.share_holdings = on;
save(app, &l)
}
pub async fn set_share_listens(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, on: bool) -> Result<SpaceView, String> { pub async fn set_share_listens(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, on: bool) -> Result<SpaceView, String> {
{ {
let _ledger = ledger::hold(LEDGER_FILE).await; let _ledger = ledger::hold(LEDGER_FILE).await;
@@ -946,6 +1003,7 @@ pub(crate) async fn on_inbox_invite(app: &tauri::AppHandle, sender: [u8; 32], in
roster: BTreeMap::new(), roster: BTreeMap::new(),
invited: BTreeMap::new(), invited: BTreeMap::new(),
share_listens: false, share_listens: false,
share_holdings: false,
last_listen: None, last_listen: None,
join_sent_at: 0, join_sent_at: 0,
}); });
@@ -1197,6 +1255,7 @@ mod tests {
roster: BTreeMap::new(), roster: BTreeMap::new(),
invited: BTreeMap::new(), invited: BTreeMap::new(),
share_listens: false, share_listens: false,
share_holdings: false,
last_listen: None, last_listen: None,
join_sent_at: 0, join_sent_at: 0,
} }
+2
View File
@@ -28,6 +28,7 @@
import UploadToast from "./components/UploadToast.svelte"; import UploadToast from "./components/UploadToast.svelte";
import ProposalToast from "./components/ProposalToast.svelte"; import ProposalToast from "./components/ProposalToast.svelte";
import FollowNewsToast from "./components/FollowNewsToast.svelte"; import FollowNewsToast from "./components/FollowNewsToast.svelte";
import WhoHasSheet from "./components/WhoHasSheet.svelte";
import SetlistScanChip from "./components/SetlistScanChip.svelte"; import SetlistScanChip from "./components/SetlistScanChip.svelte";
import { import {
ui, ui,
@@ -188,6 +189,7 @@
<UploadToast /> <UploadToast />
<ProposalToast /> <ProposalToast />
<FollowNewsToast /> <FollowNewsToast />
<WhoHasSheet />
<SetlistScanChip /> <SetlistScanChip />
<PreviewOverlay /> <PreviewOverlay />
<LyricsSyncEditor /> <LyricsSyncEditor />
+149
View File
@@ -0,0 +1,149 @@
<script>
// A chooser: one value out of a list, in the app's own menu vocabulary
// (Library's sort menu ← CatalogDiscography ← the player bar's popover)
// instead of a native <select>, which WebView2 draws with the OS's text
// rendering and looks wrong beside everything else. The trigger is a
// pill with the current label and a chevron; the popover is the same
// dark .menu as the sort menu, with the accent check on the current row.
//
// Portalled to body and positioned fixed: a settings card clips its
// overflow, and the pane is a containing block for anything fixed
// inside it. Closes on a choice, Escape, an outside press, a scroll, or
// a resize — a menu that drifts from its trigger is worse than none.
import Icon from "./Icon.svelte";
/** @type {{ options: [string, string][], value: string, onchange: (v: string) => void, disabled?: boolean, title?: string, width?: number }} */
let { options, value, onchange, disabled = false, title = "", width = 0 } = $props();
let triggerEl = $state(null);
let menuEl = $state(null);
let open = $state(false);
let pos = $state({ top: 0, left: 0, minWidth: 186 });
const label = $derived(options.find(([v]) => v === value)?.[1] ?? value ?? "");
function portal(node) {
document.body.appendChild(node);
return { destroy: () => node.remove() };
}
function place() {
const r = triggerEl.getBoundingClientRect();
const minWidth = Math.max(186, width || r.width);
const est = 12 + options.length * 30;
let top = r.bottom + 6;
if (top + est > window.innerHeight - 8) top = Math.max(8, r.top - 6 - est);
let left = Math.min(r.right - minWidth, window.innerWidth - minWidth - 8);
left = Math.max(8, left);
pos = { top, left, minWidth };
}
function toggle(e) {
e.stopPropagation();
if (disabled) return;
if (open) {
open = false;
return;
}
place();
open = true;
}
function pick(v) {
open = false;
if (v !== value) onchange(v);
}
$effect(() => {
if (!open) return;
const onDown = (e) => {
if (triggerEl?.contains(e.target) || menuEl?.contains(e.target)) return;
open = false;
};
const onKey = (e) => {
if (e.key === "Escape") open = false;
};
const close = () => (open = false);
document.addEventListener("pointerdown", onDown, true);
document.addEventListener("keydown", onKey);
document.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
return () => {
document.removeEventListener("pointerdown", onDown, true);
document.removeEventListener("keydown", onKey);
document.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
};
});
</script>
<button bind:this={triggerEl} class="chooser" class:on={open} onclick={toggle} {disabled} {title} aria-haspopup="menu" aria-expanded={open}>
<span class="lab">{label}</span>
<Icon name="chevron-down" size={13} />
</button>
{#if open}
<div class="menu" role="menu" use:portal bind:this={menuEl} style:top="{pos.top}px" style:left="{pos.left}px" style:min-width="{pos.minWidth}px">
{#each options as [v, l] (v)}
<button role="menuitemradio" aria-checked={v === value} class:cur={v === value} onclick={() => pick(v)}>
{l}
{#if v === value}<span class="mcheck"><Icon name="check" size={13} /></span>{/if}
</button>
{/each}
</div>
{/if}
<style>
.chooser {
display: inline-flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 11px 0 14px;
border-radius: 99px;
background: var(--bg-highlight);
color: var(--text-primary);
font-size: 12.5px;
font-weight: 800;
white-space: nowrap;
max-width: 320px;
transition: background 0.15s;
}
.chooser .lab { overflow: hidden; text-overflow: ellipsis; }
.chooser :global(svg) { color: var(--text-secondary); flex: none; transition: transform 160ms ease; }
.chooser:hover:not(:disabled), .chooser.on { background: var(--bg-press); }
.chooser.on :global(svg) { transform: rotate(180deg); }
.chooser:disabled { opacity: 0.6; cursor: default; }
/* the sort menu's popover, verbatim — solid on purpose: a menu has to be
readable over whatever it lands on, dark glass included */
.menu {
position: fixed;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 2px;
padding: 7px;
border-radius: 9px;
background: #0d0d0d;
border: 1px solid var(--border);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.7);
font-family: var(--app-font);
}
.menu button {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 6px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-align: left;
white-space: nowrap;
}
.menu button:hover, .menu button.cur { color: var(--text-primary); }
.menu button:hover { background: var(--bg-highlight); }
.menu .mcheck { margin-left: auto; display: inline-flex; color: var(--accent-hover); }
</style>
+196
View File
@@ -0,0 +1,196 @@
<script>
// The answer to "who has this?": the members of your rooms whose holdings
// name this album, grouped by room, each with the line Discover already
// writes for that person and the action the answer allows —
//
// Get it a linked holder: browse their share, find the
// folder the room said they hold, queue the grab
// Wait for them the grab could not reach them: a local wait
// that fires when their presence flips online
// Ask in chat a room-local member with no Soulseek name;
// nothing is ever pushed to them
//
// Reads the cached holdings only; the network was read when the room was.
import Icon from "./Icon.svelte";
import Identicon, { hueOf } from "./Identicon.svelte";
import { slsk } from "../lib/state.svelte.js";
import { community, askHoldings, holdingsGrab, waitFor, forgetWait, waitingOn } from "../lib/community.svelte.js";
let { artist, album, mbid = null, compact = false, onanswer = null } = $props();
let holders = $state(null);
let asking = $state(false);
let grabbed = $state({});
let failed = $state({});
$effect(() => {
const a = artist;
const b = album;
const m = mbid;
if (!a || !b) {
holders = [];
return;
}
let live = true;
asking = true;
askHoldings([{ artist: a, album: b, mbid: m }])
.then((r) => {
if (!live) return;
holders = r?.[0]?.holders ?? [];
onanswer?.(holders);
})
.catch(() => {
if (live) holders = [];
})
.finally(() => {
if (live) asking = false;
});
return () => {
live = false;
};
});
/** Rooms in the order they appear, each with its holders. */
const rooms = $derived.by(() => {
const by = new Map();
for (const h of holders ?? []) {
const k = h.owner + ":" + h.space_id;
if (!by.has(k)) by.set(k, { key: k, name: h.space_name, holders: [] });
by.get(k).holders.push(h);
}
return [...by.values()];
});
const people = $derived.by(() => {
const s = new Set();
for (const h of holders ?? []) s.add((h.username ?? h.member).toLowerCase());
return s.size;
});
const display = (u) => community.contacts[u]?.petname || u;
const fpShort = (k) => (k ? `${k.slice(0, 4)}…${k.slice(-4)}` : "");
function presenceOf(u) {
const st = slsk.buddyState[u];
if (!st || st.exists === false) return null;
if (st.status === 2) return "online";
if (st.status === 1) return "away";
return "off";
}
const fmtClock = (ms) => new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
function seenLine(u) {
const ms = community.lastSeen[u];
if (!ms) return null;
const d = new Date(ms);
const now = new Date();
if (d.toDateString() === now.toDateString()) return `last seen by this device today · ${fmtClock(ms)}`;
if (new Date(now - 86400000).toDateString() === d.toDateString()) return `last seen by this device yesterday · ${fmtClock(ms)}`;
return `last seen by this device ${d.toLocaleDateString([], { month: "short", day: "numeric" })}`;
}
/** The second line: presence, then what Discover knows about them. */
function line(h) {
const u = h.username;
if (!u) return "room-local · not linked to a Soulseek account";
const parts = [];
const p = presenceOf(u);
if (p === "online") parts.push("online now");
else if (p === "away") parts.push("away");
else {
const s = seenLine(u);
if (s) parts.push(s);
}
const o = community.overlap.find((x) => x.username.toLowerCase() === u.toLowerCase());
if (o?.albums_in_common_count) parts.push(`shares ${o.albums_in_common_count} ${o.albums_in_common_count === 1 ? "album" : "albums"} you own`);
if (o?.gaps_count) parts.push(`${o.gaps_count} you lack`);
return parts.join(" · ") || "linked to Soulseek";
}
const tierWord = (t) => (t === 2 ? "FLAC" : t === 1 ? "MP3" : null);
async function getIt(h) {
const u = h.username;
delete failed[u];
try {
const m = await holdingsGrab(u, artist, album);
grabbed[u] = m?.files?.length ?? 0;
} catch (e) {
failed[u] = String(e?.message ?? e);
}
}
const busy = (h) => !!community.busy[`grab:${h.username}:${album}`] || !!community.busy[`wait:${h.username}:${album}`];
</script>
<div class="whohas" class:compact>
{#if asking && holders === null}
<div class="quiet"><span class="bl"></span> Asking your rooms…</div>
{:else if !holders?.length}
<div class="quiet">
<Icon name="users" size={16} />
<span>{community.spaces.length ? "Nobody in your rooms has published this one." : "You are in no rooms yet — holdings are asked room by room."}</span>
</div>
{:else}
{#each rooms as r (r.key)}
<div class="grp"><span class="src">{r.name}</span><span class="n">{r.holders.length} of the room</span><span class="ln"></span></div>
{#each r.holders as h (h.member)}
{@const u = h.username}
<div class="row">
<span class="av" style:background="hsl({hueOf(u ?? h.member)} 30% 14%)"><Identicon seed={u ?? h.member} size={compact ? 30 : 36} presence={u ? presenceOf(u) : null} /></span>
<span class="tx">
<span class="l1"><span class="nm">{u ? display(u) : fpShort(h.member)}</span>{#if u}<span class="badge">Soulseek</span>{/if}</span>
<span class="l2">{line(h)}</span>
{#if failed[u]}<span class="l2 warn">{failed[u]}</span>{/if}
</span>
<span class="r">
{#if tierWord(h.tier)}<span class="tier" class:lossless={h.tier === 2}>{tierWord(h.tier)}</span>{/if}
{#if !u}
<button class="ax-ghost sm" disabled title="They have no Soulseek name here; ask in the room's chat">Ask in chat</button>
{:else if grabbed[u] != null}
<span class="chip ok"><Icon name="check" size={11} /> {grabbed[u]} {grabbed[u] === 1 ? "file" : "files"} queued</span>
{:else if waitingOn(u, artist, album)}
<span class="chip acc">waiting</span>
<button class="ax-ghost sm" onclick={() => forgetWait(u, artist, album)} disabled={busy(h)}>Forget</button>
{:else if failed[u]}
<button class="ax-ghost sm" onclick={() => waitFor(u, artist, album, h.space_name)} disabled={busy(h)}>Wait for them</button>
<button class="ax-fill sm" onclick={() => getIt(h)} disabled={busy(h)}>Try again</button>
{:else}
<button class="ax-fill sm" onclick={() => getIt(h)} disabled={busy(h)}>{busy(h) ? "Browsing…" : "Get it"}</button>
{/if}
</span>
</div>
{/each}
{/each}
<div class="foot"><span class="prov"><Icon name="compass" size={11} /> from your rooms' holdings · {people} {people === 1 ? "person" : "people"}</span><span class="note">names compared, never files</span></div>
{/if}
</div>
<style>
.whohas { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.quiet { display: flex; align-items: center; gap: 10px; padding: 12px 10px; font-size: 12.5px; color: var(--text-muted); line-height: 1.5; }
.bl { width: 12px; height: 12px; border-radius: 50%; border: 2px solid var(--text-muted); border-top-color: transparent; animation: spin 0.8s linear infinite; flex: none; }
@keyframes spin { to { transform: rotate(360deg); } }
.grp { display: flex; align-items: center; gap: 8px; height: 26px; padding: 0 2px; font-size: 10.5px; font-weight: 900; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); margin-top: 4px; }
.grp .n { font-weight: 700; letter-spacing: 0; text-transform: none; }
.grp .ln { flex: 1; height: 1px; background: var(--hair); }
.src { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 99px; background: color-mix(in srgb, var(--space, #c77dff) 14%, transparent); color: #d9a6ff; text-transform: none; }
.row { display: flex; align-items: center; gap: 10px; min-height: 46px; padding: 5px 8px; border-radius: 8px; }
.compact .row { min-height: 40px; padding: 3px 8px; }
.row:hover { background: var(--surf-hi, var(--bg-highlight)); }
.av { display: grid; place-items: center; border-radius: 50%; flex: none; }
.tx { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
.l1 { display: flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 800; }
.l1 .nm { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.badge { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 99px; background: var(--surf-hi, var(--bg-highlight)); color: var(--text-secondary); }
.l2 { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.l2.warn { color: #f0c070; white-space: normal; }
.r { display: flex; align-items: center; gap: 8px; flex: none; }
.tier { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 7px; border-radius: 5px; background: rgba(255, 255, 255, 0.08); color: var(--text-secondary); }
.tier.lossless { background: color-mix(in srgb, var(--ok, #3ddc84) 12%, transparent); color: #5ee89a; }
.chip { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 99px; white-space: nowrap; display: inline-flex; align-items: center; gap: 5px; line-height: 1.5; }
.chip.ok { background: color-mix(in srgb, var(--ok, #3ddc84) 14%, transparent); color: #5ee89a; }
.chip.acc { background: var(--accent-soft, color-mix(in srgb, var(--accent) 15%, transparent)); color: var(--accent-hover); }
.ax-ghost { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 11px; border-radius: 99px; font-size: 11px; font-weight: 800; border: 1px solid rgba(255, 255, 255, 0.28); transition: 0.18s; white-space: nowrap; }
.ax-ghost:hover:not(:disabled) { border-color: #fff; background: rgba(255, 255, 255, 0.08); }
.ax-ghost:disabled { opacity: 0.45; cursor: default; }
.ax-fill { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 12px; border-radius: 99px; font-size: 11px; font-weight: 800; background: var(--accent); color: #000; white-space: nowrap; }
.ax-fill:disabled { opacity: 0.55; cursor: default; }
.foot { display: flex; align-items: center; gap: 10px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--hair); }
.prov { font-size: 11.5px; color: var(--text-muted); display: flex; align-items: center; gap: 6px; }
.note { margin-left: auto; font-size: 11px; color: var(--text-muted); }
</style>
Binary file not shown.
+8
View File
@@ -196,6 +196,14 @@ 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 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 spaceDecide = (owner, space, proposal, accept, reason) => invoke("space_decide", { owner, space, proposal, accept, reason });
export const overlapList = () => invoke("overlap_list"); export const overlapList = () => invoke("overlap_list");
export const holdingsSetShare = (owner, space, on) => invoke("holdings_set_share", { owner, space, on });
export const holdingsRoom = (owner, space, refresh = false) => invoke("holdings_room", { owner, space, refresh });
export const holdingsAsk = (items) => invoke("holdings_ask", { items });
export const holdingsFindFolder = (username, artist, album) => invoke("holdings_find_folder", { username, artist, album });
export const holdingsRepublish = () => invoke("holdings_republish");
export const holdingsWaits = () => invoke("holdings_waits");
export const holdingsWaitAdd = (username, artist, album, via = null) => invoke("holdings_wait_add", { username, artist, album, via });
export const holdingsWaitRemove = (username, artist, album) => invoke("holdings_wait_remove", { username, artist, album });
export const overlapCompute = (username) => invoke("overlap_compute", { username }); export const overlapCompute = (username) => invoke("overlap_compute", { username });
export const overlapForget = (username) => invoke("overlap_forget", { username }); export const overlapForget = (username) => invoke("overlap_forget", { username });
export const buddyList = () => invoke("buddy_list"); export const buddyList = () => invoke("buddy_list");
+85 -2
View File
@@ -91,6 +91,11 @@ export const community = $state({
boardError: "", boardError: "",
/** overlap_list: library comparisons computed on this device, best first. */ /** overlap_list: library comparisons computed on this device, best first. */
overlap: [], overlap: [],
/** `owner:space` → holdings_room: who in that room publishes what they
* hold, and whether this device does. Read when a Space opens. */
holdings: {},
/** holdings_waits: albums to grab from someone the moment they come online. */
waits: [],
/** persona key → ProfileView | {none} | {error} | {pending}: other /** persona key → ProfileView | {none} | {error} | {pending}: other
* people's signed profiles, read once per session when surfaced. */ * people's signed profiles, read once per session when surfaced. */
remoteProfiles: {}, remoteProfiles: {},
@@ -350,6 +355,18 @@ function wireEvents() {
void refreshFollowing(); void refreshFollowing();
}); });
api.onEvent("community:overlap", () => void refreshOverlap()); api.onEvent("community:overlap", () => void refreshOverlap());
api.onEvent("community:holdings", () => {
// A read finished, a publish landed, a wait changed: re-read the
// open room's lists from the cache (fresh members cost no network).
if (community.space) void refreshHoldings(community.space.owner, community.space.space_id);
void refreshWaits();
});
// The library changed: every room with the switch on gets the new
// list, coalesced so a scan's burst of changes is one publish.
api.onEvent("soulseek:library-changed", () => {
clearTimeout(republishTimer);
republishTimer = setTimeout(() => void api.holdingsRepublish().catch(() => {}), REPUBLISH_DEBOUNCE_MS);
});
api.onEvent("community:proposal", (p) => { api.onEvent("community:proposal", (p) => {
announceProposal(p.from, p.playlist); announceProposal(p.from, p.playlist);
// If the board is already on screen, show what it is talking about. // If the board is already on screen, show what it is talking about.
@@ -357,6 +374,8 @@ function wireEvents() {
}); });
api.onEvent("soulseek:user-state", (p) => { api.onEvent("soulseek:user-state", (p) => {
if (p.status === 1 || p.status === 2) noteSeen(p.username); if (p.status === 1 || p.status === 2) noteSeen(p.username);
// Someone we are waiting on came online: the grab runs now.
if (p.status === 2) void drainWaits(p.username);
}); });
} }
} }
@@ -364,7 +383,7 @@ function wireEvents() {
/** Idempotent: pulls the state the Community page draws. */ /** Idempotent: pulls the state the Community page draws. */
export async function initCommunity() { export async function initCommunity() {
wireEvents(); wireEvents();
await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished(), refreshFollowing(), refreshInbox(), refreshSpaces(), refreshOverlap()]); await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished(), refreshFollowing(), refreshInbox(), refreshSpaces(), refreshOverlap(), refreshWaits()]);
await refreshPersona(); await refreshPersona();
} }
@@ -379,6 +398,9 @@ export async function initCommunity() {
* that comes up later — or an inbox published later — simply starts working * that comes up later — or an inbox published later — simply starts working
* without anything having to restart the timer. */ * without anything having to restart the timer. */
const INBOX_POLL_MS = 90_000; const INBOX_POLL_MS = 90_000;
/** A library scan raises many change events in a row; one publish serves them all. */
const REPUBLISH_DEBOUNCE_MS = 30_000;
let republishTimer = 0;
/** Followed playlists are read far less often: every pointer read is a ring /** Followed playlists are read far less often: every pointer read is a ring
* search that can take seconds, and an owner's next revision is a matter * search that can take seconds, and an owner's next revision is a matter
* of days, not minutes. */ * of days, not minutes. */
@@ -705,6 +727,67 @@ export async function refreshSpaces() {
} }
} }
/** Read a Space through the node; the view is what the checkpoint says. */ /** Read a Space through the node; the view is what the checkpoint says. */
// ---- holdings: who has this, and get it from them ---------------------------------
const holdingsId = (owner, space) => `${owner}:${space}`;
/** The room's lists as the cache has them; the backend reads stale members
* in the background and the event brings the result back here. */
export async function refreshHoldings(owner, space, refresh = false) {
try {
community.holdings[holdingsId(owner, space)] = await api.holdingsRoom(owner, space, refresh);
} catch {
// stays as it was
}
}
export const holdingsFor = (owner, space) => community.holdings[holdingsId(owner, space)] ?? null;
/** The switch: publish this device's list into the room, or withdraw it. */
export const setShareHoldings = (owner, space, on) =>
run("holdings:share", async () => {
community.holdings[holdingsId(owner, space)] = await api.holdingsSetShare(owner, space, on);
if (community.space?.owner === owner && community.space?.space_id === space) await openSpace(owner, space);
});
/** Who holds each (artist, album), across every room — local, instant. */
export const askHoldings = (items) => api.holdingsAsk(items);
/** Get it: browse the holder, find the folder the room said they hold, and
* queue every audio file through the hub's own download — the transfers
* page shows them like any album grab. */
export const holdingsGrab = (username, artist, album) =>
run(`grab:${username}:${album}`, async () => {
const m = await api.holdingsFindFolder(username, artist, album);
if (!m) throw new Error(`${username}'s share does not name ${album} any more`);
for (const f of m.files) await api.downloadFile(username, f.path, f.size);
return m;
});
export async function refreshWaits() {
try {
community.waits = await api.holdingsWaits();
} catch {
// stays as it was
}
}
export const waitFor = (username, artist, album, via = null) =>
run(`wait:${username}:${album}`, async () => {
community.waits = await api.holdingsWaitAdd(username, artist, album, via);
});
export const forgetWait = (username, artist, album) =>
run(`wait:${username}:${album}`, async () => {
community.waits = await api.holdingsWaitRemove(username, artist, album);
});
export const waitingOn = (username, artist, album) =>
community.waits.some((w) => w.username.toLowerCase() === (username ?? "").toLowerCase() && w.artist === artist && w.album === album);
/** A waited-on person came online: run their grabs, each once. */
async function drainWaits(username) {
const mine = community.waits.filter((w) => w.username.toLowerCase() === (username ?? "").toLowerCase());
for (const w of mine) {
try {
await holdingsGrab(w.username, w.artist, w.album);
await forgetWait(w.username, w.artist, w.album);
} catch {
// their share no longer names it, or the browse failed: the wait
// stays, and the row says what happened when it is looked at
}
}
}
export async function openSpace(owner, space) { export async function openSpace(owner, space) {
community.spaceError = ""; community.spaceError = "";
community.busy["space:" + space] = true; community.busy["space:" + space] = true;
@@ -814,4 +897,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, refreshFollowing, followFor, followNews, checkFollows, followUpdate, unfollow, publishInbox, checkInbox, refreshInbox, refreshSpaces, openSpace, createSpace, postToSpace, inviteToSpace, removeFromSpace, pinToSpace, leaveSpace, setShareListens, openBoard, proposeToBoard, decideProposal, refreshOverlap, computeOverlap, forgetOverlap, refreshInterests, setInterest }; if (import.meta.env.DEV) globalThis.__communityActions = { refreshHoldings, holdingsFor, setShareHoldings, askHoldings, holdingsGrab, refreshWaits, waitFor, forgetWait, waitingOn, friendRequest, friendRequestByKey, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished, refreshFollowing, followFor, followNews, checkFollows, followUpdate, unfollow, publishInbox, checkInbox, refreshInbox, refreshSpaces, openSpace, createSpace, postToSpace, inviteToSpace, removeFromSpace, pinToSpace, leaveSpace, setShareListens, openBoard, proposeToBoard, decideProposal, refreshOverlap, computeOverlap, forgetOverlap, refreshInterests, setInterest };
+165
View File
@@ -0,0 +1,165 @@
// Discover's ranked shelf: one list across the signals the page already
// draws in silos — the room you are in, the people your transfers touched,
// the server's similar-interests answer, the library overlaps computed on
// this device, the roster of the Space you have open. Nothing here reads
// the network; every input is a store the page holds already.
//
// The page's rule holds: every shelf says where its people came from. A
// ranked card is not exempt — its `reasons` are the shelves it drew from,
// strongest first, and the template names each one. Contacts and friends
// are left out: this is for people you have not sorted yet.
//
// The weights are the whole opinion, in one place so they can be argued
// with. Each signal is capped so one huge browse cannot bury everything.
export const WEIGHTS = {
/** Per album you both own (overlap), up to `CAPS.albums`. */
albumInCommon: 3,
/** Per album of theirs by an artist you follow that you lack. */
gap: 1,
/** Per artist in common, only when no album is (a weaker overlap). */
artistInCommon: 0.5,
/** Per declared like the server says you share. */
like: 2,
/** In the room you are looking at right now. */
activeRoom: 2,
/** Per other joined room they are in. */
room: 1,
/** Per file, either direction, this session. */
transfer: 1,
/** A seat beside yours in the open Space. */
spaceMember: 3,
};
export const CAPS = { albums: 10, gaps: 10, artists: 10, rooms: 3, transfers: 5 };
const cap = (n, max) => Math.min(Math.max(n ?? 0, 0), max);
/**
* @param {object} input
* @param {string} input.me our own username (never listed)
* @param {(u: string) => boolean} [input.skip] contacts, friends — people already sorted
* @param {Record<string, {users: {username: string}[]}>} [input.rooms] joined rooms by name
* @param {string|null} [input.activeRoom]
* @param {{username?: string}[]} [input.downloads] this session's downloads
* @param {{username?: string}[]} [input.uploads] this session's uploads
* @param {{username: string, rating: number}[]} [input.similar]
* @param {{username: string, albums_in_common_count?: number, gaps_count?: number, artists_in_common_count?: number}[]} [input.overlap]
* @param {{name: string, members: {username?: string|null, is_me?: boolean}[]}|null} [input.space] the open Space
* @param {number} [input.limit]
* @returns {{username: string, score: number, reasons: object[]}[]} best first
*/
export function rankPeople(input) {
const { me = "", skip = () => false, limit = 12 } = input;
const people = new Map();
const at = (u) => {
if (!u || u === me || skip(u)) return null;
let p = people.get(u);
if (!p) people.set(u, (p = { username: u, score: 0, reasons: [] }));
return p;
};
const add = (u, points, reason) => {
const p = at(u);
if (!p || points <= 0) return;
p.score += points;
p.reasons.push({ ...reason, points });
};
for (const o of input.overlap ?? []) {
const albums = cap(o.albums_in_common_count, CAPS.albums);
const gaps = cap(o.gaps_count, CAPS.gaps);
const artists = albums ? 0 : cap(o.artists_in_common_count, CAPS.artists);
const points = albums * WEIGHTS.albumInCommon + gaps * WEIGHTS.gap + artists * WEIGHTS.artistInCommon;
add(o.username, points, {
kind: "overlap",
albums: o.albums_in_common_count ?? 0,
gaps: o.gaps_count ?? 0,
artists: o.artists_in_common_count ?? 0,
});
}
for (const su of input.similar ?? []) {
add(su.username, (su.rating ?? 0) * WEIGHTS.like, { kind: "likes", n: su.rating ?? 0 });
}
// Rooms: the one on screen counts most; the rest count once each.
const roomsOf = new Map();
for (const [name, room] of Object.entries(input.rooms ?? {})) {
for (const u of room?.users ?? []) {
if (!u?.username) continue;
if (!roomsOf.has(u.username)) roomsOf.set(u.username, []);
roomsOf.get(u.username).push(name);
}
}
for (const [u, names] of roomsOf) {
const active = input.activeRoom && names.includes(input.activeRoom) ? input.activeRoom : null;
const others = names.filter((n) => n !== active);
const points = (active ? WEIGHTS.activeRoom : 0) + cap(others.length, CAPS.rooms) * WEIGHTS.room;
add(u, points, { kind: "rooms", active, others });
}
const tally = new Map();
const count = (u, side) => {
if (!u) return;
if (!tally.has(u)) tally.set(u, { from: 0, to: 0 });
tally.get(u)[side] += 1;
};
for (const d of input.downloads ?? []) count(d?.username, "from");
for (const x of input.uploads ?? []) count(x?.username, "to");
for (const [u, t] of tally) {
add(u, cap(t.from + t.to, CAPS.transfers) * WEIGHTS.transfer, { kind: "transfers", from: t.from, to: t.to });
}
const space = input.space;
for (const m of space?.members ?? []) {
if (!m?.username || m.is_me) continue;
add(m.username, WEIGHTS.spaceMember, { kind: "space", name: space.name });
}
const out = [...people.values()].filter((p) => p.score > 0);
// Reasons strongest first; a tie keeps the order the signals were read
// in above, which is the order of how much each one says about taste.
for (const p of out) p.reasons.sort((a, b) => b.points - a.points);
out.sort((a, b) => b.score - a.score || a.reasons.length - b.reasons.length || a.username.localeCompare(b.username));
return out.slice(0, limit);
}
/** A reason as the card draws it: segments, the numbers and names bold.
* The wording is each source shelf's own, so a reason reads the same here
* as where it came from. */
export function reasonParts(r) {
const b = (t) => ({ t: String(t), b: true });
const t = (s) => ({ t: s, b: false });
const n = (k, one, many) => [b(k), t(` ${k === 1 ? one : many}`)];
switch (r.kind) {
case "overlap":
if (r.albums) return [t("shares "), ...n(r.albums, "album", "albums"), t(" you own"), ...(r.gaps ? [t(" · "), b(r.gaps), t(" you lack")] : [])];
if (r.artists) return [...n(r.artists, "artist", "artists"), t(" in common"), ...(r.gaps ? [t(" · "), b(r.gaps), t(" albums you lack")] : [])];
return [t("browsed, nothing in common")];
case "likes":
return [t("shares "), b(r.n), t(" of your declared likes")];
case "rooms": {
const parts = [];
if (r.active) parts.push(t("in "), b(`#${r.active}`), t(" now"));
if (r.others.length) {
if (parts.length) parts.push(t(" · "));
if (r.others.length === 1) parts.push(t("also in "), b(`#${r.others[0]}`));
else parts.push(t("in "), b(r.others.length), t(" rooms with you"));
}
return parts;
}
case "transfers": {
const parts = [];
if (r.from) parts.push(t("sent you "), ...n(r.from, "file", "files"));
if (r.from && r.to) parts.push(t(" · "));
if (r.to) parts.push(t("you sent them "), b(r.to));
return parts;
}
case "space":
return [t("with you in "), b(r.name)];
default:
return [];
}
}
/** A reason in words — what a test reads, and what the card says. */
export const reasonText = (r) => reasonParts(r).map((s) => s.t).join("");
Binary file not shown.
+13
View File
@@ -30,6 +30,8 @@
import { listenerPeak, listenerShare } from "../lib/listenerBars.js"; import { listenerPeak, listenerShare } from "../lib/listenerBars.js";
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js"; import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
import Icon from "../components/Icon.svelte"; import Icon from "../components/Icon.svelte";
import WhoHas from "../components/WhoHas.svelte";
import { community } from "../lib/community.svelte.js";
import PlaylistMenu from "../components/PlaylistMenu.svelte"; import PlaylistMenu from "../components/PlaylistMenu.svelte";
const page = $derived(catalog.albumPage); // { album, tracks, album_plays, … } const page = $derived(catalog.albumPage); // { album, tracks, album_plays, … }
@@ -887,6 +889,13 @@
</div> </div>
{/if} {/if}
{#if !inLibrary && community.spaces.length}
<section class="whohas-sec">
<div class="whohas-head"><h2>Who has this</h2><span class="p">from your rooms' holdings · names compared, never files</span></div>
{#key album.artist + "\u0000" + album.title}<WhoHas artist={album.artist} album={album.title} />{/key}
</section>
{/if}
<div class="tracklist"> <div class="tracklist">
{#if tracks.length} {#if tracks.length}
<div class="tcols" class:has-count={showCount}> <div class="tcols" class:has-count={showCount}>
@@ -1976,4 +1985,8 @@
:global(:root.glass) .stat-note { :global(:root.glass) .stat-note {
border-color: var(--glass-hairline); border-color: var(--glass-hairline);
} }
.whohas-sec { margin: 8px 0 18px; padding: 12px 14px; border-radius: 12px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.07); }
.whohas-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 6px; }
.whohas-head h2 { margin: 0; font-size: 15px; font-weight: 800; }
.whohas-head .p { font-size: 11.5px; color: var(--text-muted); }
</style> </style>
+29
View File
@@ -52,6 +52,8 @@
import { openPreview } from "../lib/preview/previewSession.svelte.js"; import { openPreview } from "../lib/preview/previewSession.svelte.js";
import { traceToSvg, svgDataUrl } from "../lib/trace.js"; import { traceToSvg, svgDataUrl } from "../lib/trace.js";
import Icon from "../components/Icon.svelte"; import Icon from "../components/Icon.svelte";
import WhoHas from "../components/WhoHas.svelte";
import { openWhoHas, holdersFor, pairKey, holderCount } from "../lib/whoHas.svelte.js";
import PlaylistMenu from "../components/PlaylistMenu.svelte"; import PlaylistMenu from "../components/PlaylistMenu.svelte";
import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js"; import { consumeScroll, restoreScroll } from "../lib/navigation.svelte.js";
import HeroGpuCanvas from "../components/HeroGpuCanvas.svelte"; import HeroGpuCanvas from "../components/HeroGpuCanvas.svelte";
@@ -2536,6 +2538,17 @@
}); });
const visibleTabs = $derived(tabs.filter((tab) => tab.key === activeTabKey)); const visibleTabs = $derived(tabs.filter((tab) => tab.key === activeTabKey));
// Who in your rooms holds a release you lack: asked for the visible tab's
// families at once, filed by pairKey; the count rides the card, and the
// open album shows the people.
let heldBy = $state({});
const heldFor = (title) => (page ? heldBy[pairKey(page.artist.name, title)] : null);
$effect(() => {
const name = page?.artist?.name;
const items = visibleTabs.flatMap((g) => g.items).filter((al) => !inLibrary(al)).map((al) => ({ artist: name, album: al.title }));
if (!name || !items.length) return;
untrack(() => void holdersFor(items).then((m) => { for (const [k, v] of m) heldBy[k] = v; }));
});
// FLIP for the stage-2 correction. Svelte's `animate:` directive can't be // FLIP for the stage-2 correction. Svelte's `animate:` directive can't be
// used here — it requires the animated element to be the ONLY child of the // used here — it requires the animated element to be the ONLY child of the
@@ -5399,6 +5412,9 @@
onclick={(e) => { e.stopPropagation(); streamRelease(al, al); }} onclick={(e) => { e.stopPropagation(); streamRelease(al, al); }}
><Icon name="play" size={13} /></button> ><Icon name="play" size={13} /></button>
{/if} {/if}
{#if heldFor(al.title)?.length}
<button class="have" title="In your rooms — who has it?" onclick={(e) => { e.stopPropagation(); openWhoHas({ artist: page.artist.name, album: al.title, cover: al.cover ?? null }, e.currentTarget.closest(".release")); }}>{holderCount(heldFor(al.title))} {holderCount(heldFor(al.title)) === 1 ? "has it" : "have it"}</button>
{/if}
</div> </div>
<div class="title" title={al.title}> <div class="title" title={al.title}>
{al.title} {al.title}
@@ -5565,6 +5581,12 @@
{/if} {/if}
</div> </div>
<div class="xp-tracks"> <div class="xp-tracks">
{#if openAlbum && !inLibrary(openAlbum) && heldFor(openAlbum.title)?.length}
<div class="xp-whohas">
<div class="xp-whohas-head"><span class="t">Who has this</span><span class="p">from your rooms' holdings · names compared, never files</span></div>
{#key openAlbum.id}<WhoHas artist={page.artist.name} album={openAlbum.title} compact />{/key}
</div>
{/if}
{#if openLoading} {#if openLoading}
<!-- Nothing at all until the load <!-- Nothing at all until the load
has outlasted LOADING_GRACE: has outlasted LOADING_GRACE:
@@ -9602,4 +9624,11 @@
:global(:root.glass) .notch { :global(:root.glass) .notch {
background: var(--glass-surface); background: var(--glass-surface);
} }
/* who in your rooms holds a release you lack — the count rides the card */
.have { position: absolute; right: 6px; bottom: 6px; z-index: 2; display: inline-flex; align-items: center; gap: 4px; height: 20px; padding: 0 7px; border-radius: 99px; background: rgba(0, 0, 0, 0.62); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); color: #fff; font-size: 10.5px; font-weight: 900; border: 1px solid rgba(255, 255, 255, 0.14); cursor: pointer; white-space: nowrap; }
.have:hover { background: rgba(0, 0, 0, 0.82); border-color: rgba(255, 255, 255, 0.34); }
.xp-whohas { margin: 0 0 12px; padding: 10px 12px; border-radius: 10px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.07); }
.xp-whohas-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 4px; flex-wrap: wrap; }
.xp-whohas-head .t { font-size: 12.5px; font-weight: 800; white-space: nowrap; }
.xp-whohas-head .p { font-size: 11px; color: var(--text-muted); }
</style> </style>
+118 -116
View File
@@ -13,6 +13,8 @@
import { onMount, untrack } from "svelte"; import { onMount, untrack } from "svelte";
import Icon from "../components/Icon.svelte"; import Icon from "../components/Icon.svelte";
import Identicon, { hueOf } from "../components/Identicon.svelte"; import Identicon, { hueOf } from "../components/Identicon.svelte";
import WhoHas from "../components/WhoHas.svelte";
import { rankPeople, reasonParts } from "../lib/discover.js";
import Soulseek from "./Soulseek.svelte"; import Soulseek from "./Soulseek.svelte";
import * as api from "../lib/api.js"; import * as api from "../lib/api.js";
import { import {
@@ -95,6 +97,9 @@
pinToSpace, pinToSpace,
leaveSpace, leaveSpace,
setShareListens, setShareListens,
refreshHoldings,
holdingsFor,
setShareHoldings,
openBoard, openBoard,
proposeToBoard, proposeToBoard,
decideProposal, decideProposal,
@@ -362,6 +367,22 @@
} }
// ---- discover ------------------------------------------------------------ // ---- discover ------------------------------------------------------------
// The ranked shelf: the four silos below scored as one list, each card
// naming the shelves it drew from. Contacts and friends are left off —
// they have shelves of their own; this one is for people not yet sorted.
const ranked = $derived.by(() =>
rankPeople({
me,
skip: (u) => isContact(u) || friendshipFor(u)?.state === "friends",
rooms: slsk.joined,
activeRoom: slsk.activeRoom,
downloads: Object.values(downloads.items),
uploads: Object.values(network.uploads),
similar: community.similar?.users ?? [],
overlap: community.overlap,
space: community.space ? { name: community.space.name, members: community.space.members } : null,
}),
);
const roomPeople = $derived.by(() => { const roomPeople = $derived.by(() => {
if (!activeRoom) return []; if (!activeRoom) return [];
return activeRoom.users return activeRoom.users
@@ -413,7 +434,6 @@
let exportDone = $state(null); let exportDone = $state(null);
let confirmRotate = $state(false); let confirmRotate = $state(false);
let confirmRemove = $state(false); let confirmRemove = $state(false);
let disclosuresOpen = $state(false);
async function doExport() { async function doExport() {
const r = await exportPersona(passExport); const r = await exportPersona(passExport);
if (r) { if (r) {
@@ -531,6 +551,18 @@
let spaceCreating = $state(false); let spaceCreating = $state(false);
const sv = $derived(community.space); const sv = $derived(community.space);
const spaceOwner = $derived(sv?.my_role === "owner"); const spaceOwner = $derived(sv?.my_role === "owner");
// Holdings: the room's lists are read when a Space opens (stale members
// in the background; the event brings the result back), and the ask box
// answers from the cache.
const hv = $derived(sv ? holdingsFor(sv.owner, sv.space_id) : null);
let askArtist = $state("");
let askAlbum = $state("");
let asked = $state(null);
$effect(() => {
const id = sv?.id;
if (!id) return;
untrack(() => void refreshHoldings(sv.owner, sv.space_id));
});
let spaceEl = $state(null); let spaceEl = $state(null);
$effect(() => { $effect(() => {
const n = sv?.messages.length; const n = sv?.messages.length;
@@ -1173,6 +1205,25 @@
<!-- ── DISCOVER ───────────────────────────────────────────────────── --> <!-- ── DISCOVER ───────────────────────────────────────────────────── -->
{:else if community.section === "discover"} {:else if community.section === "discover"}
<div class="shelf">
<div class="ax-head">
<h2 class="ax-title">Closest to you</h2>
<span class="prov"><Icon name="sparkle" size={12} /> the shelves below, scored together · every reason named</span>
</div>
{#if ranked.length}
<div class="track">
{#each ranked as p (p.username)}
<button class="pcard" onclick={() => (community.drawer = p.username)}>
<span class="face" style:background="hsl({hueOf(p.username)} 30% 14%)"><Identicon seed={p.username} size={84} presence={presenceOf(p.username)} /></span>
<span class="nm">{display(p.username)}</span>
<span class="why">{#each p.reasons as r, i}{#if i}{" · "}{/if}{#each reasonParts(r) as seg}{#if seg.b}<b>{seg.t}</b>{:else}{seg.t}{/if}{/each}{/each}</span>
</button>
{/each}
</div>
{:else}
<div class="emptyshelf"><Icon name="sparkle" size={22} /><div><b>Nothing to rank yet.</b> This shelf is the ones below added up — join a room, browse someone, declare a like — with contacts and friends left off.</div></div>
{/if}
</div>
<div class="shelf"> <div class="shelf">
<div class="ax-head"> <div class="ax-head">
<h2 class="ax-title">{slsk.activeRoom ? `In #${slsk.activeRoom} right now` : "In a room right now"}</h2> <h2 class="ax-title">{slsk.activeRoom ? `In #${slsk.activeRoom} right now` : "In a room right now"}</h2>
@@ -1326,6 +1377,7 @@
<div class="sptabs"> <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 === "chat"} onclick={() => (spaceTab = "chat")}>Chat<b>{sv.messages.length}</b></button>
<button class="subtab" class:on={spaceTab === "listening"} onclick={() => (spaceTab = "listening")}>Listening{#if sv.listens.length}<b>{sv.listens.length}</b>{/if}</button> <button class="subtab" class:on={spaceTab === "listening"} onclick={() => (spaceTab = "listening")}>Listening{#if sv.listens.length}<b>{sv.listens.length}</b>{/if}</button>
<button class="subtab" class:on={spaceTab === "holdings"} onclick={() => (spaceTab = "holdings")}>Holdings{#if hv?.sharing}<b>{hv.sharing}</b>{/if}</button>
<button class="subtab" class:on={spaceTab === "members"} onclick={() => (spaceTab = "members")}>Members<b>{sv.members.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> <button class="subtab" class:on={spaceTab === "about"} onclick={() => (spaceTab = "about")}>About</button>
<button class="subtab" class:on={spaceTab === "proposals"} onclick={() => (spaceTab = "proposals")}>Proposals{#if bv && bv.uuid === sv.pinned_uuid && bv.proposals.length}<b>{bv.proposals.length}</b>{/if}</button> <button class="subtab" class:on={spaceTab === "proposals"} onclick={() => (spaceTab = "proposals")}>Proposals{#if bv && bv.uuid === sv.pinned_uuid && bv.proposals.length}<b>{bv.proposals.length}</b>{/if}</button>
@@ -1410,6 +1462,50 @@
</div> </div>
{/if} {/if}
</div> </div>
{:else if spaceTab === "holdings"}
<div class="panel sharebar" style="margin-bottom:12px">
<button class="toggle" class:on={sv.share_holdings} aria-pressed={sv.share_holdings} title="Share what I hold in this Space"
onclick={() => setShareHoldings(sv.owner, sv.space_id, !sv.share_holdings)} disabled={!nodeUp || !sv.can_post || !!community.busy["holdings:share"]}><i></i></button>
<div class="grow">
<div class="ttl">Share what I hold in this Space</div>
<p class="hint">{#if sv.share_holdings && hv?.mine && !hv.mine.withdrawn}<b>{fmtCount(hv.mine.count)} albums</b> · published{#if sv.private} sealed to this room's epoch key{/if} · updated when your library changes · <b>synced as of {fmtClock(hv.mine.published_at * 1000)}</b>{:else}Album names as keys, one quality word each, and your Soulseek name so members can get it from you — never files. Per Space, off until you say so.{/if}</p>
</div>
{#if community.busy["holdings:share"]}<span class="chip off">working…</span>{:else if sv.share_holdings}<span class="chip acc">sharing</span>{:else}<span class="chip off">not sharing</span>{/if}
</div>
<div class="holddisc"><Icon name="alert" size={14} /><span><b>What the room learns:</b> members can tell whether you hold any album they can name, and roughly how many you hold. They never see files, paths, or formats beyond a lossless/lossy word. Turning this off withdraws the list; copies members already read stay with them.</span></div>
<div class="panel" style="margin-top:12px">
<h3><Icon name="search" size={13} /> Ask this room{#if hv}<span class="chip up">{hv.sharing} of {sv.members.length} sharing</span>{/if}</h3>
<form class="inl askinl" style="margin-top:8px" onsubmit={(e) => { e.preventDefault(); if (askArtist.trim() && askAlbum.trim()) asked = { artist: askArtist.trim(), album: askAlbum.trim() }; }}>
<input placeholder="Artist" bind:value={askArtist} />
<input placeholder="Album" bind:value={askAlbum} />
<button class="ax-ghost sm" type="submit" disabled={!askArtist.trim() || !askAlbum.trim()}>Ask</button>
</form>
{#if asked}{#key asked.artist + "\u0000" + asked.album}<div style="margin-top:8px"><WhoHas artist={asked.artist} album={asked.album} /></div>{/key}{/if}
</div>
<div class="panel" style="margin-top:12px">
<h3><Icon name="users" size={13} /> Who shares here <button class="ax-ghost sm" style="margin-left:auto" onclick={() => refreshHoldings(sv.owner, sv.space_id, true)} disabled={!nodeUp}><Icon name="refresh" size={12} /> Refresh</button></h3>
{#if hv}
<div class="hlist">
<div class="hrow">
<Identicon seed={key ?? "you"} size={30} />
<span class="tx"><span class="l1">you</span>
<span class="l2">{#if sv.share_holdings && hv.mine && !hv.mine.withdrawn}<b>{fmtCount(hv.mine.count)}</b> albums · list {hv.mine.sequence} · published {fmtClock(hv.mine.published_at * 1000)}{:else}not sharing{/if}</span></span>
<span class="chip" class:acc={sv.share_holdings && hv.mine && !hv.mine.withdrawn} class:off={!(sv.share_holdings && hv.mine && !hv.mine.withdrawn)}>{sv.share_holdings && hv.mine && !hv.mine.withdrawn ? "shared" : "none"}</span>
</div>
{#each hv.members.filter((m) => !m.is_me) as m (m.key)}
<div class="hrow">
<Identicon seed={m.username ?? m.key} size={30} />
<span class="tx"><span class="l1">{m.is_me ? "you" : m.username ? display(m.username) : fpShort(m.key)}</span>
<span class="l2">{#if m.state === "shared"}<b>{fmtCount(m.count)}</b> albums · list {m.sequence}{#if m.read_at} · read {fmtClock(m.read_at * 1000)}{/if}{:else if m.state === "none"}not sharing{:else if m.state === "withdrawn"}withdrew their list{:else if m.state === "sealed"}sealed to an epoch you have no key for{:else}not read yet{/if}</span></span>
<span class="chip" class:acc={m.state === "shared"} class:off={m.state !== "shared"}>{m.state}</span>
</div>
{/each}
</div>
{:else}
<p class="hint" style="margin-top:8px">Reading the room's lists…</p>
{/if}
<p class="hint" style="margin-top:8px">A member's list is read when the Space opens and again after ten minutes; a member who never published costs a slow search, so they are asked again only hourly.</p>
</div>
{:else if spaceTab === "members"} {:else if spaceTab === "members"}
<div class="panel"> <div class="panel">
<div class="members"> <div class="members">
@@ -1712,98 +1808,7 @@
<div class="yc c"> <div class="yc c">
<div class="panel"> <div class="panel">
<h3>Networks</h3> <h3>Networks</h3>
<div class="netcard"> <p class="hint">The Soulseek link, the Freenet node, remote images and the encrypted inbox live in <button class="lnk" onclick={() => goToView("settings")}>Settings → Networks</button> now — every switch in one place.</p>
<div class="nico slsk"><Icon name="globe" size={18} /></div>
<div class="ntx">
<div class="t">Soulseek <span class="chip {online ? 'ok' : 'off'}">{online ? "online" : "offline"}</span></div>
<div class="s">
{#if me}Signed in as <b>{me}</b> · {fmtCount(network.shareFiles)} files shared.{:else}Not signed in — see Settings.{/if}
<br />
{#if !key}
Create a SoulStream identity to link this account to it.
{:else if !me}
Sign in to Soulseek to link this account to your identity.
{:else if ownLinkCurrent}
<b>Linked to this identity</b> — proof verified {new Date(ownLink.verified_at * 1000).toLocaleString()} through a Soulseek message to yourself. Other SoulStream users who ask over Soulseek now get your signed ID; nothing is published.
{:else}
Not linked to your identity. Linking sends one message to yourself over Soulseek and verifies the signed answer — nothing is published, and from then on other SoulStream users who ask get your signed ID.
{/if}
</div>
{#if key && me}
<div class="noderow">
{#if ownLinkCurrent}
<button class="ax-ghost sm" onclick={() => unlinkSelf()} disabled={!!community.busy.linkself}>Unlink</button>
{:else}
<button class="ax-fill sm" onclick={() => linkSelf()} disabled={!online || !nodeUp || !!community.busy.linkself}><Icon name="link" size={12} /> {community.busy.linkself ? "Linking…" : "Link this account"}</button>
{#if !nodeUp}<span class="hint">needs the node connected</span>{/if}
{/if}
</div>
{/if}
</div>
</div>
<div class="netcard">
<div class="nico fn"><Icon name="disc" size={18} /></div>
<div class="ntx">
<div class="t">Freenet <span class="chip acc">experimental</span></div>
<div class="s">
{#if freenetOn}
Local node on 127.0.0.1:{community.freenet?.ws_port ?? 7509} · <span class="chip {status.tone}">{status.text}</span>
<button class="lnk" onclick={() => detectNode()} disabled={!!community.busy.detect}>{community.busy.detect ? "checking…" : "check again"}</button>
{#if statusKind(community.freenet?.status) === "node-absent"}
<div class="noderow">
{#if community.nodeInfo?.binary}
<button class="ax-fill sm" onclick={() => startNode()} disabled={!!community.busy.node}>
<Icon name="disc" size={12} /> {community.busy.node ? "Starting…" : "Start node"}
</button>
<span class="hint">runs the installed peer{#if community.nodeInfo.version} ({community.nodeInfo.version}){/if} in the background · telemetry pinned off · data in the app folder</span>
{:else}
<span class="hint warn">No Freenet peer found on this machine — install it (cargo install freenet) and check again.</span>
{/if}
</div>
{:else if community.nodeInfo?.started_by_app}
<div class="noderow">
<button class="ax-ghost sm" onclick={() => stopNode()} disabled={!!community.busy.node}>{community.busy.node ? "Stopping…" : "Stop node"}</button>
<span class="hint">started by SoulStream · keeps running when the app closes; this button stops it</span>
</div>
{/if}
{:else}
Off. Carries your identity and profile; later playlists, friend links and Spaces. Turning it on publishes nothing. Turning it off is instant and touches nothing else in the app.
{/if}
</div>
<details class="disc" bind:open={disclosuresOpen}>
<summary>Before you rely on it — seven things that are true</summary>
<ul>
<li>A desktop background peer must run; it uses network, disk, memory and CPU.</li>
<li>The network is alpha. Things you publish are replicated and cannot be guaranteed erased.</li>
<li>Freenet is not an anonymity service.</li>
<li>The node's own diagnostics are a node setting SoulStream pins off; verify after upgrades.</li>
<li>Nothing is published when you enable this. Publishing is always a button.</li>
<li>Nothing here ever says "online" — a successful fetch is not presence.</li>
<li>Phones and TVs cannot run the same peer today.</li>
</ul>
</details>
{#if freenetOn}
<label class="swrow">
<button class="tog" class:on={community.freenet?.load_remote_images ?? true} role="switch" aria-label="Load remote profile images" aria-checked={community.freenet?.load_remote_images ?? true} onclick={() => setLoadRemoteImages(!(community.freenet?.load_remote_images ?? true))} disabled={!!community.busy.images}></button>
<span>Load remote profile images</span>
<span class="hint r">verified, resized, cached by hash</span>
</label>
{#if key}
<div class="noderow">
{#if community.inbox?.published}
<span class="chip ok"><Icon name="lock" size={10} />encrypted inbox · key {community.inbox.box_key_id}</span>
<button class="ax-ghost sm" onclick={() => checkInbox()} disabled={!nodeUp || !!community.busy.inboxcheck}>{community.busy.inboxcheck ? "Checking…" : "Check now"}</button>
<span class="hint">{community.inbox.last_check ? `checked ${fmtWhen(community.inbox.last_check * 1000)} · ` : ""}friend requests and shares arrive sealed to a key kept on this device; a persona restored elsewhere publishes a new one</span>
{:else}
<button class="ax-fill sm" onclick={() => publishInbox()} disabled={!nodeUp || !!community.busy.inbox}><Icon name="lock" size={12} /> {community.busy.inbox ? "Publishing…" : "Publish an encrypted inbox"}</button>
<span class="hint">lets other SoulStream users send you friend requests and shares sealed, without Soulseek in between</span>
{/if}
</div>
{/if}
{/if}
</div>
<button class="tog big" class:on={freenetOn} role="switch" aria-checked={freenetOn} title={freenetOn ? "Turn the Freenet layer off" : "Turn the Freenet layer on"} onclick={() => setFreenetEnabled(!freenetOn)} disabled={!!community.busy.freenet}></button>
</div>
</div> </div>
<div class="panel dim"> <div class="panel dim">
<h3><Icon name="devices" size={13} /> Devices <span class="chip off">later</span></h3> <h3><Icon name="devices" size={13} /> Devices <span class="chip off">later</span></h3>
@@ -1993,13 +1998,14 @@
throughout (the approved knob): 46px rows, 12px gaps and pads. */ throughout (the approved knob): 46px rows, 12px gaps and pads. */
.community { .community {
position: relative; position: relative;
/* A designed page width, centred in the pane: at 2560 the sections used /* The whole pane, edge to edge (Kyle, 2026-09-08: "not using the full
to huddle top-left with the rest of the pane empty (Kyle, 2026-09-02). window"). The 1520px cap from 2026-09-02 answered a page that
Below the cap nothing changes. */ huddled top-left at 2560; now every section stretches instead —
max-width: 1520px; the shelves, the split grids, the You grid and the chat threads all
margin: 0 auto; size to the pane; no inner cap remains. */
padding-top: calc(var(--bar-h) + 18px); max-width: none;
padding-bottom: 40px; margin: 0;
padding: calc(var(--bar-h) + 18px) 24px 40px;
--row: 46px; --row: 46px;
--gap: 12px; --gap: 12px;
--pad: 12px; --pad: 12px;
@@ -2354,6 +2360,18 @@
.mem .nm { font-size: 11.5px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 96px; } .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 { font-size: 9.5px; font-weight: 900; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); }
.mem .role.own { color: #d9a6ff; } .mem .role.own { color: #d9a6ff; }
/* holdings: the disclosure and the who-shares list */
.holddisc { display: flex; gap: 8px; align-items: flex-start; padding: 9px 11px; border-radius: 9px; background: color-mix(in srgb, var(--warn) 7%, transparent); border: 1px solid color-mix(in srgb, var(--warn) 25%, transparent); font-size: 11.5px; color: var(--text-secondary); line-height: 1.5; }
.holddisc b { color: #f0c070; }
.askinl input { height: 30px; padding: 0 10px; border-radius: 8px; background: var(--surf-hi); border: none; color: var(--text-primary); font-size: 12px; outline: none; }
.askinl input::placeholder { color: var(--text-muted); }
.hlist { display: flex; flex-direction: column; gap: 2px; margin-top: 8px; }
.hrow { display: flex; align-items: center; gap: 10px; padding: 5px 8px; border-radius: 8px; min-height: 40px; }
.hrow:hover { background: var(--surf-hi); }
.hrow .tx { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
.hrow .l1 { font-size: 13px; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hrow .l2 { font-size: 11.5px; color: var(--text-muted); }
.hrow .l2 b { color: var(--text-secondary); }
/* ── you ── */ /* ── you ── */
/* The You section sized by the PANE (container query), never the window: /* The You section sized by the PANE (container query), never the window:
three columns from ~1240px of pane, two down to ~820, then one. */ three columns from ~1240px of pane, two down to ~820, then one. */
@@ -2372,8 +2390,6 @@
.yougrid, .yougrid.setup { grid-template-columns: 1fr; } .yougrid, .yougrid.setup { grid-template-columns: 1fr; }
.yc.c { grid-column: 1; grid-row: auto; } .yc.c { grid-column: 1; grid-row: auto; }
} }
/* A chat wider than a comfortable line reads badly; the pane may be 1900px. */
.split .thread { max-width: 1200px; }
.noderow { display: flex; align-items: center; gap: 10px; margin-top: 10px; flex-wrap: wrap; } .noderow { display: flex; align-items: center; gap: 10px; margin-top: 10px; flex-wrap: wrap; }
.bl { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--accent-hover); animation: cm-blink 1.4s infinite; margin-right: 2px; vertical-align: middle; } .bl { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--accent-hover); animation: cm-blink 1.4s infinite; margin-right: 2px; vertical-align: middle; }
@keyframes cm-blink { 50% { opacity: 0.25; } } @keyframes cm-blink { 50% { opacity: 0.25; } }
@@ -2414,25 +2430,11 @@
.kv dt { color: var(--text-muted); font-weight: 700; } .kv dt { color: var(--text-muted); font-weight: 700; }
.kv dd { margin: 0; color: var(--text-secondary); overflow-wrap: anywhere; } .kv dd { margin: 0; color: var(--text-secondary); overflow-wrap: anywhere; }
.kv dd b { color: var(--text-primary); } .kv dd b { color: var(--text-primary); }
.netcard { display: flex; gap: 12px; align-items: flex-start; margin-top: 12px; }
.nico { width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; flex: none; }
.nico.slsk { background: rgba(255, 255, 255, 0.08); color: #fff; }
.nico.fn { background: var(--accent-soft); color: var(--accent-hover); }
.ntx { min-width: 0; flex: 1; }
.netcard .t { font-size: 13.5px; font-weight: 800; display: flex; align-items: center; gap: 8px; }
.netcard .s { font-size: 11.5px; color: var(--text-muted); margin-top: 3px; line-height: 1.5; }
.netcard .s b { color: var(--text-secondary); font-weight: 700; }
.tog { display: inline-block; width: 36px; height: 20px; border-radius: 10px; background: rgba(255, 255, 255, 0.16); position: relative; flex: none; transition: background 0.15s; } .tog { display: inline-block; width: 36px; height: 20px; border-radius: 10px; background: rgba(255, 255, 255, 0.16); position: relative; flex: none; transition: background 0.15s; }
.tog::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: left 0.15s; } .tog::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: left 0.15s; }
.tog.on { background: var(--accent); } .tog.on { background: var(--accent); }
.tog.on::after { left: 18px; } .tog.on::after { left: 18px; }
.tog:disabled { opacity: 0.5; cursor: default; } .tog:disabled { opacity: 0.5; cursor: default; }
.tog.big { margin-top: 6px; }
.swrow { display: flex; align-items: center; gap: 10px; margin-top: 12px; font-size: 12px; color: var(--text-secondary); }
.swrow .hint.r { margin-left: auto; }
.disc { margin-top: 10px; }
.disc summary { font-size: 11.5px; font-weight: 800; color: var(--text-secondary); cursor: pointer; }
.disc ul { margin: 8px 0 0; padding-left: 18px; font-size: 11.5px; color: var(--text-muted); line-height: 1.6; }
.danger { display: flex; gap: 8px; align-items: center; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--hair); flex-wrap: wrap; } .danger { display: flex; gap: 8px; align-items: center; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--hair); flex-wrap: wrap; }
/* ── the person drawer (portaled to body — see portal()) ── */ /* ── the person drawer (portaled to body — see portal()) ── */
+25 -4
View File
@@ -19,7 +19,7 @@
// is CatalogArtist's class for class with its animation numbers unchanged. // is CatalogArtist's class for class with its animation numbers unchanged.
// Styles are copied because Svelte scopes them per component — the same // Styles are copied because Svelte scopes them per component — the same
// trade CatalogDiscography made, with provenance comments at each block. // trade CatalogDiscography made, with provenance comments at each block.
import { tick } from "svelte"; import { tick, untrack } from "svelte";
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
import Icon from "../components/Icon.svelte"; import Icon from "../components/Icon.svelte";
import PlaylistMenu from "../components/PlaylistMenu.svelte"; import PlaylistMenu from "../components/PlaylistMenu.svelte";
@@ -41,6 +41,7 @@
importResume, importResume,
goToView, goToView,
} from "../lib/state.svelte.js"; } from "../lib/state.svelte.js";
import { openWhoHas, holdersFor, pairKey, holderCount } from "../lib/whoHas.svelte.js";
import { import {
fileRelease, fileRelease,
CAT_ORDER, CAT_ORDER,
@@ -121,6 +122,17 @@
} catch { return {}; } } catch { return {}; }
})()); })());
let gapRows = $state({}); // foldKey(artist) -> catalog rows not owned let gapRows = $state({}); // foldKey(artist) -> catalog rows not owned
let gapNames = $state({}); // foldKey(artist) -> the artist's display name
// Who in your rooms holds a gap: asked for every open shelf's rows at
// once, filed by pairKey. A marked card opens the ask sheet.
let heldBy = $state({});
$effect(() => {
const items = Object.keys(gapOpen)
.filter((k) => gapOpen[k] && gapRows[k]?.length && gapNames[k])
.flatMap((k) => gapRows[k].map((r) => ({ artist: gapNames[k], album: r.title })));
if (!items.length) return;
untrack(() => void holdersFor(items).then((m) => { for (const [k, v] of m) heldBy[k] = v; }));
});
let gapOpen = $state({}); // foldKey(artist) -> line expanded let gapOpen = $state({}); // foldKey(artist) -> line expanded
let gapLoading = $state({}); let gapLoading = $state({});
@@ -182,6 +194,7 @@
...gapRows, ...gapRows,
[key]: released.filter((r) => !ownedTitles.has(normKey(r.title))), [key]: released.filter((r) => !ownedTitles.has(normKey(r.title))),
}; };
gapNames = { ...gapNames, [key]: artistName };
gapTotals = { ...gapTotals, [key]: { total: released.length } }; gapTotals = { ...gapTotals, [key]: { total: released.length } };
try { localStorage.setItem(GAPS_KEY, JSON.stringify(gapTotals)); } catch { /* ignore */ } try { localStorage.setItem(GAPS_KEY, JSON.stringify(gapTotals)); } catch { /* ignore */ }
} catch { /* the line stays collapsed; nothing is invented */ } } catch { /* the line stays collapsed; nothing is invented */ }
@@ -1544,7 +1557,7 @@
</div> </div>
{/snippet} {/snippet}
{#snippet gapCard(r)} {#snippet gapCard(r, artist)}
<!-- A record you do NOT own, at full size and dimmed. It is filed by the <!-- A record you do NOT own, at full size and dimmed. It is filed by the
same classifier as everything else, off the CatalogAlbum payload's own same classifier as everything else, off the CatalogAlbum payload's own
fields (`record_type`/`track_count`/`release_date`, plus the is_live and fields (`record_type`/`track_count`/`release_date`, plus the is_live and
@@ -1558,7 +1571,8 @@
is_live: r.is_live, is_live: r.is_live,
is_soundtrack: r.is_soundtrack, is_soundtrack: r.is_soundtrack,
})} })}
<div class="release gap" role="listitem" title={`${r.title} — not in your library`}> {@const held = heldBy[pairKey(artist, r.title)]}
<div class="release gap" class:held={!!held?.length} role="listitem" title={`${r.title} — not in your library`}>
<div class="cover-wrap"> <div class="cover-wrap">
{#if r.release_date?.slice(0, 4)} {#if r.release_date?.slice(0, 4)}
<span class="ed-badge">{r.release_date.slice(0, 4)}</span> <span class="ed-badge">{r.release_date.slice(0, 4)}</span>
@@ -1568,6 +1582,9 @@
{:else} {:else}
<div class="cover placeholder"><Icon name="disc" size={30} /></div> <div class="cover placeholder"><Icon name="disc" size={30} /></div>
{/if} {/if}
{#if held?.length}
<button class="have" title="{holderCount(held)} in your rooms {holderCount(held) === 1 ? "has" : "have"} this — who?" onclick={(e) => { e.stopPropagation(); openWhoHas({ artist, album: r.title, cover: r.cover ?? null }, e.currentTarget.closest(".release")); }}>{holderCount(held)} {holderCount(held) === 1 ? "has it" : "have it"}</button>
{/if}
</div> </div>
<div class="title" title={r.title}>{r.title}</div> <div class="title" title={r.title}>{r.title}</div>
<div class="sub">{cat === "other" ? "" : CAT_ONE[cat]}</div> <div class="sub">{cat === "other" ? "" : CAT_ONE[cat]}</div>
@@ -1826,7 +1843,7 @@
<div class="lib-gaps" role="list"> <div class="lib-gaps" role="list">
<div class="grid"> <div class="grid">
{#each missing as r (r.id)} {#each missing as r (r.id)}
{@render gapCard(r)} {@render gapCard(r, name)}
{/each} {/each}
</div> </div>
</div> </div>
@@ -2942,6 +2959,10 @@
.lib-gaps { margin-top: 12px; } .lib-gaps { margin-top: 12px; }
.lib-gaps .release.gap { opacity: 0.44; transition: opacity 150ms ease; cursor: default; } .lib-gaps .release.gap { opacity: 0.44; transition: opacity 150ms ease; cursor: default; }
.lib-gaps .release.gap:hover { opacity: 1; background: none; } .lib-gaps .release.gap:hover { opacity: 1; background: none; }
.lib-gaps .release.gap.held { opacity: 0.72; }
/* who in your rooms holds a release you lack — the count rides the card */
.have { position: absolute; right: 6px; bottom: 6px; z-index: 2; display: inline-flex; align-items: center; gap: 4px; height: 20px; padding: 0 7px; border-radius: 99px; background: rgba(0, 0, 0, 0.62); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); color: #fff; font-size: 10.5px; font-weight: 900; border: 1px solid rgba(255, 255, 255, 0.14); cursor: pointer; white-space: nowrap; }
.have:hover { background: rgba(0, 0, 0, 0.82); border-color: rgba(255, 255, 255, 0.34); }
/* ============================================================ the table /* ============================================================ the table
The power-user surface: every column is something the files carry. The The power-user surface: every column is something the files carry. The
+603 -705
View File
File diff suppressed because it is too large. Load diff
+23
View File
@@ -39,6 +39,7 @@
} from "../lib/state.svelte.js"; } from "../lib/state.svelte.js";
import { untrack } from "svelte"; import { untrack } from "svelte";
import * as api from "../lib/api.js"; import * as api from "../lib/api.js";
import { community, forgetWait } from "../lib/community.svelte.js";
import Icon from "../components/Icon.svelte"; import Icon from "../components/Icon.svelte";
import PlaylistMenu from "../components/PlaylistMenu.svelte"; import PlaylistMenu from "../components/PlaylistMenu.svelte";
@@ -408,6 +409,18 @@
</div> </div>
{#if sub === "downloads"} {#if sub === "downloads"}
{#if community.waits.length}
<div class="waits">
<div class="waits-head">Waiting for <b>{community.waits.length}</b> · grabs that start the moment the person comes online</div>
{#each community.waits as w (w.username + "\u0000" + w.artist + "\u0000" + w.album)}
<div class="wait">
<span class="tx"><span class="t">{w.album}</span><span class="a">{w.artist} · from <b>{w.username}</b>{#if w.via} · via {w.via}{/if}</span></span>
<span class="chip">waiting</span>
<button class="toolbtn" onclick={() => forgetWait(w.username, w.artist, w.album)}>Forget</button>
</div>
{/each}
</div>
{/if}
{#if dlItems.length === 0} {#if dlItems.length === 0}
<div class="empty"> <div class="empty">
<p class="big">No downloads yet</p> <p class="big">No downloads yet</p>
@@ -1622,4 +1635,14 @@
:global(:root.glass) .bub.me { :global(:root.glass) .bub.me {
background: var(--accent-soft); background: var(--accent-soft);
} }
/* holdings: albums waiting on a person's presence */
.waits { margin: 0 0 12px; padding: 8px 10px; border: 1px dashed rgba(255, 255, 255, 0.16); border-radius: 10px; }
.waits-head { font-size: 11.5px; color: var(--text-muted); margin-bottom: 4px; }
.waits-head b { color: var(--text-secondary); }
.wait { display: flex; align-items: center; gap: 10px; padding: 6px 4px; }
.wait .tx { min-width: 0; flex: 1; display: flex; flex-direction: column; }
.wait .t { font-size: 12.5px; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wait .a { font-size: 11px; color: var(--text-muted); }
.wait .a b { color: var(--text-secondary); }
.wait .chip { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 99px; background: var(--bg-highlight); color: var(--text-secondary); }
</style> </style>
+86
View File
@@ -0,0 +1,86 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { rankPeople, reasonText, reasonParts, WEIGHTS, CAPS } from "../../src/lib/discover.js";
const overlapOf = (username, albums, gaps = 0, artists = 0) => ({
username,
albums_in_common_count: albums,
gaps_count: gaps,
artists_in_common_count: artists,
});
test("one list across the shelves, every card naming where it came from", () => {
const ranked = rankPeople({
me: "zoocone",
rooms: {
ambient: { users: [{ username: "aphex" }, { username: "zoocone" }, { username: "boc" }] },
idm: { users: [{ username: "boc" }] },
},
activeRoom: "ambient",
downloads: [{ username: "aphex" }, { username: "aphex" }],
uploads: [{ username: "autechre" }],
similar: [{ username: "boc", rating: 3 }, { username: "aphex", rating: 1 }],
overlap: [overlapOf("autechre", 4, 2), overlapOf("aphex", 0, 0, 0)],
space: { name: "Late Night Ambient", members: [{ username: "boc" }, { username: null }, { username: "zoocone", is_me: true }] },
});
assert.deepEqual(
ranked.map((p) => p.username),
["autechre", "boc", "aphex"],
);
const [autechre, boc, aphex] = ranked;
// autechre: 4 albums in common + 2 gaps, one upload
assert.equal(autechre.score, 4 * WEIGHTS.albumInCommon + 2 * WEIGHTS.gap + WEIGHTS.transfer);
assert.deepEqual(autechre.reasons.map(reasonText), ["shares 4 albums you own · 2 you lack", "you sent them 1"]);
// boc: 3 likes, in the active room and one other, a seat in the Space
assert.equal(boc.score, 3 * WEIGHTS.like + WEIGHTS.activeRoom + WEIGHTS.room + WEIGHTS.spaceMember);
assert.deepEqual(boc.reasons.map(reasonText), [
"shares 3 of your declared likes",
"in #ambient now · also in #idm",
"with you in Late Night Ambient",
]);
// aphex: a browse with nothing in common scores nothing and is not a
// reason; the three that remain tie at two points each and keep the
// signal order (likes, rooms, transfers)
assert.equal(aphex.score, WEIGHTS.activeRoom + 2 * WEIGHTS.transfer + WEIGHTS.like);
assert.deepEqual(aphex.reasons.map(reasonText), ["shares 1 of your declared likes", "in #ambient now", "sent you 2 files"]);
// we never list ourselves, and a member without a Soulseek name has no card
assert.ok(!ranked.some((p) => p.username === "zoocone"));
});
test("contacts and friends are someone else's shelf, and a cap keeps one browse honest", () => {
const ranked = rankPeople({
me: "zoocone",
skip: (u) => u === "buddy",
overlap: [overlapOf("buddy", 40, 40), overlapOf("hoarder", 40, 40, 40), overlapOf("thin", 0, 3, 2)],
similar: [{ username: "buddy", rating: 9 }],
});
assert.deepEqual(ranked.map((p) => p.username), ["hoarder", "thin"]);
assert.equal(ranked[0].score, CAPS.albums * WEIGHTS.albumInCommon + CAPS.gaps * WEIGHTS.gap);
// artists only count when no album is in common
assert.equal(ranked[1].score, 3 * WEIGHTS.gap + 2 * WEIGHTS.artistInCommon);
assert.equal(reasonText(ranked[1].reasons[0]), "2 artists in common · 3 albums you lack");
});
test("ties break toward fewer, stronger reasons, then by name; the limit holds", () => {
const ranked = rankPeople({
me: "",
similar: [{ username: "b", rating: 2 }, { username: "a", rating: 2 }],
downloads: [{ username: "c" }, { username: "c" }, { username: "c" }, { username: "c" }],
limit: 2,
});
// c: 4 transfers = 4 points; a and b: 2 likes = 4 points each, one reason each
assert.deepEqual(ranked.map((p) => p.username), ["a", "b"]);
assert.deepEqual(rankPeople({ me: "", similar: [] }), []);
assert.equal(reasonText({ kind: "rooms", active: null, others: ["x", "y"] }), "in 2 rooms with you");
// the numbers and names are what the card bolds
assert.deepEqual(
reasonParts({ kind: "transfers", from: 2, to: 1 }).filter((s) => s.b).map((s) => s.t),
["2", "1"],
);
assert.deepEqual(reasonParts({ kind: "rooms", active: "idm", others: [] }), [
{ t: "in ", b: false },
{ t: "#idm", b: true },
{ t: " now", b: false },
]);
});