community: friends, two signed answers on one request carried over soulseek, one answer is only ever pending
This commit is contained in:
1 parent
6ed6f5b8f8
commit
984738135a
9 files changed
+967
-4
No files matched your search
@@ -0,0 +1,281 @@
|
|||||||
|
//! Friendship proofs — F4 §7.5's exact shapes and verifier. A friendship
|
||||||
|
//! exists ONLY from two reciprocal, verified, currently-accepted assertions
|
||||||
|
//! on the same relationship id and request hash; one assertion is a pending
|
||||||
|
//! request and is never shown as friendship; a revocation is a NEWER
|
||||||
|
//! assertion with accepted=false — nothing is erased. Names and avatars
|
||||||
|
//! never enter any decision here.
|
||||||
|
//!
|
||||||
|
//! Sign convention: the delegate signs blake3(bincode(sig-body)) under
|
||||||
|
//! `soulstream.friend/v1`; the verifiers mirror it. Field order is the wire.
|
||||||
|
|
||||||
|
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub const FRIEND_DOMAIN: &str = "soulstream.friend/v1";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct FriendRequest {
|
||||||
|
pub relationship_id: [u8; 16],
|
||||||
|
pub sender: [u8; 32],
|
||||||
|
pub recipient: [u8; 32],
|
||||||
|
pub nonce: [u8; 16],
|
||||||
|
pub issued_at: u64,
|
||||||
|
pub expires_at: u64,
|
||||||
|
pub sig: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct RequestSigBody<'a> {
|
||||||
|
relationship_id: &'a [u8; 16],
|
||||||
|
sender: &'a [u8; 32],
|
||||||
|
recipient: &'a [u8; 32],
|
||||||
|
nonce: &'a [u8; 16],
|
||||||
|
issued_at: u64,
|
||||||
|
expires_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request_sig_body(r: &FriendRequest) -> Vec<u8> {
|
||||||
|
bincode::serialize(&RequestSigBody {
|
||||||
|
relationship_id: &r.relationship_id,
|
||||||
|
sender: &r.sender,
|
||||||
|
recipient: &r.recipient,
|
||||||
|
nonce: &r.nonce,
|
||||||
|
issued_at: r.issued_at,
|
||||||
|
expires_at: r.expires_at,
|
||||||
|
})
|
||||||
|
.expect("serializes")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the delegate is handed to sign for a request.
|
||||||
|
pub fn request_sign_payload(r: &FriendRequest) -> Vec<u8> {
|
||||||
|
blake3::hash(&request_sig_body(r)).as_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The request's identity: what both assertions bind to.
|
||||||
|
pub fn request_hash(r: &FriendRequest) -> [u8; 32] {
|
||||||
|
*blake3::hash(&request_sig_body(r)).as_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_request(r: &FriendRequest) -> bool {
|
||||||
|
crate::verify_sig_in(FRIEND_DOMAIN, &r.sender, &request_sign_payload(r), &r.sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One party's signed stance on one relationship.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct FriendAssertion {
|
||||||
|
pub relationship_id: [u8; 16],
|
||||||
|
pub signer: [u8; 32],
|
||||||
|
pub peer: [u8; 32],
|
||||||
|
pub request_hash: [u8; 32],
|
||||||
|
pub accepted: bool,
|
||||||
|
pub seq: u64,
|
||||||
|
pub sig: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct AssertionSigBody<'a> {
|
||||||
|
relationship_id: &'a [u8; 16],
|
||||||
|
signer: &'a [u8; 32],
|
||||||
|
peer: &'a [u8; 32],
|
||||||
|
request_hash: &'a [u8; 32],
|
||||||
|
accepted: bool,
|
||||||
|
seq: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn assertion_sig_body(a: &FriendAssertion) -> Vec<u8> {
|
||||||
|
bincode::serialize(&AssertionSigBody {
|
||||||
|
relationship_id: &a.relationship_id,
|
||||||
|
signer: &a.signer,
|
||||||
|
peer: &a.peer,
|
||||||
|
request_hash: &a.request_hash,
|
||||||
|
accepted: a.accepted,
|
||||||
|
seq: a.seq,
|
||||||
|
})
|
||||||
|
.expect("serializes")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn assertion_sign_payload(a: &FriendAssertion) -> Vec<u8> {
|
||||||
|
blake3::hash(&assertion_sig_body(a)).as_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_assertion(a: &FriendAssertion) -> bool {
|
||||||
|
crate::verify_sig_in(FRIEND_DOMAIN, &a.signer, &assertion_sign_payload(a), &a.sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A key-rotation transition (the identity delegate's): old key vouches
|
||||||
|
/// for new. Carried so a rotated friend can continue instead of re-accept.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct KeyTransition {
|
||||||
|
pub old_public: [u8; 32],
|
||||||
|
pub new_public: [u8; 32],
|
||||||
|
pub key_version: u32,
|
||||||
|
/// By the OLD key, the delegate's own transition construction.
|
||||||
|
pub sig: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transition_signable(old: &[u8; 32], new: &[u8; 32], v: u32) -> Vec<u8> {
|
||||||
|
let mut p = Vec::with_capacity(72);
|
||||||
|
p.extend_from_slice(b"rotate:");
|
||||||
|
p.extend_from_slice(old);
|
||||||
|
p.extend_from_slice(new);
|
||||||
|
p.extend_from_slice(&v.to_le_bytes());
|
||||||
|
crate::signable("soulstream.key-transition/v1", &p)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_transition(t: &KeyTransition) -> bool {
|
||||||
|
let Ok(vk) = VerifyingKey::from_bytes(&t.old_public) else { return false };
|
||||||
|
let Ok(sig) = Signature::from_slice(&t.sig) else { return false };
|
||||||
|
vk.verify(&transition_signable(&t.old_public, &t.new_public, t.key_version), &sig)
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum FriendStatus {
|
||||||
|
/// Both reciprocal assertions verified and accepted.
|
||||||
|
Friends,
|
||||||
|
/// One valid assertion — pending, never displayed as friendship.
|
||||||
|
PendingOneSided,
|
||||||
|
/// A newer signed revocation stands from at least one side.
|
||||||
|
Revoked,
|
||||||
|
/// A key changed without a verifiable transition — must re-accept.
|
||||||
|
ReacceptanceRequired,
|
||||||
|
NotRelated,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One persona's current key through transition records (one hop).
|
||||||
|
fn current_key(key: &[u8; 32], transitions: &[KeyTransition]) -> [u8; 32] {
|
||||||
|
for t in transitions {
|
||||||
|
if &t.old_public == key && verify_transition(t) {
|
||||||
|
return t.new_public;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*key
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The verifier. Duplicates collapse (the newest seq per side wins); a
|
||||||
|
/// rotated key without a transition forces re-acceptance.
|
||||||
|
pub fn friendship_status(
|
||||||
|
a_key: &[u8; 32],
|
||||||
|
b_key: &[u8; 32],
|
||||||
|
relationship_id: &[u8; 16],
|
||||||
|
req_hash: &[u8; 32],
|
||||||
|
assertions: &[FriendAssertion],
|
||||||
|
transitions: &[KeyTransition],
|
||||||
|
) -> FriendStatus {
|
||||||
|
let a_now = current_key(a_key, transitions);
|
||||||
|
let b_now = current_key(b_key, transitions);
|
||||||
|
let latest = |signer: &[u8; 32], peer: &[u8; 32]| -> Option<&FriendAssertion> {
|
||||||
|
assertions
|
||||||
|
.iter()
|
||||||
|
.filter(|a| {
|
||||||
|
&a.relationship_id == relationship_id
|
||||||
|
&& &a.request_hash == req_hash
|
||||||
|
&& ¤t_key(&a.signer, transitions) == signer
|
||||||
|
&& ¤t_key(&a.peer, transitions) == peer
|
||||||
|
&& verify_assertion(a)
|
||||||
|
})
|
||||||
|
.max_by_key(|a| a.seq)
|
||||||
|
};
|
||||||
|
let ab = latest(&a_now, &b_now);
|
||||||
|
let ba = latest(&b_now, &a_now);
|
||||||
|
match (ab, ba) {
|
||||||
|
(Some(x), Some(y)) if x.accepted && y.accepted => FriendStatus::Friends,
|
||||||
|
(Some(x), Some(y)) if !x.accepted || !y.accepted => FriendStatus::Revoked,
|
||||||
|
(Some(x), None) if x.accepted => {
|
||||||
|
let stale_exists = assertions.iter().any(|a| {
|
||||||
|
&a.relationship_id == relationship_id
|
||||||
|
&& verify_assertion(a)
|
||||||
|
&& current_key(&a.signer, transitions) != a_now
|
||||||
|
&& current_key(&a.signer, transitions) != b_now
|
||||||
|
});
|
||||||
|
if stale_exists {
|
||||||
|
FriendStatus::ReacceptanceRequired
|
||||||
|
} else {
|
||||||
|
FriendStatus::PendingOneSided
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(None, Some(y)) if y.accepted => FriendStatus::PendingOneSided,
|
||||||
|
(None, None) => {
|
||||||
|
let stale_exists = assertions
|
||||||
|
.iter()
|
||||||
|
.any(|a| &a.relationship_id == relationship_id && verify_assertion(a));
|
||||||
|
if stale_exists {
|
||||||
|
FriendStatus::ReacceptanceRequired
|
||||||
|
} else {
|
||||||
|
FriendStatus::NotRelated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => FriendStatus::Revoked,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ed25519_dalek::{Signer, SigningKey};
|
||||||
|
|
||||||
|
fn sign(key: &SigningKey, payload: &[u8]) -> Vec<u8> {
|
||||||
|
key.sign(&crate::signable(FRIEND_DOMAIN, payload)).to_bytes().to_vec()
|
||||||
|
}
|
||||||
|
fn request(from: &SigningKey, to: &SigningKey) -> FriendRequest {
|
||||||
|
let mut r = FriendRequest {
|
||||||
|
relationship_id: [3; 16],
|
||||||
|
sender: from.verifying_key().to_bytes(),
|
||||||
|
recipient: to.verifying_key().to_bytes(),
|
||||||
|
nonce: [4; 16],
|
||||||
|
issued_at: 100,
|
||||||
|
expires_at: 900,
|
||||||
|
sig: vec![],
|
||||||
|
};
|
||||||
|
r.sig = sign(from, &request_sign_payload(&r));
|
||||||
|
r
|
||||||
|
}
|
||||||
|
fn assertion(by: &SigningKey, peer: &SigningKey, r: &FriendRequest, accepted: bool, seq: u64) -> FriendAssertion {
|
||||||
|
let mut a = FriendAssertion {
|
||||||
|
relationship_id: r.relationship_id,
|
||||||
|
signer: by.verifying_key().to_bytes(),
|
||||||
|
peer: peer.verifying_key().to_bytes(),
|
||||||
|
request_hash: request_hash(r),
|
||||||
|
accepted,
|
||||||
|
seq,
|
||||||
|
sig: vec![],
|
||||||
|
};
|
||||||
|
a.sig = sign(by, &assertion_sign_payload(&a));
|
||||||
|
a
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_sided_is_never_friends_and_revocation_sticks() {
|
||||||
|
let a = SigningKey::from_bytes(&[1; 32]);
|
||||||
|
let b = SigningKey::from_bytes(&[2; 32]);
|
||||||
|
let mallory = SigningKey::from_bytes(&[3; 32]);
|
||||||
|
let r = request(&a, &b);
|
||||||
|
assert!(verify_request(&r));
|
||||||
|
let (ak, bk) = (a.verifying_key().to_bytes(), b.verifying_key().to_bytes());
|
||||||
|
let h = request_hash(&r);
|
||||||
|
let st = |list: &[FriendAssertion]| friendship_status(&ak, &bk, &r.relationship_id, &h, list, &[]);
|
||||||
|
|
||||||
|
assert_eq!(st(&[]), FriendStatus::NotRelated);
|
||||||
|
let mine = assertion(&a, &b, &r, true, 10);
|
||||||
|
assert_eq!(st(&[mine.clone()]), FriendStatus::PendingOneSided);
|
||||||
|
let theirs = assertion(&b, &a, &r, true, 11);
|
||||||
|
assert_eq!(st(&[mine.clone(), theirs.clone()]), FriendStatus::Friends);
|
||||||
|
// duplicates collapse
|
||||||
|
assert_eq!(st(&[mine.clone(), theirs.clone(), theirs.clone()]), FriendStatus::Friends);
|
||||||
|
// a forged acceptance never counts as friendship. (To the verifier a
|
||||||
|
// validly signed third-key assertion looks like an old key without a
|
||||||
|
// transition, so it answers ReacceptanceRequired — still not friends;
|
||||||
|
// the app never stores a foreign assertion in the first place.)
|
||||||
|
let forged = assertion(&mallory, &a, &r, true, 12);
|
||||||
|
assert_ne!(st(&[mine.clone(), forged]), FriendStatus::Friends);
|
||||||
|
// a newer revocation stands, and an old acceptance cannot resurrect it
|
||||||
|
let revoke = assertion(&b, &a, &r, false, 20);
|
||||||
|
assert_eq!(st(&[mine.clone(), theirs.clone(), revoke.clone()]), FriendStatus::Revoked);
|
||||||
|
assert_eq!(st(&[revoke, theirs, mine]), FriendStatus::Revoked);
|
||||||
|
// tampering after signing
|
||||||
|
let mut t = request(&a, &b);
|
||||||
|
t.recipient = mallory.verifying_key().to_bytes();
|
||||||
|
assert!(!verify_request(&t));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
pub mod slsk_link;
|
pub mod slsk_link;
|
||||||
|
pub mod friend;
|
||||||
|
|
||||||
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";
|
||||||
|
|||||||
@@ -928,7 +928,9 @@ pub async fn connect_and_login(
|
|||||||
} => {
|
} => {
|
||||||
// Identity-link handshakes ride private messages and
|
// Identity-link handshakes ride private messages and
|
||||||
// never reach the Inbox (slsk_link.rs).
|
// never reach the Inbox (slsk_link.rs).
|
||||||
if crate::slsk_link::intercept_pm(&event_app, &username, &message).await {
|
if crate::slsk_link::intercept_pm(&event_app, &username, &message).await
|
||||||
|
|| crate::friends::intercept_pm(&event_app, &username, &message)
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let _ = event_app.emit(
|
let _ = event_app.emit(
|
||||||
|
|||||||
@@ -0,0 +1,546 @@
|
|||||||
|
//! Friends, product side (F4 §7.5 → the app). A friendship is two
|
||||||
|
//! reciprocal signed assertions on one relationship id and request hash;
|
||||||
|
//! everything else — one assertion, a declined request, a revocation — is
|
||||||
|
//! shown as exactly what it is. Proofs stay on this device; nothing is
|
||||||
|
//! published.
|
||||||
|
//!
|
||||||
|
//! Carry today: the same Soulseek private-message channel the link proof
|
||||||
|
//! uses (the encrypted inbox of F5 takes over later). The server can see
|
||||||
|
//! that one account wrote to another; it cannot forge who wrote, which is
|
||||||
|
//! the one thing these messages need from it.
|
||||||
|
//!
|
||||||
|
//! Wire: `SSFRIEND1?<b64 request>` asks, `SSFRIEND1!<b64 assertion>`
|
||||||
|
//! accepts or revokes, `SSFRIEND1-<b58 relationship id>` declines. An
|
||||||
|
//! incoming REQUEST is bound to its sender by the link handshake: the
|
||||||
|
//! request names a persona key, and the sender is asked (over PM, with a
|
||||||
|
//! fresh nonce) to prove that key is theirs before the request is shown.
|
||||||
|
//! That ask waits on a PM, so the pump hands friend messages to a task —
|
||||||
|
//! awaiting inline would block the very loop that delivers the answer.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use persona_state::friend as fr;
|
||||||
|
use persona_state::slsk_link::normalize_username;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::{Emitter, Manager};
|
||||||
|
|
||||||
|
use crate::persona;
|
||||||
|
use crate::slsk_link;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
const REQ_PREFIX: &str = "SSFRIEND1?";
|
||||||
|
const ASSERT_PREFIX: &str = "SSFRIEND1!";
|
||||||
|
const DECLINE_PREFIX: &str = "SSFRIEND1-";
|
||||||
|
/// A request stays open this long.
|
||||||
|
const REQUEST_TTL_SECS: u64 = 7 * 24 * 3600;
|
||||||
|
pub const EVENT: &str = "community:friends";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct Relationship {
|
||||||
|
/// Their Soulseek account as the server spelled it when it last spoke.
|
||||||
|
username: String,
|
||||||
|
username_normalized: String,
|
||||||
|
their_key: String,
|
||||||
|
my_key: String,
|
||||||
|
request: fr::FriendRequest,
|
||||||
|
request_from_me: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
assertions: Vec<fr::FriendAssertion>,
|
||||||
|
/// Local-only stances the verifier has no signature for.
|
||||||
|
#[serde(default)]
|
||||||
|
declined_by_me: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
declined_by_them: bool,
|
||||||
|
created_at: u64,
|
||||||
|
updated_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Serialize, Deserialize)]
|
||||||
|
struct Store {
|
||||||
|
#[serde(default)]
|
||||||
|
relationships: BTreeMap<String, Relationship>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the UI sees: one row per relationship, status from the verifier.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RelationshipView {
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub username_normalized: String,
|
||||||
|
pub persona_key: String,
|
||||||
|
pub direction: &'static str,
|
||||||
|
/// friends | pending-out | pending-in | accepted-waiting | declined |
|
||||||
|
/// revoked | expired | reacceptance-required
|
||||||
|
pub state: String,
|
||||||
|
pub since: u64,
|
||||||
|
pub updated_at: u64,
|
||||||
|
pub expires_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_secs() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
fn nonce16() -> [u8; 16] {
|
||||||
|
use rand::RngCore;
|
||||||
|
let mut n = [0u8; 16];
|
||||||
|
rand::thread_rng().fill_bytes(&mut n);
|
||||||
|
n
|
||||||
|
}
|
||||||
|
fn b58(bytes: &[u8]) -> String {
|
||||||
|
bs58::encode(bytes).into_string()
|
||||||
|
}
|
||||||
|
fn key_of(b58s: &str) -> Result<[u8; 32], String> {
|
||||||
|
bs58::decode(b58s)
|
||||||
|
.into_vec()
|
||||||
|
.map_err(|e| format!("bad key: {e}"))?
|
||||||
|
.as_slice()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| "key is not 32 bytes".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||||
|
Ok(app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.join("freenet")
|
||||||
|
.join("friends.json"))
|
||||||
|
}
|
||||||
|
fn load(app: &tauri::AppHandle) -> Store {
|
||||||
|
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, store: &Store) -> 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_pretty(store).map_err(|e| e.to_string())?)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let _ = app.emit(EVENT, ());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_of(id: &str, r: &Relationship, now: u64) -> RelationshipView {
|
||||||
|
let (my, their) = (key_of(&r.my_key), key_of(&r.their_key));
|
||||||
|
let status = match (my, their) {
|
||||||
|
(Ok(m), Ok(t)) => fr::friendship_status(
|
||||||
|
&m,
|
||||||
|
&t,
|
||||||
|
&r.request.relationship_id,
|
||||||
|
&fr::request_hash(&r.request),
|
||||||
|
&r.assertions,
|
||||||
|
&[],
|
||||||
|
),
|
||||||
|
_ => fr::FriendStatus::NotRelated,
|
||||||
|
};
|
||||||
|
let mine_accepted = r
|
||||||
|
.assertions
|
||||||
|
.iter()
|
||||||
|
.any(|a| b58(&a.signer) == r.my_key && a.accepted);
|
||||||
|
let state = match status {
|
||||||
|
fr::FriendStatus::Friends => "friends",
|
||||||
|
fr::FriendStatus::Revoked => "revoked",
|
||||||
|
fr::FriendStatus::ReacceptanceRequired => "reacceptance-required",
|
||||||
|
fr::FriendStatus::PendingOneSided | fr::FriendStatus::NotRelated => {
|
||||||
|
if r.declined_by_me || r.declined_by_them {
|
||||||
|
"declined"
|
||||||
|
} else if now > r.request.expires_at && status == fr::FriendStatus::NotRelated {
|
||||||
|
"expired"
|
||||||
|
} else if mine_accepted {
|
||||||
|
"accepted-waiting"
|
||||||
|
} else if r.request_from_me {
|
||||||
|
"pending-out"
|
||||||
|
} else {
|
||||||
|
"pending-in"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let since = r
|
||||||
|
.assertions
|
||||||
|
.iter()
|
||||||
|
.filter(|a| a.accepted)
|
||||||
|
.map(|a| a.seq)
|
||||||
|
.max()
|
||||||
|
.unwrap_or(r.created_at);
|
||||||
|
RelationshipView {
|
||||||
|
id: id.to_string(),
|
||||||
|
username: r.username.clone(),
|
||||||
|
username_normalized: r.username_normalized.clone(),
|
||||||
|
persona_key: r.their_key.clone(),
|
||||||
|
direction: if r.request_from_me { "out" } else { "in" },
|
||||||
|
state: state.to_string(),
|
||||||
|
since,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
expires_at: r.request.expires_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn my_key(app: &tauri::AppHandle) -> Result<[u8; 32], String> {
|
||||||
|
let adapter = app.state::<AppState>().freenet.clone();
|
||||||
|
let info = persona::current(&adapter)
|
||||||
|
.await?
|
||||||
|
.ok_or("create a SoulStream identity first")?;
|
||||||
|
key_of(&info.public_key)
|
||||||
|
}
|
||||||
|
async fn send_pm(app: &tauri::AppHandle, to: &str, text: &str) -> Result<(), String> {
|
||||||
|
let st = app.state::<AppState>();
|
||||||
|
let guard = st.client.lock().await;
|
||||||
|
guard
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("not connected to Soulseek")?
|
||||||
|
.message_user(to, text)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
fn encode<T: Serialize>(prefix: &str, v: &T) -> Result<String, String> {
|
||||||
|
use base64::Engine;
|
||||||
|
let bytes = bincode::serialize(v).map_err(|e| e.to_string())?;
|
||||||
|
Ok(format!("{prefix}{}", base64::engine::general_purpose::STANDARD.encode(bytes)))
|
||||||
|
}
|
||||||
|
fn decode<T: for<'de> Deserialize<'de>>(b64: &str) -> Option<T> {
|
||||||
|
use base64::Engine;
|
||||||
|
let bytes = base64::engine::general_purpose::STANDARD.decode(b64.trim()).ok()?;
|
||||||
|
bincode::deserialize(&bytes).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sign_assertion(app: &tauri::AppHandle, mut a: fr::FriendAssertion) -> Result<fr::FriendAssertion, String> {
|
||||||
|
let adapter = app.state::<AppState>().freenet.clone();
|
||||||
|
a.sig = persona::sign(&adapter, fr::FRIEND_DOMAIN, fr::assertion_sign_payload(&a)).await?;
|
||||||
|
Ok(a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- outgoing
|
||||||
|
|
||||||
|
/// Ask `username` to be friends. Needs their verified link (the drawer's
|
||||||
|
/// "Ask for their SoulStream ID") — the request is addressed to a KEY.
|
||||||
|
pub async fn request(app: &tauri::AppHandle, username: &str) -> Result<RelationshipView, String> {
|
||||||
|
let me = my_key(app).await?;
|
||||||
|
let link = slsk_link::known_link(app, username)
|
||||||
|
.ok_or("verify their SoulStream ID first (the drawer's Ask button)")?;
|
||||||
|
let their = key_of(&link.persona_key)?;
|
||||||
|
let now = now_secs();
|
||||||
|
let mut req = fr::FriendRequest {
|
||||||
|
relationship_id: nonce16(),
|
||||||
|
sender: me,
|
||||||
|
recipient: their,
|
||||||
|
nonce: nonce16(),
|
||||||
|
issued_at: now,
|
||||||
|
expires_at: now + REQUEST_TTL_SECS,
|
||||||
|
sig: vec![],
|
||||||
|
};
|
||||||
|
let adapter = app.state::<AppState>().freenet.clone();
|
||||||
|
req.sig = persona::sign(&adapter, fr::FRIEND_DOMAIN, fr::request_sign_payload(&req)).await?;
|
||||||
|
let id = b58(&req.relationship_id);
|
||||||
|
let rel = Relationship {
|
||||||
|
username: link.username.clone(),
|
||||||
|
username_normalized: link.username_normalized.clone(),
|
||||||
|
their_key: link.persona_key.clone(),
|
||||||
|
my_key: b58(&me),
|
||||||
|
request: req.clone(),
|
||||||
|
request_from_me: true,
|
||||||
|
assertions: vec![],
|
||||||
|
declined_by_me: false,
|
||||||
|
declined_by_them: false,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
send_pm(app, &link.username, &encode(REQ_PREFIX, &req)?).await?;
|
||||||
|
let mut store = load(app);
|
||||||
|
store.relationships.insert(id.clone(), rel);
|
||||||
|
save(app, &store)?;
|
||||||
|
log::info!("friends: request sent to {}", link.username);
|
||||||
|
Ok(view_of(&id, &store.relationships[&id], now))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accept an incoming request: sign our assertion, send it, remember it.
|
||||||
|
/// Friendship is still one-sided until their reciprocal assertion arrives.
|
||||||
|
pub async fn accept(app: &tauri::AppHandle, id: &str) -> Result<RelationshipView, String> {
|
||||||
|
let me = my_key(app).await?;
|
||||||
|
let mut store = load(app);
|
||||||
|
let rel = store.relationships.get_mut(id).ok_or("no such request")?;
|
||||||
|
if rel.request_from_me {
|
||||||
|
return Err("that request is yours to wait on, not to accept".into());
|
||||||
|
}
|
||||||
|
let their = key_of(&rel.their_key)?;
|
||||||
|
let now = now_secs();
|
||||||
|
let a = sign_assertion(
|
||||||
|
app,
|
||||||
|
fr::FriendAssertion {
|
||||||
|
relationship_id: rel.request.relationship_id,
|
||||||
|
signer: me,
|
||||||
|
peer: their,
|
||||||
|
request_hash: fr::request_hash(&rel.request),
|
||||||
|
accepted: true,
|
||||||
|
seq: now,
|
||||||
|
sig: vec![],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
send_pm(app, &rel.username, &encode(ASSERT_PREFIX, &a)?).await?;
|
||||||
|
rel.assertions.push(a);
|
||||||
|
rel.declined_by_me = false;
|
||||||
|
rel.updated_at = now;
|
||||||
|
let v = view_of(id, rel, now);
|
||||||
|
save(app, &store)?;
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn decline(app: &tauri::AppHandle, id: &str) -> Result<RelationshipView, String> {
|
||||||
|
let mut store = load(app);
|
||||||
|
let rel = store.relationships.get_mut(id).ok_or("no such request")?;
|
||||||
|
let now = now_secs();
|
||||||
|
rel.declined_by_me = true;
|
||||||
|
rel.updated_at = now;
|
||||||
|
let _ = send_pm(app, &rel.username, &format!("{DECLINE_PREFIX}{id}")).await;
|
||||||
|
let v = view_of(id, rel, now);
|
||||||
|
save(app, &store)?;
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unfriend: a signed revocation goes to them; the record stays as
|
||||||
|
/// "revoked" — the design's honesty about what a removal can and cannot do.
|
||||||
|
pub async fn remove(app: &tauri::AppHandle, id: &str) -> Result<RelationshipView, String> {
|
||||||
|
let me = my_key(app).await?;
|
||||||
|
let mut store = load(app);
|
||||||
|
let rel = store.relationships.get_mut(id).ok_or("no such relationship")?;
|
||||||
|
let their = key_of(&rel.their_key)?;
|
||||||
|
let now = now_secs();
|
||||||
|
let a = sign_assertion(
|
||||||
|
app,
|
||||||
|
fr::FriendAssertion {
|
||||||
|
relationship_id: rel.request.relationship_id,
|
||||||
|
signer: me,
|
||||||
|
peer: their,
|
||||||
|
request_hash: fr::request_hash(&rel.request),
|
||||||
|
accepted: false,
|
||||||
|
seq: now,
|
||||||
|
sig: vec![],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let _ = send_pm(app, &rel.username, &encode(ASSERT_PREFIX, &a)?).await;
|
||||||
|
rel.assertions.push(a);
|
||||||
|
rel.updated_at = now;
|
||||||
|
let v = view_of(id, rel, now);
|
||||||
|
save(app, &store)?;
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn forget(app: &tauri::AppHandle, id: &str) -> Result<(), String> {
|
||||||
|
let mut store = load(app);
|
||||||
|
store.relationships.remove(id);
|
||||||
|
save(app, &store)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list(app: &tauri::AppHandle) -> Vec<RelationshipView> {
|
||||||
|
let now = now_secs();
|
||||||
|
let store = load(app);
|
||||||
|
let mut v: Vec<_> = store
|
||||||
|
.relationships
|
||||||
|
.iter()
|
||||||
|
.map(|(id, r)| view_of(id, r, now))
|
||||||
|
.collect();
|
||||||
|
v.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- incoming
|
||||||
|
|
||||||
|
async fn on_request(app: tauri::AppHandle, from: String, req: fr::FriendRequest) {
|
||||||
|
if !fr::verify_request(&req) {
|
||||||
|
log::warn!("friends: request from {from} does not verify");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(me) = my_key(&app).await else {
|
||||||
|
log::debug!("friends: request from {from}, but no persona here to receive it");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if req.recipient != me {
|
||||||
|
log::debug!("friends: request from {from} is addressed to another persona");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let now = now_secs();
|
||||||
|
if now > req.expires_at {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The request names a key; the sender must prove it is theirs.
|
||||||
|
let bound = match slsk_link::known_link(&app, &from) {
|
||||||
|
Some(l) if l.persona_key == b58(&req.sender) => Some(l),
|
||||||
|
_ => match slsk_link::ask(&app, &from).await {
|
||||||
|
Ok(l) if l.persona_key == b58(&req.sender) => {
|
||||||
|
slsk_link::remember(&app, l.clone());
|
||||||
|
Some(l)
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
log::warn!("friends: {from} proved a different key than the request names");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("friends: could not bind {from} to the request's key: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let Some(link) = bound else { return };
|
||||||
|
let id = b58(&req.relationship_id);
|
||||||
|
let mut store = load(&app);
|
||||||
|
if store.relationships.contains_key(&id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
store.relationships.insert(
|
||||||
|
id,
|
||||||
|
Relationship {
|
||||||
|
username: link.username,
|
||||||
|
username_normalized: link.username_normalized,
|
||||||
|
their_key: link.persona_key,
|
||||||
|
my_key: b58(&me),
|
||||||
|
request: req,
|
||||||
|
request_from_me: false,
|
||||||
|
assertions: vec![],
|
||||||
|
declined_by_me: false,
|
||||||
|
declined_by_them: false,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if let Err(e) = save(&app, &store) {
|
||||||
|
log::warn!("friends: could not store the request from {from}: {e}");
|
||||||
|
} else {
|
||||||
|
log::info!("friends: request from {from} verified and waiting");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_assertion(app: tauri::AppHandle, from: String, a: fr::FriendAssertion) {
|
||||||
|
if !fr::verify_assertion(&a) {
|
||||||
|
log::warn!("friends: assertion from {from} does not verify");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let id = b58(&a.relationship_id);
|
||||||
|
let mut store = load(&app);
|
||||||
|
let Some(rel) = store.relationships.get_mut(&id) else {
|
||||||
|
log::debug!("friends: assertion from {from} for an unknown relationship");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if normalize_username(&from) != rel.username_normalized
|
||||||
|
|| b58(&a.signer) != rel.their_key
|
||||||
|
|| b58(&a.peer) != rel.my_key
|
||||||
|
|| a.request_hash != fr::request_hash(&rel.request)
|
||||||
|
{
|
||||||
|
log::warn!("friends: assertion from {from} does not belong to its relationship");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if rel
|
||||||
|
.assertions
|
||||||
|
.iter()
|
||||||
|
.any(|x| x.signer == a.signer && x.seq == a.seq && x.accepted == a.accepted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let now = now_secs();
|
||||||
|
rel.assertions.push(a.clone());
|
||||||
|
rel.updated_at = now;
|
||||||
|
// Their acceptance of OUR request completes with our own assertion.
|
||||||
|
let mine_exists = rel.assertions.iter().any(|x| b58(&x.signer) == rel.my_key);
|
||||||
|
if a.accepted && rel.request_from_me && !mine_exists {
|
||||||
|
if let (Ok(me), Ok(their)) = (key_of(&rel.my_key), key_of(&rel.their_key)) {
|
||||||
|
match sign_assertion(
|
||||||
|
&app,
|
||||||
|
fr::FriendAssertion {
|
||||||
|
relationship_id: rel.request.relationship_id,
|
||||||
|
signer: me,
|
||||||
|
peer: their,
|
||||||
|
request_hash: fr::request_hash(&rel.request),
|
||||||
|
accepted: true,
|
||||||
|
seq: now,
|
||||||
|
sig: vec![],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(mine) => {
|
||||||
|
if let Ok(text) = encode(ASSERT_PREFIX, &mine) {
|
||||||
|
let _ = send_pm(&app, &rel.username, &text).await;
|
||||||
|
}
|
||||||
|
rel.assertions.push(mine);
|
||||||
|
}
|
||||||
|
Err(e) => log::warn!("friends: could not sign our assertion: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let state = view_of(&id, rel, now).state;
|
||||||
|
if let Err(e) = save(&app, &store) {
|
||||||
|
log::warn!("friends: could not store the assertion from {from}: {e}");
|
||||||
|
} else {
|
||||||
|
log::info!("friends: {from} → {state}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_decline(app: &tauri::AppHandle, from: &str, id: &str) {
|
||||||
|
let mut store = load(app);
|
||||||
|
let Some(rel) = store.relationships.get_mut(id) else { return };
|
||||||
|
if normalize_username(from) != rel.username_normalized || !rel.request_from_me {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rel.declined_by_them = true;
|
||||||
|
rel.updated_at = now_secs();
|
||||||
|
let _ = save(app, &store);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The event pump hands every private message here after the link proof.
|
||||||
|
/// True means it was ours and must not surface in the Inbox. Requests and
|
||||||
|
/// assertions are handled on their own task (see the module note).
|
||||||
|
pub fn intercept_pm(app: &tauri::AppHandle, from: &str, message: &str) -> bool {
|
||||||
|
let msg = message.trim();
|
||||||
|
if let Some(rest) = msg.strip_prefix(REQ_PREFIX) {
|
||||||
|
if let Some(req) = decode::<fr::FriendRequest>(rest) {
|
||||||
|
tauri::async_runtime::spawn(on_request(app.clone(), from.to_string(), req));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if let Some(rest) = msg.strip_prefix(ASSERT_PREFIX) {
|
||||||
|
if let Some(a) = decode::<fr::FriendAssertion>(rest) {
|
||||||
|
tauri::async_runtime::spawn(on_assertion(app.clone(), from.to_string(), a));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if let Some(rest) = msg.strip_prefix(DECLINE_PREFIX) {
|
||||||
|
on_decline(app, from, rest.trim());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- commands
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn friends_list(app: tauri::AppHandle) -> Vec<RelationshipView> {
|
||||||
|
list(&app)
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn friend_request(app: tauri::AppHandle, username: String) -> Result<RelationshipView, String> {
|
||||||
|
request(&app, &username).await
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn friend_accept(app: tauri::AppHandle, id: String) -> Result<RelationshipView, String> {
|
||||||
|
accept(&app, &id).await
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn friend_decline(app: tauri::AppHandle, id: String) -> Result<RelationshipView, String> {
|
||||||
|
decline(&app, &id).await
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn friend_remove(app: tauri::AppHandle, id: String) -> Result<RelationshipView, String> {
|
||||||
|
remove(&app, &id).await
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn friend_forget(app: tauri::AppHandle, id: String) -> Result<(), String> {
|
||||||
|
forget(&app, &id)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ mod config;
|
|||||||
mod cover_logo;
|
mod cover_logo;
|
||||||
mod diagnostics;
|
mod diagnostics;
|
||||||
mod freenet;
|
mod freenet;
|
||||||
|
mod friends;
|
||||||
mod persona;
|
mod persona;
|
||||||
mod hero_crop;
|
mod hero_crop;
|
||||||
mod import;
|
mod import;
|
||||||
@@ -129,6 +130,12 @@ pub fn run() {
|
|||||||
slsk_link::slsk_link_unlink,
|
slsk_link::slsk_link_unlink,
|
||||||
slsk_link::slsk_link_check,
|
slsk_link::slsk_link_check,
|
||||||
slsk_link::slsk_link_forget,
|
slsk_link::slsk_link_forget,
|
||||||
|
friends::friends_list,
|
||||||
|
friends::friend_request,
|
||||||
|
friends::friend_accept,
|
||||||
|
friends::friend_decline,
|
||||||
|
friends::friend_remove,
|
||||||
|
friends::friend_forget,
|
||||||
commands::buddy_list,
|
commands::buddy_list,
|
||||||
commands::buddy_add,
|
commands::buddy_add,
|
||||||
commands::buddy_remove,
|
commands::buddy_remove,
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ async fn send_pm(app: &tauri::AppHandle, to: &str, text: &str) -> Result<(), Str
|
|||||||
|
|
||||||
/// Ask `username` over Soulseek for their signed SoulStream ID and verify
|
/// Ask `username` over Soulseek for their signed SoulStream ID and verify
|
||||||
/// the answer. One PM out, one in, thirty seconds at most.
|
/// the answer. One PM out, one in, thirty seconds at most.
|
||||||
async fn ask(app: &tauri::AppHandle, username: &str) -> Result<KnownLink, String> {
|
pub(crate) async fn ask(app: &tauri::AppHandle, username: &str) -> Result<KnownLink, String> {
|
||||||
let target = sl::normalize_username(username);
|
let target = sl::normalize_username(username);
|
||||||
if target.is_empty() {
|
if target.is_empty() {
|
||||||
return Err("no username".into());
|
return Err("no username".into());
|
||||||
@@ -278,6 +278,21 @@ pub async fn intercept_pm(app: &tauri::AppHandle, from: &str, message: &str) ->
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A verified link for `username`, if this device holds one.
|
||||||
|
pub(crate) fn known_link(app: &tauri::AppHandle, username: &str) -> Option<KnownLink> {
|
||||||
|
load(app)
|
||||||
|
.others
|
||||||
|
.get(&sl::normalize_username(username))
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
/// Keep a link another service verified (the friends handshake binds a
|
||||||
|
/// request's key to its sender the same way the drawer's Ask does).
|
||||||
|
pub(crate) fn remember(app: &tauri::AppHandle, link: KnownLink) {
|
||||||
|
let mut links = load(app);
|
||||||
|
links.others.insert(link.username_normalized.clone(), link);
|
||||||
|
let _ = save(app, &links);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- commands
|
// ---------------------------------------------------------------- commands
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -156,6 +156,12 @@ export const slskLinkSelf = () => invoke("slsk_link_self");
|
|||||||
export const slskLinkUnlink = () => invoke("slsk_link_unlink");
|
export const slskLinkUnlink = () => invoke("slsk_link_unlink");
|
||||||
export const slskLinkCheck = (username) => invoke("slsk_link_check", { username });
|
export const slskLinkCheck = (username) => invoke("slsk_link_check", { username });
|
||||||
export const slskLinkForget = (username) => invoke("slsk_link_forget", { username });
|
export const slskLinkForget = (username) => invoke("slsk_link_forget", { username });
|
||||||
|
export const friendsList = () => invoke("friends_list");
|
||||||
|
export const friendRequest = (username) => invoke("friend_request", { username });
|
||||||
|
export const friendAccept = (id) => invoke("friend_accept", { id });
|
||||||
|
export const friendDecline = (id) => invoke("friend_decline", { id });
|
||||||
|
export const friendRemove = (id) => invoke("friend_remove", { id });
|
||||||
|
export const friendForget = (id) => invoke("friend_forget", { id });
|
||||||
export const buddyList = () => invoke("buddy_list");
|
export const buddyList = () => invoke("buddy_list");
|
||||||
export const buddyAdd = (username) => invoke("buddy_add", { username });
|
export const buddyAdd = (username) => invoke("buddy_add", { username });
|
||||||
export const buddyRemove = (username) => invoke("buddy_remove", { username });
|
export const buddyRemove = (username) => invoke("buddy_remove", { username });
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export const community = $state({
|
|||||||
links: null,
|
links: null,
|
||||||
/** username → the last failed ask's reason. */
|
/** username → the last failed ask's reason. */
|
||||||
linkErrors: {},
|
linkErrors: {},
|
||||||
|
/** friends_list: every relationship this device holds, newest first. */
|
||||||
|
friends: [],
|
||||||
/** 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: {},
|
||||||
@@ -306,11 +308,12 @@ export async function initCommunity() {
|
|||||||
if (adapterConnected(status)) void refreshPersona();
|
if (adapterConnected(status)) void refreshPersona();
|
||||||
else if (community.persona) community.persona.available = false;
|
else if (community.persona) community.persona.available = false;
|
||||||
});
|
});
|
||||||
|
api.onEvent("community:friends", () => void refreshFriends());
|
||||||
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await Promise.all([refreshFreenet(), loadContacts(), refreshLinks()]);
|
await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends()]);
|
||||||
await refreshPersona();
|
await refreshPersona();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,6 +394,32 @@ export async function loadRemoteProfile(key, force = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---- friends ------------------------------------------------------------------------
|
||||||
|
export async function refreshFriends() {
|
||||||
|
try {
|
||||||
|
community.friends = await api.friendsList();
|
||||||
|
} catch {
|
||||||
|
// stays as it was
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const friendAction = (key, fn) => run(key, async () => { await fn(); await refreshFriends(); });
|
||||||
|
export const friendRequest = (username) => friendAction("friend:" + username, () => api.friendRequest(username));
|
||||||
|
export const friendAccept = (id) => friendAction("friend:" + id, () => api.friendAccept(id));
|
||||||
|
export const friendDecline = (id) => friendAction("friend:" + id, () => api.friendDecline(id));
|
||||||
|
export const friendRemove = (id) => friendAction("friend:" + id, () => api.friendRemove(id));
|
||||||
|
export const friendForget = (id) => friendAction("friend:" + id, () => api.friendForget(id));
|
||||||
|
/** The live relationship with a username, if any: friends first, then the
|
||||||
|
* most recently touched one. */
|
||||||
|
export const friendshipFor = (username) => {
|
||||||
|
const n = (username ?? "").trim().toLowerCase();
|
||||||
|
const mine = community.friends.filter((f) => f.username_normalized === n);
|
||||||
|
return mine.find((f) => f.state === "friends") ?? mine[0] ?? null;
|
||||||
|
};
|
||||||
|
export const friendCount = () => community.friends.filter((f) => f.state === "friends").length;
|
||||||
|
export const pendingIn = () => community.friends.filter((f) => f.state === "pending-in");
|
||||||
|
|
||||||
|
|
||||||
// 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, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends };
|
||||||
@@ -64,6 +64,14 @@
|
|||||||
checkLink,
|
checkLink,
|
||||||
linkFor,
|
linkFor,
|
||||||
loadRemoteProfile,
|
loadRemoteProfile,
|
||||||
|
friendRequest,
|
||||||
|
friendAccept,
|
||||||
|
friendDecline,
|
||||||
|
friendRemove,
|
||||||
|
friendForget,
|
||||||
|
friendshipFor,
|
||||||
|
friendCount,
|
||||||
|
pendingIn,
|
||||||
} from "../lib/community.svelte.js";
|
} from "../lib/community.svelte.js";
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -296,6 +304,8 @@
|
|||||||
? true
|
? true
|
||||||
: peopleGroup === "trusted"
|
: peopleGroup === "trusted"
|
||||||
? p.meta.trusted
|
? p.meta.trusted
|
||||||
|
: peopleGroup === "friends"
|
||||||
|
? friendshipFor(p.u)?.state === "friends"
|
||||||
: peopleGroup === "favorite"
|
: peopleGroup === "favorite"
|
||||||
? p.meta.favorite
|
? p.meta.favorite
|
||||||
: p.meta.group === peopleGroup,
|
: p.meta.group === peopleGroup,
|
||||||
@@ -412,6 +422,8 @@
|
|||||||
);
|
);
|
||||||
const drawerLink = $derived(drawerUser ? linkFor(drawerUser) : null);
|
const drawerLink = $derived(drawerUser ? linkFor(drawerUser) : null);
|
||||||
const drawerRemote = $derived(drawerLink ? community.remoteProfiles[drawerLink.persona_key] : null);
|
const drawerRemote = $derived(drawerLink ? community.remoteProfiles[drawerLink.persona_key] : null);
|
||||||
|
const drawerFriend = $derived(drawerUser ? friendshipFor(drawerUser) : null);
|
||||||
|
const isMe = (u) => !!me && (u ?? "").trim().toLowerCase() === me.trim().toLowerCase();
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const lk = drawerLink;
|
const lk = drawerLink;
|
||||||
if (lk) untrack(() => loadRemoteProfile(lk.persona_key));
|
if (lk) untrack(() => loadRemoteProfile(lk.persona_key));
|
||||||
@@ -493,7 +505,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<span class="chip off">SoulStream identity off</span>
|
<span class="chip off">SoulStream identity off</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="hint">{slsk.buddies.length} contacts · {joinedNames.length} rooms · {convos.length} conversations</span>
|
<span class="hint">{friendCount()} friends · {slsk.buddies.length} contacts · {joinedNames.length} rooms · {convos.length} conversations</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="ax-ghost" onclick={() => setSection("you")}>
|
<button class="ax-ghost" onclick={() => setSection("you")}>
|
||||||
<Icon name={key ? "pencil" : "sparkle"} size={13} />
|
<Icon name={key ? "pencil" : "sparkle"} size={13} />
|
||||||
@@ -536,6 +548,27 @@
|
|||||||
|
|
||||||
<!-- ── INBOX ──────────────────────────────────────────────────────── -->
|
<!-- ── INBOX ──────────────────────────────────────────────────────── -->
|
||||||
{:else if community.section === "inbox"}
|
{:else if community.section === "inbox"}
|
||||||
|
{#if pendingIn().length}
|
||||||
|
<div class="freqs">
|
||||||
|
{#each pendingIn() as f (f.id)}
|
||||||
|
<div class="card inv">
|
||||||
|
<div class="top">
|
||||||
|
<button class="avbtn" onclick={() => (community.drawer = f.username)}><Identicon seed={f.persona_key} size={44} presence={presenceOf(f.username)} /></button>
|
||||||
|
<div>
|
||||||
|
<div class="t">{display(f.username)} wants to be friends</div>
|
||||||
|
<div class="s">{f.username} · SoulStream ID {fpShort(f.persona_key)} · asked {fmtWhen(f.updated_at * 1000)}</div>
|
||||||
|
<div class="prov"><span class="src ss"><Icon name="disc" size={10} />SoulStream</span><span>signed request · their ID proven over Soulseek</span><span>·</span><span>open until {fmtWhen(f.expires_at * 1000)}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bot">
|
||||||
|
<button class="ax-fill sm" onclick={() => friendAccept(f.id)} disabled={!online || !nodeUp || !!community.busy["friend:" + f.id]}>Accept</button>
|
||||||
|
<button class="ax-ghost sm" onclick={() => friendDecline(f.id)} disabled={!!community.busy["friend:" + f.id]}>Decline</button>
|
||||||
|
<span class="hint">friends are two signed answers — yours goes back to them over Soulseek</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if !convos.length}
|
{#if !convos.length}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<Icon name="inbox" size={30} />
|
<Icon name="inbox" size={30} />
|
||||||
@@ -762,6 +795,7 @@
|
|||||||
{#each groups as g (g)}
|
{#each groups as g (g)}
|
||||||
<button class="fchip" class:on={peopleGroup === g} onclick={() => (peopleGroup = g)}>{g}</button>
|
<button class="fchip" class:on={peopleGroup === g} onclick={() => (peopleGroup = g)}>{g}</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
<button class="fchip" class:on={peopleGroup === "friends"} onclick={() => (peopleGroup = "friends")}>Friends</button>
|
||||||
<button class="fchip" class:on={peopleGroup === "favorite"} onclick={() => (peopleGroup = "favorite")}>Favorites</button>
|
<button class="fchip" class:on={peopleGroup === "favorite"} onclick={() => (peopleGroup = "favorite")}>Favorites</button>
|
||||||
<button class="fchip" class:on={peopleGroup === "trusted"} onclick={() => (peopleGroup = "trusted")}>Trusted</button>
|
<button class="fchip" class:on={peopleGroup === "trusted"} onclick={() => (peopleGroup = "trusted")}>Trusted</button>
|
||||||
<span class="sp"></span>
|
<span class="sp"></span>
|
||||||
@@ -787,6 +821,7 @@
|
|||||||
{#if p.meta.petname}<span class="real">{p.u}</span>{/if}
|
{#if p.meta.petname}<span class="real">{p.u}</span>{/if}
|
||||||
{#if p.meta.favorite}<span class="mark fav" title="Favorite"><Icon name="star" size={11} /></span>{/if}
|
{#if p.meta.favorite}<span class="mark fav" title="Favorite"><Icon name="star" size={11} /></span>{/if}
|
||||||
{#if p.meta.trusted}<span class="mark tr" title="Trusted — a local permission choice, not a verified identity"><Icon name="shield" size={11} /></span>{/if}
|
{#if p.meta.trusted}<span class="mark tr" title="Trusted — a local permission choice, not a verified identity"><Icon name="shield" size={11} /></span>{/if}
|
||||||
|
{#if friendshipFor(p.u)?.state === "friends"}<span class="mark fr" title="Friend — a signed, reciprocal SoulStream relationship"><Icon name="link" size={11} /></span>{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="sub">
|
<div class="sub">
|
||||||
<span class="src slsk"><Icon name="globe" size={10} />Soulseek</span>
|
<span class="src slsk"><Icon name="globe" size={10} />Soulseek</span>
|
||||||
@@ -1170,6 +1205,7 @@
|
|||||||
<input class="pet" placeholder={drawerUser} bind:value={petDraft} onchange={() => saveMeta()} title="Your private petname for this person" />
|
<input class="pet" placeholder={drawerUser} bind:value={petDraft} onchange={() => saveMeta()} title="Your private petname for this person" />
|
||||||
{#if drawerMeta?.favorite}<span class="mark fav"><Icon name="star" size={12} /></span>{/if}
|
{#if drawerMeta?.favorite}<span class="mark fav"><Icon name="star" size={12} /></span>{/if}
|
||||||
{#if drawerMeta?.trusted}<span class="mark tr"><Icon name="shield" size={12} /></span>{/if}
|
{#if drawerMeta?.trusted}<span class="mark tr"><Icon name="shield" size={12} /></span>{/if}
|
||||||
|
{#if drawerFriend?.state === "friends"}<span class="mark fr" title="Friend"><Icon name="link" size={12} /></span>{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="sub">
|
<div class="sub">
|
||||||
{#if petDraft.trim()}<span>{drawerUser}</span><span>·</span>{/if}
|
{#if petDraft.trim()}<span>{drawerUser}</span><span>·</span>{/if}
|
||||||
@@ -1263,6 +1299,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint" style="margin-top:8px">Their client answered a one-time question over Soulseek with a claim signed by this key — the server vouched for the sender, the signature for the key. <button class="lnk" onclick={() => checkLink(drawerUser)} disabled={!online || !!community.busy["link:" + drawerUser]}>ask again</button></p>
|
<p class="hint" style="margin-top:8px">Their client answered a one-time question over Soulseek with a claim signed by this key — the server vouched for the sender, the signature for the key. <button class="lnk" onclick={() => checkLink(drawerUser)} disabled={!online || !!community.busy["link:" + drawerUser]}>ask again</button></p>
|
||||||
|
{#if !isMe(drawerUser)}
|
||||||
|
<div class="friendrow">
|
||||||
|
{#if !drawerFriend || drawerFriend.state === "expired" || drawerFriend.state === "revoked" && drawerFriend.direction === "in"}
|
||||||
|
<button class="ax-fill sm" onclick={() => friendRequest(drawerUser)} disabled={!online || !nodeUp || !key || !!community.busy["friend:" + drawerUser]}><Icon name="link" size={12} /> {community.busy["friend:" + drawerUser] ? "Sending…" : "Add as friend"}</button>
|
||||||
|
<span class="hint">{drawerFriend?.state === "revoked" ? "the earlier friendship was ended — a new request starts over" : "a signed request goes to them over Soulseek; friends are two reciprocal signed answers"}</span>
|
||||||
|
{:else if drawerFriend.state === "friends"}
|
||||||
|
<span class="chip ok"><Icon name="link" size={10} />friends since {fmtWhen(drawerFriend.since * 1000)}</span>
|
||||||
|
<button class="ax-ghost sm" onclick={() => friendRemove(drawerFriend.id)} disabled={!!community.busy["friend:" + drawerFriend.id]}>Unfriend</button>
|
||||||
|
{:else if drawerFriend.state === "pending-in"}
|
||||||
|
<span class="chip acc">they asked to be friends</span>
|
||||||
|
<button class="ax-fill sm" onclick={() => friendAccept(drawerFriend.id)} disabled={!online || !nodeUp || !!community.busy["friend:" + drawerFriend.id]}>Accept</button>
|
||||||
|
<button class="ax-ghost sm" onclick={() => friendDecline(drawerFriend.id)} disabled={!!community.busy["friend:" + drawerFriend.id]}>Decline</button>
|
||||||
|
{:else if drawerFriend.state === "pending-out"}
|
||||||
|
<span class="chip acc"><span class="bl"></span>request sent · waiting for their answer</span>
|
||||||
|
<button class="lnk" onclick={() => friendForget(drawerFriend.id)}>withdraw</button>
|
||||||
|
{:else if drawerFriend.state === "accepted-waiting"}
|
||||||
|
<span class="chip acc"><span class="bl"></span>you accepted · waiting for their signed answer</span>
|
||||||
|
{:else if drawerFriend.state === "declined"}
|
||||||
|
<span class="chip off">{drawerFriend.direction === "out" ? "they declined" : "you declined"}</span>
|
||||||
|
<button class="lnk" onclick={() => friendForget(drawerFriend.id)}>forget</button>
|
||||||
|
{:else if drawerFriend.state === "revoked"}
|
||||||
|
<span class="chip warn">friendship ended</span>
|
||||||
|
<button class="ax-ghost sm" onclick={() => friendRequest(drawerUser)} disabled={!online || !nodeUp}>Ask again</button>
|
||||||
|
{:else if drawerFriend.state === "reacceptance-required"}
|
||||||
|
<span class="chip warn">their key changed — must re-accept</span>
|
||||||
|
<button class="ax-ghost sm" onclick={() => friendRequest(drawerUser)} disabled={!online || !nodeUp}>Ask again</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<h4>Signed SoulStream profile <span class="chip off">not asked</span></h4>
|
<h4>Signed SoulStream profile <span class="chip off">not asked</span></h4>
|
||||||
<p class="hint">Ask their client, over Soulseek, for a signed SoulStream ID. One message goes out; a client that is not SoulStream sees a line saying it is safe to ignore.</p>
|
<p class="hint">Ask their client, over Soulseek, for a signed SoulStream ID. One message goes out; a client that is not SoulStream sees a line saying it is safe to ignore.</p>
|
||||||
@@ -1443,6 +1508,15 @@
|
|||||||
.sub { color: var(--text-secondary); font-size: 12.5px; max-width: 460px; line-height: 1.6; }
|
.sub { color: var(--text-secondary); font-size: 12.5px; max-width: 460px; line-height: 1.6; }
|
||||||
.spico { width: 56px; height: 56px; border-radius: 16px; display: grid; place-items: center; background: color-mix(in srgb, var(--space) 16%, transparent); color: #d9a6ff; }
|
.spico { width: 56px; height: 56px; border-radius: 16px; display: grid; place-items: center; background: color-mix(in srgb, var(--space) 16%, transparent); color: #d9a6ff; }
|
||||||
|
|
||||||
|
/* friend requests — the invitation card, parsed and signed, never pasted text */
|
||||||
|
.freqs { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
|
||||||
|
.card { border-radius: 12px; background: var(--surf); border: 1px solid var(--hair); overflow: hidden; max-width: 560px; }
|
||||||
|
.card .top { display: flex; gap: 12px; padding: 12px; align-items: center; }
|
||||||
|
.card .t { font-size: 14px; font-weight: 800; }
|
||||||
|
.card .s { font-size: 11.5px; color: var(--text-secondary); margin-top: 2px; }
|
||||||
|
.card .prov { font-size: 10.5px; color: var(--text-muted); margin-top: 6px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.card .bot { display: flex; gap: 8px; padding: 0 12px 12px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.card .bot .hint { margin-left: auto; font-size: 10.5px; }
|
||||||
/* ── thread / room ── */
|
/* ── thread / room ── */
|
||||||
.thread { display: flex; flex-direction: column; height: calc(100vh - 330px); min-height: 480px; }
|
.thread { display: flex; flex-direction: column; height: calc(100vh - 330px); min-height: 480px; }
|
||||||
.thead-bar, .roomhead { display: flex; align-items: center; gap: 12px; padding: 4px 4px 10px; border-bottom: 1px solid var(--hair); }
|
.thead-bar, .roomhead { display: flex; align-items: center; gap: 12px; padding: 4px 4px 10px; border-bottom: 1px solid var(--hair); }
|
||||||
@@ -1628,6 +1702,8 @@
|
|||||||
.rp { display: flex; gap: 12px; align-items: flex-start; }
|
.rp { display: flex; gap: 12px; align-items: flex-start; }
|
||||||
.rptx { min-width: 0; flex: 1; }
|
.rptx { min-width: 0; flex: 1; }
|
||||||
.rp .nm { font-size: 15px; font-weight: 800; display: flex; align-items: center; gap: 8px; }
|
.rp .nm { font-size: 15px; font-weight: 800; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.friendrow { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
|
||||||
|
.mark.fr { color: var(--accent-hover); }
|
||||||
.dsec { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--hair); }
|
.dsec { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--hair); }
|
||||||
.dsec h4 { margin: 0 0 8px; font-size: 10.5px; font-weight: 900; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); display: flex; align-items: center; gap: 8px; }
|
.dsec h4 { margin: 0 0 8px; font-size: 10.5px; font-weight: 900; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); display: flex; align-items: center; gap: 8px; }
|
||||||
.dsec h4 .src, .dsec h4 .chip { margin-left: auto; }
|
.dsec h4 .src, .dsec h4 .chip { margin-left: auto; }
|
||||||
|
|||||||
Reference in new issue
Block a user