forked from Keelrleek/SoulStream
community: spaces, an owner-signed roster with epochs, a bounded window of signed lines, bodies sealed to a rotating key when private
This commit is contained in:
1 parent
4378c4c0cc
commit
8b511b20e7
14 files changed
+2913
-13
No files matched your search
Generated
-1
@@ -771,7 +771,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
"rand_core 0.6.4",
|
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ serde.workspace = true
|
|||||||
bincode = "1"
|
bincode = "1"
|
||||||
ed25519-dalek = { version = "2", default-features = false, features = ["alloc"] }
|
ed25519-dalek = { version = "2", default-features = false, features = ["alloc"] }
|
||||||
blake3 = "1"
|
blake3 = "1"
|
||||||
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
x25519-dalek = { version = "2", default-features = false, features = ["static_secrets", "alloc", "precomputed-tables"] }
|
||||||
chacha20poly1305 = "0.10"
|
# no default features: the OS-RNG feature would drag getrandom into the
|
||||||
|
# wasm32 contract build; this crate draws no entropy of its own anyway
|
||||||
|
chacha20poly1305 = { version = "0.10", default-features = false, features = ["alloc"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||||
@@ -19,6 +19,7 @@ pub mod slsk_link;
|
|||||||
pub mod friend;
|
pub mod friend;
|
||||||
pub mod playlist;
|
pub mod playlist;
|
||||||
pub mod inbox;
|
pub mod inbox;
|
||||||
|
pub mod space;
|
||||||
|
|
||||||
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";
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
//! A SoulStream Space — the persistent community the F6 decision chose to
|
||||||
|
//! build on River's patterns rather than River's format: ONE contract per
|
||||||
|
//! Space addressed by (owner, space id); an owner-signed CHECKPOINT that IS
|
||||||
|
//! the authority (name, mode, the member roster, the pinned playlist) with
|
||||||
|
//! a monotonic epoch, so a removal is a new epoch without that member and
|
||||||
|
//! nothing about membership is ever inferred from who talked recently; a
|
||||||
|
//! bounded window of member-signed MESSAGES admitted only from the current
|
||||||
|
//! roster; and, for a private Space, message bodies sealed to a per-epoch
|
||||||
|
//! space key the owner hands members through their encrypted inboxes —
|
||||||
|
//! rotated on every removal. Deterministic merge, canonical-only
|
||||||
|
//! validation: the F5 bouncer, again. BYTE-COMPATIBILITY IS THE CONTRACT:
|
||||||
|
//! `crates/space-contract` compiles this very module into the wasm.
|
||||||
|
//!
|
||||||
|
//! Signing rides `soulstream.friend/v1` like the inbox does (the delegate
|
||||||
|
//! generation in the app has no space domain yet).
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
pub const SCHEMA_VERSION: u32 = 1;
|
||||||
|
pub const PARAMS_FAMILY: &[u8] = b"soulstream.space.v1";
|
||||||
|
pub const SIGN_DOMAIN: &str = "soulstream.friend/v1";
|
||||||
|
pub const MAX_STATE_BYTES: usize = 1024 * 1024;
|
||||||
|
pub const MAX_MEMBERS: usize = 256;
|
||||||
|
pub const MAX_MESSAGES: usize = 500;
|
||||||
|
pub const DEFAULT_MESSAGE_CAP: u32 = 200;
|
||||||
|
pub const MAX_BODY_BYTES: usize = 4096;
|
||||||
|
pub const MAX_NAME_BYTES: usize = 64;
|
||||||
|
pub const MAX_DESC_BYTES: usize = 512;
|
||||||
|
pub const MAX_PER_AUTHOR_UNCAPPED: usize = 64;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum Role {
|
||||||
|
Owner,
|
||||||
|
Member,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Member {
|
||||||
|
pub key: [u8; 32],
|
||||||
|
pub role: Role,
|
||||||
|
pub since_epoch: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct PlaylistRef {
|
||||||
|
pub owner: [u8; 32],
|
||||||
|
pub uuid: [u8; 16],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The owner's word on what the Space is and who is in it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Checkpoint {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub space_id: [u8; 16],
|
||||||
|
pub owner: [u8; 32],
|
||||||
|
pub epoch: u64,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub private: bool,
|
||||||
|
pub members: Vec<Member>,
|
||||||
|
pub pinned: Option<PlaylistRef>,
|
||||||
|
pub message_cap: u32,
|
||||||
|
pub sig: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CheckpointSigBody<'a> {
|
||||||
|
schema_version: u32,
|
||||||
|
space_id: &'a [u8; 16],
|
||||||
|
owner: &'a [u8; 32],
|
||||||
|
epoch: u64,
|
||||||
|
name: &'a str,
|
||||||
|
description: &'a str,
|
||||||
|
private: bool,
|
||||||
|
members: &'a [Member],
|
||||||
|
pinned: &'a Option<PlaylistRef>,
|
||||||
|
message_cap: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn checkpoint_sig_body(c: &Checkpoint) -> Vec<u8> {
|
||||||
|
bincode::serialize(&CheckpointSigBody {
|
||||||
|
schema_version: c.schema_version,
|
||||||
|
space_id: &c.space_id,
|
||||||
|
owner: &c.owner,
|
||||||
|
epoch: c.epoch,
|
||||||
|
name: &c.name,
|
||||||
|
description: &c.description,
|
||||||
|
private: c.private,
|
||||||
|
members: &c.members,
|
||||||
|
pinned: &c.pinned,
|
||||||
|
message_cap: c.message_cap,
|
||||||
|
})
|
||||||
|
.expect("serializes")
|
||||||
|
}
|
||||||
|
pub fn checkpoint_sign_payload(c: &Checkpoint) -> Vec<u8> {
|
||||||
|
blake3::hash(&checkpoint_sig_body(c)).as_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line in the Space. Public: `body` is UTF-8. Private: `body` is the
|
||||||
|
/// ChaCha20-Poly1305 seal of the UTF-8 under the epoch's space key.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Message {
|
||||||
|
pub space_id: [u8; 16],
|
||||||
|
pub epoch: u64,
|
||||||
|
pub msg_id: [u8; 16],
|
||||||
|
pub author: [u8; 32],
|
||||||
|
pub created_at: u64,
|
||||||
|
pub body: Vec<u8>,
|
||||||
|
pub sig: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct MessageSigBody<'a> {
|
||||||
|
space_id: &'a [u8; 16],
|
||||||
|
epoch: u64,
|
||||||
|
msg_id: &'a [u8; 16],
|
||||||
|
author: &'a [u8; 32],
|
||||||
|
created_at: u64,
|
||||||
|
body: &'a [u8],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn message_sig_body(m: &Message) -> Vec<u8> {
|
||||||
|
bincode::serialize(&MessageSigBody {
|
||||||
|
space_id: &m.space_id,
|
||||||
|
epoch: m.epoch,
|
||||||
|
msg_id: &m.msg_id,
|
||||||
|
author: &m.author,
|
||||||
|
created_at: m.created_at,
|
||||||
|
body: &m.body,
|
||||||
|
})
|
||||||
|
.expect("serializes")
|
||||||
|
}
|
||||||
|
pub fn message_sign_payload(m: &Message) -> Vec<u8> {
|
||||||
|
blake3::hash(&message_sig_body(m)).as_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The AAD for a sealed body: everything about the message but the body.
|
||||||
|
fn message_aad(m: &Message) -> Vec<u8> {
|
||||||
|
let mut v = Vec::with_capacity(80);
|
||||||
|
v.extend_from_slice(&m.space_id);
|
||||||
|
v.extend_from_slice(&m.epoch.to_le_bytes());
|
||||||
|
v.extend_from_slice(&m.msg_id);
|
||||||
|
v.extend_from_slice(&m.author);
|
||||||
|
v.extend_from_slice(&m.created_at.to_le_bytes());
|
||||||
|
v
|
||||||
|
}
|
||||||
|
fn body_key(space_key: &[u8; 32]) -> [u8; 32] {
|
||||||
|
blake3::derive_key("soulstream.space-body.v1", space_key)
|
||||||
|
}
|
||||||
|
fn body_nonce(msg_id: &[u8; 16]) -> [u8; 12] {
|
||||||
|
blake3::hash(msg_id).as_bytes()[..12].try_into().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seal a private message body. Call BEFORE signing (the signature covers
|
||||||
|
/// the sealed bytes). `m.body` must hold the plaintext going in.
|
||||||
|
pub fn seal_body(space_key: &[u8; 32], m: &Message) -> 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(&body_nonce(&m.msg_id)),
|
||||||
|
Payload { msg: &m.body, aad: &message_aad(m) },
|
||||||
|
)
|
||||||
|
.expect("seal")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a private message body. None = wrong key for this epoch, or tampered.
|
||||||
|
pub fn open_body(space_key: &[u8; 32], m: &Message) -> 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(&body_nonce(&m.msg_id)),
|
||||||
|
Payload { msg: &m.body, aad: &message_aad(m) },
|
||||||
|
)
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SpaceState {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub space_id: [u8; 16],
|
||||||
|
pub checkpoint: Option<Checkpoint>,
|
||||||
|
pub messages: Vec<Message>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn make_params(owner: &[u8; 32], space_id: &[u8; 16]) -> Vec<u8> {
|
||||||
|
let mut p = Vec::with_capacity(PARAMS_FAMILY.len() + 48);
|
||||||
|
p.extend_from_slice(PARAMS_FAMILY);
|
||||||
|
p.extend_from_slice(owner);
|
||||||
|
p.extend_from_slice(space_id);
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn params_parts(params: &[u8]) -> Option<([u8; 32], [u8; 16])> {
|
||||||
|
let t = PARAMS_FAMILY.len();
|
||||||
|
if params.len() != t + 48 || ¶ms[..t] != PARAMS_FAMILY {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((
|
||||||
|
params[t..t + 32].try_into().unwrap(),
|
||||||
|
params[t + 32..t + 48].try_into().unwrap(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn checkpoint_valid(owner: &[u8; 32], space_id: &[u8; 16], c: &Checkpoint) -> bool {
|
||||||
|
c.schema_version == SCHEMA_VERSION
|
||||||
|
&& &c.space_id == space_id
|
||||||
|
&& &c.owner == owner
|
||||||
|
&& c.epoch >= 1
|
||||||
|
&& c.name.len() <= MAX_NAME_BYTES
|
||||||
|
&& c.description.len() <= MAX_DESC_BYTES
|
||||||
|
&& !c.members.is_empty()
|
||||||
|
&& c.members.len() <= MAX_MEMBERS
|
||||||
|
&& c.message_cap >= 1
|
||||||
|
&& c.message_cap as usize <= MAX_MESSAGES
|
||||||
|
&& c.members.iter().any(|m| &m.key == owner && m.role == Role::Owner)
|
||||||
|
&& crate::verify_sig_in(SIGN_DOMAIN, owner, &checkpoint_sign_payload(c), &c.sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn message_valid(space_id: &[u8; 16], c: &Checkpoint, m: &Message) -> bool {
|
||||||
|
&m.space_id == space_id
|
||||||
|
&& m.epoch <= c.epoch
|
||||||
|
&& m.body.len() <= MAX_BODY_BYTES + 16
|
||||||
|
&& c.members.iter().any(|mem| mem.key == m.author)
|
||||||
|
&& crate::verify_sig_in(SIGN_DOMAIN, &m.author, &message_sign_payload(m), &m.sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deterministic multi-writer merge: the highest valid (epoch, hash)
|
||||||
|
/// checkpoint wins; messages are admitted only from ITS roster, deduped by
|
||||||
|
/// id (smaller content hash wins a collision), fairly bounded per author,
|
||||||
|
/// then the newest `message_cap` kept in (created_at, id) order.
|
||||||
|
pub fn merge_space(owner: &[u8; 32], space_id: &[u8; 16], candidates: &[&[u8]]) -> SpaceState {
|
||||||
|
let mut best: Option<(u64, [u8; 32], Checkpoint)> = None;
|
||||||
|
let mut parsed: Vec<SpaceState> = Vec::new();
|
||||||
|
for bytes in candidates {
|
||||||
|
if bytes.is_empty() || bytes.len() > MAX_STATE_BYTES {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(st) = bincode::deserialize::<SpaceState>(bytes) else { continue };
|
||||||
|
if st.schema_version != SCHEMA_VERSION || &st.space_id != space_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(c) = &st.checkpoint {
|
||||||
|
if checkpoint_valid(owner, space_id, c) {
|
||||||
|
let rank = (c.epoch, *blake3::hash(&checkpoint_sig_body(c)).as_bytes());
|
||||||
|
let better = match &best {
|
||||||
|
None => true,
|
||||||
|
Some((e, h, _)) => rank > (*e, *h),
|
||||||
|
};
|
||||||
|
if better {
|
||||||
|
best = Some((rank.0, rank.1, c.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parsed.push(st);
|
||||||
|
}
|
||||||
|
let Some((_, _, checkpoint)) = best else {
|
||||||
|
return SpaceState { schema_version: SCHEMA_VERSION, space_id: *space_id, checkpoint: None, messages: vec![] };
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut by_id: BTreeMap<[u8; 16], (Message, [u8; 32])> = BTreeMap::new();
|
||||||
|
for st in &parsed {
|
||||||
|
for m in &st.messages {
|
||||||
|
if !message_valid(space_id, &checkpoint, m) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let h = *blake3::hash(&bincode::serialize(m).unwrap()).as_bytes();
|
||||||
|
match by_id.get(&m.msg_id) {
|
||||||
|
Some((_, existing)) if existing <= &h => {}
|
||||||
|
_ => {
|
||||||
|
by_id.insert(m.msg_id, (m.clone(), h));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut all: Vec<Message> = by_id.into_values().map(|(m, _)| m).collect();
|
||||||
|
all.sort_by_key(|m| (m.created_at, m.msg_id));
|
||||||
|
// Per-author fairness before the global window: one voice cannot fill
|
||||||
|
// the whole window and push everyone else out.
|
||||||
|
let mut per_author: BTreeMap<[u8; 32], usize> = BTreeMap::new();
|
||||||
|
let mut kept: Vec<Message> = Vec::with_capacity(all.len());
|
||||||
|
for m in all.into_iter().rev() {
|
||||||
|
let n = per_author.entry(m.author).or_insert(0);
|
||||||
|
if *n < MAX_PER_AUTHOR_UNCAPPED {
|
||||||
|
*n += 1;
|
||||||
|
kept.push(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kept.reverse();
|
||||||
|
let cap = checkpoint.message_cap as usize;
|
||||||
|
if kept.len() > cap {
|
||||||
|
kept.drain(..kept.len() - cap);
|
||||||
|
}
|
||||||
|
SpaceState { schema_version: SCHEMA_VERSION, space_id: *space_id, checkpoint: Some(checkpoint), messages: kept }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ed25519_dalek::{Signer, SigningKey};
|
||||||
|
|
||||||
|
fn sign(key: &SigningKey, payload: &[u8]) -> Vec<u8> {
|
||||||
|
key.sign(&crate::signable(SIGN_DOMAIN, payload)).to_bytes().to_vec()
|
||||||
|
}
|
||||||
|
fn checkpoint(owner: &SigningKey, epoch: u64, members: &[&SigningKey], private: bool) -> Checkpoint {
|
||||||
|
let mut c = Checkpoint {
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
space_id: [4; 16],
|
||||||
|
owner: owner.verifying_key().to_bytes(),
|
||||||
|
epoch,
|
||||||
|
name: "Late Night Ambient".into(),
|
||||||
|
description: String::new(),
|
||||||
|
private,
|
||||||
|
members: std::iter::once(Member { key: owner.verifying_key().to_bytes(), role: Role::Owner, since_epoch: 1 })
|
||||||
|
.chain(members.iter().map(|k| Member { key: k.verifying_key().to_bytes(), role: Role::Member, since_epoch: epoch }))
|
||||||
|
.collect(),
|
||||||
|
pinned: None,
|
||||||
|
message_cap: DEFAULT_MESSAGE_CAP,
|
||||||
|
sig: vec![],
|
||||||
|
};
|
||||||
|
c.sig = sign(owner, &checkpoint_sign_payload(&c));
|
||||||
|
c
|
||||||
|
}
|
||||||
|
fn message(author: &SigningKey, epoch: u64, id: u8, at: u64, key: Option<&[u8; 32]>) -> Message {
|
||||||
|
let mut m = Message {
|
||||||
|
space_id: [4; 16],
|
||||||
|
epoch,
|
||||||
|
msg_id: [id; 16],
|
||||||
|
author: author.verifying_key().to_bytes(),
|
||||||
|
created_at: at,
|
||||||
|
body: b"hello".to_vec(),
|
||||||
|
sig: vec![],
|
||||||
|
};
|
||||||
|
if let Some(k) = key {
|
||||||
|
m.body = seal_body(k, &m);
|
||||||
|
}
|
||||||
|
m.sig = sign(author, &message_sign_payload(&m));
|
||||||
|
m
|
||||||
|
}
|
||||||
|
fn state(c: Option<&Checkpoint>, msgs: &[Message]) -> Vec<u8> {
|
||||||
|
bincode::serialize(&SpaceState { schema_version: SCHEMA_VERSION, space_id: [4; 16], checkpoint: c.cloned(), messages: msgs.to_vec() }).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roster_is_the_authority_and_removal_is_an_epoch() {
|
||||||
|
let owner = SigningKey::from_bytes(&[1; 32]);
|
||||||
|
let bob = SigningKey::from_bytes(&[2; 32]);
|
||||||
|
let mallory = SigningKey::from_bytes(&[3; 32]);
|
||||||
|
let ok = owner.verifying_key().to_bytes();
|
||||||
|
let c1 = checkpoint(&owner, 1, &[&bob], false);
|
||||||
|
let m_bob = message(&bob, 1, 10, 100, None);
|
||||||
|
let m_mal = message(&mallory, 1, 11, 101, None);
|
||||||
|
let merged = merge_space(&ok, &[4; 16], &[&state(Some(&c1), &[]), &state(None, &[m_bob.clone(), m_mal])]);
|
||||||
|
assert_eq!(merged.messages.len(), 1, "an off-roster author never enters");
|
||||||
|
// the canonical state merges to itself
|
||||||
|
let canon = bincode::serialize(&merged).unwrap();
|
||||||
|
assert_eq!(bincode::serialize(&merge_space(&ok, &[4; 16], &[&canon])).unwrap(), canon);
|
||||||
|
// a newer epoch without bob drops bob's messages; an old checkpoint cannot restore him
|
||||||
|
let c2 = checkpoint(&owner, 2, &[], false);
|
||||||
|
let after = merge_space(&ok, &[4; 16], &[&canon, &state(Some(&c2), &[])]);
|
||||||
|
assert_eq!(after.checkpoint.as_ref().map(|c| c.epoch), Some(2));
|
||||||
|
assert!(after.messages.is_empty());
|
||||||
|
let replay = merge_space(&ok, &[4; 16], &[&bincode::serialize(&after).unwrap(), &state(Some(&c1), &[m_bob])]);
|
||||||
|
assert_eq!(replay.checkpoint.as_ref().map(|c| c.epoch), Some(2));
|
||||||
|
// a checkpoint signed by a non-owner is not a checkpoint
|
||||||
|
let mut forged = checkpoint(&owner, 3, &[&mallory], false);
|
||||||
|
forged.sig = sign(&mallory, &checkpoint_sign_payload(&forged));
|
||||||
|
assert!(!checkpoint_valid(&ok, &[4; 16], &forged));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn private_bodies_open_only_with_the_epoch_key() {
|
||||||
|
let owner = SigningKey::from_bytes(&[1; 32]);
|
||||||
|
let key = [9u8; 32];
|
||||||
|
let m = message(&owner, 1, 7, 100, Some(&key));
|
||||||
|
assert_eq!(open_body(&key, &m).as_deref(), Some(&b"hello"[..]));
|
||||||
|
assert_eq!(open_body(&[8u8; 32], &m), None);
|
||||||
|
let mut t = m.clone();
|
||||||
|
t.created_at += 1;
|
||||||
|
assert_eq!(open_body(&key, &t), None, "the AAD binds the header");
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1449
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,28 @@
|
|||||||
|
[package]
|
||||||
|
name = "soulstream-space-contract"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "The SoulStream Space contract — a thin shell over persona_state::space::merge_space. 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]
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
//! The SoulStream Space contract. Multi-writer: the owner offers
|
||||||
|
//! checkpoints, members offer messages; `persona_state::space::merge_space`
|
||||||
|
//! is the bouncer (owner signature, roster, dedup, per-author fairness,
|
||||||
|
//! the window). Deterministic by construction, so every delivery order
|
||||||
|
//! converges; a state that does not merge to itself is refused.
|
||||||
|
//!
|
||||||
|
//! Build: `cargo build --release --target wasm32-unknown-unknown` in this
|
||||||
|
//! directory; copy `target/wasm32-unknown-unknown/release/
|
||||||
|
//! soulstream_space_contract.wasm` to `src-tauri/assets/space_contract.wasm`
|
||||||
|
//! and re-pin the code hash in `src-tauri/src/spaces.rs`.
|
||||||
|
|
||||||
|
use freenet_stdlib::prelude::*;
|
||||||
|
use persona_state::space as t;
|
||||||
|
|
||||||
|
pub struct Contract;
|
||||||
|
|
||||||
|
fn parts(parameters: &Parameters<'static>) -> Result<([u8; 32], [u8; 16]), ContractError> {
|
||||||
|
t::params_parts(parameters.as_ref())
|
||||||
|
.ok_or_else(|| ContractError::Other("params are not family+owner+space".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect<'a>(state: &'a State<'static>, data: &'a [UpdateData<'static>]) -> (Vec<&'a [u8]>, bool) {
|
||||||
|
let mut candidates: Vec<&[u8]> = Vec::new();
|
||||||
|
if !state.as_ref().is_empty() {
|
||||||
|
candidates.push(state.as_ref());
|
||||||
|
}
|
||||||
|
let mut any_parse = false;
|
||||||
|
let mut push = |b: &'a [u8]| {
|
||||||
|
if !b.is_empty() {
|
||||||
|
any_parse |= bincode::deserialize::<t::SpaceState>(b).is_ok();
|
||||||
|
candidates.push(b);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for ud in data {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(candidates, any_parse)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[contract]
|
||||||
|
impl ContractInterface for Contract {
|
||||||
|
fn validate_state(
|
||||||
|
parameters: Parameters<'static>,
|
||||||
|
state: State<'static>,
|
||||||
|
_related: RelatedContracts<'static>,
|
||||||
|
) -> Result<ValidateResult, ContractError> {
|
||||||
|
let (owner, space_id) = parts(¶meters)?;
|
||||||
|
let bytes = state.as_ref();
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return Ok(ValidateResult::Valid);
|
||||||
|
}
|
||||||
|
if bytes.len() > t::MAX_STATE_BYTES {
|
||||||
|
return Err(ContractError::Other(format!(
|
||||||
|
"space state {} bytes over cap {}",
|
||||||
|
bytes.len(),
|
||||||
|
t::MAX_STATE_BYTES
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let merged = t::merge_space(&owner, &space_id, &[bytes]);
|
||||||
|
let canon = bincode::serialize(&merged).map_err(|e| ContractError::Deser(e.to_string()))?;
|
||||||
|
if canon != bytes {
|
||||||
|
return Err(ContractError::Other(
|
||||||
|
"state is not canonical (unsigned, off-roster, or over the window)".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(ValidateResult::Valid)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_state(
|
||||||
|
parameters: Parameters<'static>,
|
||||||
|
state: State<'static>,
|
||||||
|
data: Vec<UpdateData<'static>>,
|
||||||
|
) -> Result<UpdateModification<'static>, ContractError> {
|
||||||
|
let (owner, space_id) = parts(¶meters)?;
|
||||||
|
let (candidates, any_parse) = collect(&state, &data);
|
||||||
|
if !any_parse && !data.is_empty() {
|
||||||
|
return Err(ContractError::InvalidUpdate);
|
||||||
|
}
|
||||||
|
let merged = t::merge_space(&owner, &space_id, &candidates);
|
||||||
|
let bytes = bincode::serialize(&merged).map_err(|e| ContractError::Deser(e.to_string()))?;
|
||||||
|
Ok(UpdateModification::valid(State::from(bytes)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_state(
|
||||||
|
parameters: Parameters<'static>,
|
||||||
|
state: State<'static>,
|
||||||
|
) -> Result<StateSummary<'static>, ContractError> {
|
||||||
|
let _ = parts(¶meters)?;
|
||||||
|
if state.as_ref().is_empty() {
|
||||||
|
return Ok(StateSummary::from(Vec::new()));
|
||||||
|
}
|
||||||
|
Ok(StateSummary::from(blake3::hash(state.as_ref()).as_bytes().to_vec()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_state_delta(
|
||||||
|
parameters: Parameters<'static>,
|
||||||
|
state: State<'static>,
|
||||||
|
summary: StateSummary<'static>,
|
||||||
|
) -> Result<StateDelta<'static>, ContractError> {
|
||||||
|
let _ = parts(¶meters)?;
|
||||||
|
if state.as_ref().is_empty() {
|
||||||
|
return Ok(StateDelta::from(Vec::new()));
|
||||||
|
}
|
||||||
|
if summary.as_ref() == blake3::hash(state.as_ref()).as_bytes() {
|
||||||
|
Ok(StateDelta::from(Vec::new()))
|
||||||
|
} else {
|
||||||
|
Ok(StateDelta::from(state.as_ref().to_vec()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -49,6 +49,29 @@ pub enum Cargo {
|
|||||||
FriendAssertion(fr::FriendAssertion),
|
FriendAssertion(fr::FriendAssertion),
|
||||||
FriendDecline([u8; 16]),
|
FriendDecline([u8; 16]),
|
||||||
Share(ShareCargo),
|
Share(ShareCargo),
|
||||||
|
SpaceInvite(SpaceInvite),
|
||||||
|
SpaceKey(SpaceKeyCargo),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "You are on the roster now, here is where the Space is" — and, for a
|
||||||
|
/// private Space, the epoch's key.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SpaceInvite {
|
||||||
|
pub space_id: [u8; 16],
|
||||||
|
pub owner: [u8; 32],
|
||||||
|
pub name: String,
|
||||||
|
pub private: bool,
|
||||||
|
pub epoch: u64,
|
||||||
|
pub key: Option<[u8; 32]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rotated space key for members who stay after a removal.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SpaceKeyCargo {
|
||||||
|
pub space_id: [u8; 16],
|
||||||
|
pub owner: [u8; 32],
|
||||||
|
pub epoch: u64,
|
||||||
|
pub key: [u8; 32],
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -410,6 +433,8 @@ async fn dispatch(app: &tauri::AppHandle, sender: [u8; 32], cargo: Cargo) {
|
|||||||
Cargo::FriendAssertion(a) => crate::friends::on_inbox_assertion(app, sender, a).await,
|
Cargo::FriendAssertion(a) => crate::friends::on_inbox_assertion(app, sender, a).await,
|
||||||
Cargo::FriendDecline(id) => crate::friends::on_inbox_decline(app, sender, id),
|
Cargo::FriendDecline(id) => crate::friends::on_inbox_decline(app, sender, id),
|
||||||
Cargo::Share(s) => crate::shared_playlists::on_inbox_share(app, sender, s),
|
Cargo::Share(s) => crate::shared_playlists::on_inbox_share(app, sender, s),
|
||||||
|
Cargo::SpaceInvite(i) => crate::spaces::on_inbox_invite(app, sender, i),
|
||||||
|
Cargo::SpaceKey(k) => crate::spaces::on_inbox_space_key(app, sender, k),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ mod shared_playlists;
|
|||||||
mod shazam;
|
mod shazam;
|
||||||
mod slsk_link;
|
mod slsk_link;
|
||||||
mod smtc;
|
mod smtc;
|
||||||
|
mod spaces;
|
||||||
mod state;
|
mod state;
|
||||||
mod thumbbar;
|
mod thumbbar;
|
||||||
mod vocal_sep;
|
mod vocal_sep;
|
||||||
@@ -150,6 +151,14 @@ pub fn run() {
|
|||||||
inbox::inbox_status,
|
inbox::inbox_status,
|
||||||
inbox::inbox_publish,
|
inbox::inbox_publish,
|
||||||
inbox::inbox_check,
|
inbox::inbox_check,
|
||||||
|
spaces::spaces_list,
|
||||||
|
spaces::space_create,
|
||||||
|
spaces::space_open,
|
||||||
|
spaces::space_post,
|
||||||
|
spaces::space_invite,
|
||||||
|
spaces::space_remove_member,
|
||||||
|
spaces::space_set_pinned,
|
||||||
|
spaces::space_leave,
|
||||||
commands::buddy_list,
|
commands::buddy_list,
|
||||||
commands::buddy_add,
|
commands::buddy_add,
|
||||||
commands::buddy_remove,
|
commands::buddy_remove,
|
||||||
|
|||||||
@@ -0,0 +1,637 @@
|
|||||||
|
//! Spaces, product side (F6 decision → the app). One contract per Space at
|
||||||
|
//! (owner, space id): the owner's signed CHECKPOINT is the authority —
|
||||||
|
//! name, mode, roster, pinned playlist — with an epoch that moves on every
|
||||||
|
//! roster change; members post signed MESSAGES into a bounded window; a
|
||||||
|
//! private Space seals bodies to a per-epoch space key the owner hands out
|
||||||
|
//! through members' encrypted inboxes and rotates on every removal.
|
||||||
|
//! Invitations are owner-authored: to invite is to add the key to the
|
||||||
|
//! roster and tell the person, sealed, where the Space is.
|
||||||
|
//!
|
||||||
|
//! What this device knows about its Spaces — which ones, its role, the
|
||||||
|
//! epoch keys it was given — lives in `<app_data>/freenet/spaces.json`.
|
||||||
|
//! Losing that file loses the keys; the owner can re-send them.
|
||||||
|
//!
|
||||||
|
//! v1 keeps members as their persona keys (a joined Space is a linked
|
||||||
|
//! identity); the room-local member key the design describes for
|
||||||
|
//! unlinkability is the next step, not a different contract.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use freenet_adapter::FreenetAdapter;
|
||||||
|
use persona_state::space as sp;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::{Emitter, Manager};
|
||||||
|
|
||||||
|
use crate::inbox::{self, Cargo, SpaceInvite, SpaceKeyCargo};
|
||||||
|
use crate::persona;
|
||||||
|
use crate::slsk_link;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
const SPACE_WASM: &[u8] = include_bytes!("../assets/space_contract.wasm");
|
||||||
|
pub const EVENT: &str = "community:spaces";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct SpaceRecord {
|
||||||
|
owner: String,
|
||||||
|
space_id: String,
|
||||||
|
name: String,
|
||||||
|
private: bool,
|
||||||
|
my_role: String,
|
||||||
|
joined_at: u64,
|
||||||
|
/// epoch → space key (b58), for private Spaces.
|
||||||
|
#[serde(default)]
|
||||||
|
keys: BTreeMap<String, String>,
|
||||||
|
#[serde(default)]
|
||||||
|
last_epoch: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Serialize, Deserialize)]
|
||||||
|
struct Ledger {
|
||||||
|
#[serde(default)]
|
||||||
|
spaces: BTreeMap<String, SpaceRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SpaceListItem {
|
||||||
|
pub id: String,
|
||||||
|
pub owner: String,
|
||||||
|
pub space_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub private: bool,
|
||||||
|
pub my_role: String,
|
||||||
|
pub last_epoch: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct MemberView {
|
||||||
|
pub key: String,
|
||||||
|
pub role: String,
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub is_me: bool,
|
||||||
|
pub since_epoch: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct MessageView {
|
||||||
|
pub id: String,
|
||||||
|
pub author: String,
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub is_me: bool,
|
||||||
|
pub created_at: u64,
|
||||||
|
pub epoch: u64,
|
||||||
|
/// None when sealed to a key this device does not hold.
|
||||||
|
pub text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct SpaceView {
|
||||||
|
pub id: String,
|
||||||
|
pub owner: String,
|
||||||
|
pub space_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub private: bool,
|
||||||
|
pub epoch: u64,
|
||||||
|
pub my_role: String,
|
||||||
|
pub message_cap: u32,
|
||||||
|
pub members: Vec<MemberView>,
|
||||||
|
pub pinned_owner: Option<String>,
|
||||||
|
pub pinned_uuid: Option<String>,
|
||||||
|
pub messages: Vec<MessageView>,
|
||||||
|
pub can_post: bool,
|
||||||
|
pub has_key: bool,
|
||||||
|
/// The contract exists on the network (a Space someone invited us to
|
||||||
|
/// may not have propagated to this node yet).
|
||||||
|
pub reachable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_secs() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
fn rand16() -> [u8; 16] {
|
||||||
|
use rand::RngCore;
|
||||||
|
let mut n = [0u8; 16];
|
||||||
|
rand::thread_rng().fill_bytes(&mut n);
|
||||||
|
n
|
||||||
|
}
|
||||||
|
fn rand32() -> [u8; 32] {
|
||||||
|
use rand::RngCore;
|
||||||
|
let mut n = [0u8; 32];
|
||||||
|
rand::thread_rng().fill_bytes(&mut n);
|
||||||
|
n
|
||||||
|
}
|
||||||
|
fn b58(bytes: &[u8]) -> String {
|
||||||
|
bs58::encode(bytes).into_string()
|
||||||
|
}
|
||||||
|
fn key_of(s: &str) -> Result<[u8; 32], String> {
|
||||||
|
inbox::key_of(s)
|
||||||
|
}
|
||||||
|
fn id_of(s: &str) -> Result<[u8; 16], String> {
|
||||||
|
bs58::decode(s)
|
||||||
|
.into_vec()
|
||||||
|
.map_err(|e| format!("bad id: {e}"))?
|
||||||
|
.as_slice()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| "id is not 16 bytes".to_string())
|
||||||
|
}
|
||||||
|
fn cap(s: &str, max: usize) -> String {
|
||||||
|
if s.len() <= max {
|
||||||
|
return s.to_string();
|
||||||
|
}
|
||||||
|
let mut end = max;
|
||||||
|
while end > 0 && !s.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
s[..end].to_string()
|
||||||
|
}
|
||||||
|
fn record_id(owner: &str, space_id: &str) -> String {
|
||||||
|
format!("{owner}:{space_id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||||
|
Ok(app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.join("freenet")
|
||||||
|
.join("spaces.json"))
|
||||||
|
}
|
||||||
|
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_pretty(l).map_err(|e| e.to_string())?)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let _ = app.emit(EVENT, ());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
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 sign(app: &tauri::AppHandle, payload: Vec<u8>) -> Result<Vec<u8>, String> {
|
||||||
|
let adapter = app.state::<AppState>().freenet.clone();
|
||||||
|
persona::sign(&adapter, sp::SIGN_DOMAIN, payload).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_state(app: &tauri::AppHandle, owner: &[u8; 32], space_id: &[u8; 16]) -> Result<(sp::SpaceState, bool), String> {
|
||||||
|
let adapter = app.state::<AppState>().freenet.clone();
|
||||||
|
let ids = FreenetAdapter::contract_ids(SPACE_WASM.to_vec(), &sp::make_params(owner, space_id));
|
||||||
|
match adapter.contract_get(&ids.instance, false).await {
|
||||||
|
Ok(bytes) => Ok((sp::merge_space(owner, space_id, &[&bytes]), true)),
|
||||||
|
Err(e) if e.to_string().contains("not found") => Ok((sp::SpaceState::default(), false)),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn offer(app: &tauri::AppHandle, owner: &[u8; 32], space_id: &[u8; 16], state: &sp::SpaceState, first: bool) -> Result<(), String> {
|
||||||
|
let adapter = app.state::<AppState>().freenet.clone();
|
||||||
|
let bytes = bincode::serialize(state).map_err(|e| e.to_string())?;
|
||||||
|
let params = sp::make_params(owner, space_id);
|
||||||
|
if first {
|
||||||
|
adapter.contract_put(SPACE_WASM.to_vec(), ¶ms, bytes, false).await.map(|_| ())
|
||||||
|
} else {
|
||||||
|
adapter.get_then_update(SPACE_WASM.to_vec(), ¶ms, bytes).await.map(|_| ())
|
||||||
|
}
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_state(space_id: [u8; 16], checkpoint: Option<sp::Checkpoint>, messages: Vec<sp::Message>) -> sp::SpaceState {
|
||||||
|
sp::SpaceState { schema_version: sp::SCHEMA_VERSION, space_id, checkpoint, messages }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- views
|
||||||
|
|
||||||
|
fn view_of(app: &tauri::AppHandle, rec: &SpaceRecord, state: &sp::SpaceState, me: &[u8; 32], reachable: bool) -> SpaceView {
|
||||||
|
let me_b58 = b58(me);
|
||||||
|
let key_for = |epoch: u64| -> Option<[u8; 32]> {
|
||||||
|
rec.keys.get(&epoch.to_string()).and_then(|k| key_of(k).ok())
|
||||||
|
};
|
||||||
|
let Some(c) = &state.checkpoint else {
|
||||||
|
return SpaceView {
|
||||||
|
id: record_id(&rec.owner, &rec.space_id),
|
||||||
|
owner: rec.owner.clone(),
|
||||||
|
space_id: rec.space_id.clone(),
|
||||||
|
name: rec.name.clone(),
|
||||||
|
description: String::new(),
|
||||||
|
private: rec.private,
|
||||||
|
epoch: 0,
|
||||||
|
my_role: rec.my_role.clone(),
|
||||||
|
message_cap: sp::DEFAULT_MESSAGE_CAP,
|
||||||
|
members: vec![],
|
||||||
|
pinned_owner: None,
|
||||||
|
pinned_uuid: None,
|
||||||
|
messages: vec![],
|
||||||
|
can_post: false,
|
||||||
|
has_key: false,
|
||||||
|
reachable,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let is_member = c.members.iter().any(|m| &m.key == me);
|
||||||
|
let has_key = !c.private || key_for(c.epoch).is_some();
|
||||||
|
SpaceView {
|
||||||
|
id: record_id(&rec.owner, &rec.space_id),
|
||||||
|
owner: rec.owner.clone(),
|
||||||
|
space_id: rec.space_id.clone(),
|
||||||
|
name: c.name.clone(),
|
||||||
|
description: c.description.clone(),
|
||||||
|
private: c.private,
|
||||||
|
epoch: c.epoch,
|
||||||
|
my_role: if &c.owner == me { "owner".into() } else if is_member { "member".into() } else { "none".into() },
|
||||||
|
message_cap: c.message_cap,
|
||||||
|
members: c
|
||||||
|
.members
|
||||||
|
.iter()
|
||||||
|
.map(|m| MemberView {
|
||||||
|
key: b58(&m.key),
|
||||||
|
role: match m.role {
|
||||||
|
sp::Role::Owner => "owner".into(),
|
||||||
|
sp::Role::Member => "member".into(),
|
||||||
|
},
|
||||||
|
username: slsk_link::username_for_key(app, &b58(&m.key)),
|
||||||
|
is_me: &m.key == me,
|
||||||
|
since_epoch: m.since_epoch,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
pinned_owner: c.pinned.as_ref().map(|p| b58(&p.owner)),
|
||||||
|
pinned_uuid: c.pinned.as_ref().map(|p| b58(&p.uuid)),
|
||||||
|
messages: state
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.map(|m| {
|
||||||
|
let text = if c.private {
|
||||||
|
key_for(m.epoch).and_then(|k| sp::open_body(&k, m)).and_then(|b| String::from_utf8(b).ok())
|
||||||
|
} else {
|
||||||
|
String::from_utf8(m.body.clone()).ok()
|
||||||
|
};
|
||||||
|
MessageView {
|
||||||
|
id: b58(&m.msg_id),
|
||||||
|
author: b58(&m.author),
|
||||||
|
username: slsk_link::username_for_key(app, &b58(&m.author)),
|
||||||
|
is_me: &m.author == me,
|
||||||
|
created_at: m.created_at,
|
||||||
|
epoch: m.epoch,
|
||||||
|
text,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
can_post: is_member && has_key,
|
||||||
|
has_key,
|
||||||
|
reachable,
|
||||||
|
}
|
||||||
|
.with_me(me_b58)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SpaceView {
|
||||||
|
fn with_me(self, _me: String) -> Self {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list(app: &tauri::AppHandle) -> Vec<SpaceListItem> {
|
||||||
|
let l = load(app);
|
||||||
|
let mut v: Vec<_> = l
|
||||||
|
.spaces
|
||||||
|
.values()
|
||||||
|
.map(|r| SpaceListItem {
|
||||||
|
id: record_id(&r.owner, &r.space_id),
|
||||||
|
owner: r.owner.clone(),
|
||||||
|
space_id: r.space_id.clone(),
|
||||||
|
name: r.name.clone(),
|
||||||
|
private: r.private,
|
||||||
|
my_role: r.my_role.clone(),
|
||||||
|
last_epoch: r.last_epoch,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
v.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- actions
|
||||||
|
|
||||||
|
pub async fn create(app: &tauri::AppHandle, name: &str, description: &str, private: bool) -> Result<SpaceView, String> {
|
||||||
|
let me = my_key(app).await?;
|
||||||
|
let name = cap(name.trim(), sp::MAX_NAME_BYTES);
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("a Space needs a name".into());
|
||||||
|
}
|
||||||
|
let space_id = rand16();
|
||||||
|
let mut c = sp::Checkpoint {
|
||||||
|
schema_version: sp::SCHEMA_VERSION,
|
||||||
|
space_id,
|
||||||
|
owner: me,
|
||||||
|
epoch: 1,
|
||||||
|
name: name.clone(),
|
||||||
|
description: cap(description.trim(), sp::MAX_DESC_BYTES),
|
||||||
|
private,
|
||||||
|
members: vec![sp::Member { key: me, role: sp::Role::Owner, since_epoch: 1 }],
|
||||||
|
pinned: None,
|
||||||
|
message_cap: sp::DEFAULT_MESSAGE_CAP,
|
||||||
|
sig: vec![],
|
||||||
|
};
|
||||||
|
c.sig = sign(app, sp::checkpoint_sign_payload(&c)).await?;
|
||||||
|
let state = empty_state(space_id, Some(c), vec![]);
|
||||||
|
offer(app, &me, &space_id, &state, true).await?;
|
||||||
|
let mut keys = BTreeMap::new();
|
||||||
|
if private {
|
||||||
|
keys.insert("1".to_string(), b58(&rand32()));
|
||||||
|
}
|
||||||
|
let rec = SpaceRecord {
|
||||||
|
owner: b58(&me),
|
||||||
|
space_id: b58(&space_id),
|
||||||
|
name,
|
||||||
|
private,
|
||||||
|
my_role: "owner".into(),
|
||||||
|
joined_at: now_secs(),
|
||||||
|
keys,
|
||||||
|
last_epoch: 1,
|
||||||
|
};
|
||||||
|
let mut l = load(app);
|
||||||
|
l.spaces.insert(record_id(&rec.owner, &rec.space_id), rec.clone());
|
||||||
|
save(app, &l)?;
|
||||||
|
log::info!("spaces: created {:?} ({})", rec.name, if private { "private" } else { "public" });
|
||||||
|
Ok(view_of(app, &rec, &state, &me, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn open(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<SpaceView, String> {
|
||||||
|
let me = my_key(app).await?;
|
||||||
|
let owner = key_of(owner_b58)?;
|
||||||
|
let space_id = id_of(space_b58)?;
|
||||||
|
let mut l = load(app);
|
||||||
|
let rid = record_id(owner_b58, space_b58);
|
||||||
|
let rec = l.spaces.get(&rid).cloned().ok_or("not a Space this device knows")?;
|
||||||
|
let (state, reachable) = fetch_state(app, &owner, &space_id).await?;
|
||||||
|
if let Some(c) = &state.checkpoint {
|
||||||
|
let r = l.spaces.get_mut(&rid).unwrap();
|
||||||
|
let changed = r.name != c.name || r.last_epoch != c.epoch || r.private != c.private;
|
||||||
|
r.name = c.name.clone();
|
||||||
|
r.last_epoch = c.epoch;
|
||||||
|
r.private = c.private;
|
||||||
|
// Adding members never rotates the key: the previous epoch's key
|
||||||
|
// carries forward until a removal issues a new one.
|
||||||
|
if c.private && !r.keys.contains_key(&c.epoch.to_string()) {
|
||||||
|
if let Some((_, k)) = r.keys.iter().filter(|(e, _)| e.parse::<u64>().unwrap_or(0) < c.epoch).last() {
|
||||||
|
let k = k.clone();
|
||||||
|
r.keys.insert(c.epoch.to_string(), k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
let _ = save(app, &l);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let rec = l.spaces.get(&rid).cloned().unwrap_or(rec);
|
||||||
|
Ok(view_of(app, &rec, &state, &me, reachable))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, text: &str) -> Result<(), String> {
|
||||||
|
let me = my_key(app).await?;
|
||||||
|
let owner = key_of(owner_b58)?;
|
||||||
|
let space_id = id_of(space_b58)?;
|
||||||
|
let text = text.trim();
|
||||||
|
if text.is_empty() {
|
||||||
|
return Err("nothing to say".into());
|
||||||
|
}
|
||||||
|
if text.len() > sp::MAX_BODY_BYTES {
|
||||||
|
return Err(format!("over {} bytes", sp::MAX_BODY_BYTES));
|
||||||
|
}
|
||||||
|
let (state, _) = fetch_state(app, &owner, &space_id).await?;
|
||||||
|
let c = state.checkpoint.as_ref().ok_or("the Space has not reached this node yet")?;
|
||||||
|
if !c.members.iter().any(|m| m.key == me) {
|
||||||
|
return Err("you are not in this Space's roster".into());
|
||||||
|
}
|
||||||
|
let mut m = sp::Message {
|
||||||
|
space_id,
|
||||||
|
epoch: c.epoch,
|
||||||
|
msg_id: rand16(),
|
||||||
|
author: me,
|
||||||
|
created_at: now_secs(),
|
||||||
|
body: text.as_bytes().to_vec(),
|
||||||
|
sig: vec![],
|
||||||
|
};
|
||||||
|
if c.private {
|
||||||
|
let l = load(app);
|
||||||
|
let rec = l.spaces.get(&record_id(owner_b58, space_b58)).ok_or("not a Space this device knows")?;
|
||||||
|
let key = rec
|
||||||
|
.keys
|
||||||
|
.get(&c.epoch.to_string())
|
||||||
|
.and_then(|k| key_of(k).ok())
|
||||||
|
.ok_or("no key for this epoch yet — the owner has to hand it to you")?;
|
||||||
|
m.body = sp::seal_body(&key, &m);
|
||||||
|
}
|
||||||
|
m.sig = sign(app, sp::message_sign_payload(&m)).await?;
|
||||||
|
offer(app, &owner, &space_id, &empty_state(space_id, None, vec![m]), false).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The owner rewrites the checkpoint: a new epoch with `mutate` applied.
|
||||||
|
async fn bump(app: &tauri::AppHandle, me: [u8; 32], owner: [u8; 32], space_id: [u8; 16], mutate: impl FnOnce(&mut sp::Checkpoint)) -> Result<sp::Checkpoint, String> {
|
||||||
|
if me != owner {
|
||||||
|
return Err("only the owner can change a Space".into());
|
||||||
|
}
|
||||||
|
let (state, _) = fetch_state(app, &owner, &space_id).await?;
|
||||||
|
let mut c = state.checkpoint.ok_or("the Space has not reached this node yet")?;
|
||||||
|
c.epoch += 1;
|
||||||
|
mutate(&mut c);
|
||||||
|
if c.members.len() > sp::MAX_MEMBERS {
|
||||||
|
return Err(format!("over {} members", sp::MAX_MEMBERS));
|
||||||
|
}
|
||||||
|
c.sig = sign(app, sp::checkpoint_sign_payload(&c)).await?;
|
||||||
|
offer(app, &owner, &space_id, &empty_state(space_id, Some(c.clone()), vec![]), false).await?;
|
||||||
|
Ok(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn invite(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, persona_key: &str) -> Result<SpaceView, String> {
|
||||||
|
let me = my_key(app).await?;
|
||||||
|
let owner = key_of(owner_b58)?;
|
||||||
|
let space_id = id_of(space_b58)?;
|
||||||
|
let their = key_of(persona_key)?;
|
||||||
|
if their == me {
|
||||||
|
return Err("that is your own identity".into());
|
||||||
|
}
|
||||||
|
if !inbox::reachable(app, &their).await {
|
||||||
|
return Err("they have not published an encrypted inbox — the invitation has nowhere to go".into());
|
||||||
|
}
|
||||||
|
let c = bump(app, me, owner, space_id, |c| {
|
||||||
|
if !c.members.iter().any(|m| m.key == their) {
|
||||||
|
let e = c.epoch;
|
||||||
|
c.members.push(sp::Member { key: their, role: sp::Role::Member, since_epoch: e });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let rid = record_id(owner_b58, space_b58);
|
||||||
|
let mut l = load(app);
|
||||||
|
let rec = l.spaces.get_mut(&rid).ok_or("not a Space this device knows")?;
|
||||||
|
let mut key = None;
|
||||||
|
if c.private {
|
||||||
|
let prev = rec.keys.get(&(c.epoch - 1).to_string()).cloned().or_else(|| rec.keys.values().last().cloned());
|
||||||
|
let k = prev.unwrap_or_else(|| b58(&rand32()));
|
||||||
|
rec.keys.insert(c.epoch.to_string(), k.clone());
|
||||||
|
key = Some(key_of(&k)?);
|
||||||
|
}
|
||||||
|
rec.last_epoch = c.epoch;
|
||||||
|
save(app, &l)?;
|
||||||
|
inbox::send(
|
||||||
|
app,
|
||||||
|
&their,
|
||||||
|
&Cargo::SpaceInvite(SpaceInvite { space_id, owner, name: c.name.clone(), private: c.private, epoch: c.epoch, key }),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
log::info!("spaces: invited {persona_key} to {:?} (epoch {})", c.name, c.epoch);
|
||||||
|
open(app, owner_b58, space_b58).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_member(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, persona_key: &str) -> Result<SpaceView, String> {
|
||||||
|
let me = my_key(app).await?;
|
||||||
|
let owner = key_of(owner_b58)?;
|
||||||
|
let space_id = id_of(space_b58)?;
|
||||||
|
let gone = key_of(persona_key)?;
|
||||||
|
if gone == me {
|
||||||
|
return Err("the owner cannot be removed".into());
|
||||||
|
}
|
||||||
|
let c = bump(app, me, owner, space_id, |c| c.members.retain(|m| m.key != gone)).await?;
|
||||||
|
let rid = record_id(owner_b58, space_b58);
|
||||||
|
let mut l = load(app);
|
||||||
|
let rec = l.spaces.get_mut(&rid).ok_or("not a Space this device knows")?;
|
||||||
|
rec.last_epoch = c.epoch;
|
||||||
|
if c.private {
|
||||||
|
// A removal rotates: everyone who stays gets the new epoch's key.
|
||||||
|
let k = rand32();
|
||||||
|
rec.keys.insert(c.epoch.to_string(), b58(&k));
|
||||||
|
save(app, &l)?;
|
||||||
|
for m in c.members.iter().filter(|m| m.key != me) {
|
||||||
|
let cargo = Cargo::SpaceKey(SpaceKeyCargo { space_id, owner, epoch: c.epoch, key: k });
|
||||||
|
if let Err(e) = inbox::send(app, &m.key, &cargo).await {
|
||||||
|
log::warn!("spaces: could not hand the epoch-{} key to {}: {e}", c.epoch, b58(&m.key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
save(app, &l)?;
|
||||||
|
}
|
||||||
|
open(app, owner_b58, space_b58).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_pinned(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, pl_owner: Option<String>, pl_uuid: Option<String>) -> Result<SpaceView, String> {
|
||||||
|
let me = my_key(app).await?;
|
||||||
|
let owner = key_of(owner_b58)?;
|
||||||
|
let space_id = id_of(space_b58)?;
|
||||||
|
let pinned = match (pl_owner, pl_uuid) {
|
||||||
|
(Some(o), Some(u)) => Some(sp::PlaylistRef { owner: key_of(&o)?, uuid: id_of(&u)? }),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
bump(app, me, owner, space_id, |c| c.pinned = pinned).await?;
|
||||||
|
open(app, owner_b58, space_b58).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn leave(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<(), String> {
|
||||||
|
let mut l = load(app);
|
||||||
|
let rid = record_id(owner_b58, space_b58);
|
||||||
|
if l.spaces.get(&rid).is_some_and(|r| r.my_role == "owner") {
|
||||||
|
return Err("an owner keeps the Space; remove members instead".into());
|
||||||
|
}
|
||||||
|
l.spaces.remove(&rid);
|
||||||
|
save(app, &l)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- inbox cargo
|
||||||
|
|
||||||
|
pub(crate) fn on_inbox_invite(app: &tauri::AppHandle, sender: [u8; 32], inv: SpaceInvite) {
|
||||||
|
if sender != inv.owner {
|
||||||
|
log::warn!("spaces: an invitation was sealed by someone other than the Space's owner");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let rid = record_id(&b58(&inv.owner), &b58(&inv.space_id));
|
||||||
|
let mut l = load(app);
|
||||||
|
let rec = l.spaces.entry(rid).or_insert_with(|| SpaceRecord {
|
||||||
|
owner: b58(&inv.owner),
|
||||||
|
space_id: b58(&inv.space_id),
|
||||||
|
name: cap(&inv.name, sp::MAX_NAME_BYTES),
|
||||||
|
private: inv.private,
|
||||||
|
my_role: "member".into(),
|
||||||
|
joined_at: now_secs(),
|
||||||
|
keys: BTreeMap::new(),
|
||||||
|
last_epoch: 0,
|
||||||
|
});
|
||||||
|
rec.name = cap(&inv.name, sp::MAX_NAME_BYTES);
|
||||||
|
rec.private = inv.private;
|
||||||
|
if let Some(k) = inv.key {
|
||||||
|
rec.keys.insert(inv.epoch.to_string(), b58(&k));
|
||||||
|
}
|
||||||
|
if let Err(e) = save(app, &l) {
|
||||||
|
log::warn!("spaces: could not keep an invitation: {e}");
|
||||||
|
} else {
|
||||||
|
log::info!("spaces: invited to {:?} by {}", inv.name, b58(&inv.owner));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn on_inbox_space_key(app: &tauri::AppHandle, sender: [u8; 32], sk: SpaceKeyCargo) {
|
||||||
|
if sender != sk.owner {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let rid = record_id(&b58(&sk.owner), &b58(&sk.space_id));
|
||||||
|
let mut l = load(app);
|
||||||
|
let Some(rec) = l.spaces.get_mut(&rid) else { return };
|
||||||
|
rec.keys.insert(sk.epoch.to_string(), b58(&sk.key));
|
||||||
|
let _ = save(app, &l);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- commands
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn spaces_list(app: tauri::AppHandle) -> Vec<SpaceListItem> {
|
||||||
|
list(&app)
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn space_create(app: tauri::AppHandle, name: String, description: String, private: bool) -> Result<SpaceView, String> {
|
||||||
|
create(&app, &name, &description, private).await
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn space_open(app: tauri::AppHandle, owner: String, space: String) -> Result<SpaceView, String> {
|
||||||
|
open(&app, &owner, &space).await
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn space_post(app: tauri::AppHandle, owner: String, space: String, text: String) -> Result<(), String> {
|
||||||
|
post(&app, &owner, &space, &text).await
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn space_invite(app: tauri::AppHandle, owner: String, space: String, persona_key: String) -> Result<SpaceView, String> {
|
||||||
|
invite(&app, &owner, &space, &persona_key).await
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn space_remove_member(app: tauri::AppHandle, owner: String, space: String, persona_key: String) -> Result<SpaceView, String> {
|
||||||
|
remove_member(&app, &owner, &space, &persona_key).await
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn space_set_pinned(app: tauri::AppHandle, owner: String, space: String, playlist_owner: Option<String>, playlist_uuid: Option<String>) -> Result<SpaceView, String> {
|
||||||
|
set_pinned(&app, &owner, &space, playlist_owner, playlist_uuid).await
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn space_leave(app: tauri::AppHandle, owner: String, space: String) -> Result<(), String> {
|
||||||
|
leave(&app, &owner, &space)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contract_generation_is_pinned() {
|
||||||
|
let ids = FreenetAdapter::contract_ids(SPACE_WASM.to_vec(), &sp::make_params(&[7u8; 32], &[1u8; 16]));
|
||||||
|
assert_eq!(ids.code_hash, "BQc5bg1QEfTsgkog1WsS3di7fkwVSd8VJQpNUHdEMH3a");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -174,6 +174,14 @@ export const shareDismiss = (id) => invoke("share_dismiss", { id });
|
|||||||
export const inboxStatus = () => invoke("inbox_status");
|
export const inboxStatus = () => invoke("inbox_status");
|
||||||
export const inboxPublish = () => invoke("inbox_publish");
|
export const inboxPublish = () => invoke("inbox_publish");
|
||||||
export const inboxCheck = () => invoke("inbox_check");
|
export const inboxCheck = () => invoke("inbox_check");
|
||||||
|
export const spacesList = () => invoke("spaces_list");
|
||||||
|
export const spaceCreate = (name, description, isPrivate) => invoke("space_create", { name, description, private: isPrivate });
|
||||||
|
export const spaceOpen = (owner, space) => invoke("space_open", { owner, space });
|
||||||
|
export const spacePost = (owner, space, text) => invoke("space_post", { owner, space, text });
|
||||||
|
export const spaceInvite = (owner, space, personaKey) => invoke("space_invite", { owner, space, personaKey });
|
||||||
|
export const spaceRemoveMember = (owner, space, personaKey) => invoke("space_remove_member", { owner, space, personaKey });
|
||||||
|
export const spaceSetPinned = (owner, space, playlistOwner, playlistUuid) => invoke("space_set_pinned", { owner, space, playlistOwner, playlistUuid });
|
||||||
|
export const spaceLeave = (owner, space) => invoke("space_leave", { owner, space });
|
||||||
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 });
|
||||||
|
|||||||
@@ -75,6 +75,11 @@ export const community = $state({
|
|||||||
inbox: null,
|
inbox: null,
|
||||||
/** What the last check found ({new_items, quarantined, pruned}). */
|
/** What the last check found ({new_items, quarantined, pruned}). */
|
||||||
inboxLast: null,
|
inboxLast: null,
|
||||||
|
/** spaces_list: the Spaces this device owns or was invited to. */
|
||||||
|
spaces: [],
|
||||||
|
/** The open Space (space_open), or null. */
|
||||||
|
space: null,
|
||||||
|
spaceError: "",
|
||||||
/** 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: {},
|
||||||
@@ -319,6 +324,10 @@ export async function initCommunity() {
|
|||||||
else if (community.persona) community.persona.available = false;
|
else if (community.persona) community.persona.available = false;
|
||||||
});
|
});
|
||||||
api.onEvent("community:friends", () => void refreshFriends());
|
api.onEvent("community:friends", () => void refreshFriends());
|
||||||
|
api.onEvent("community:spaces", () => {
|
||||||
|
void refreshSpaces();
|
||||||
|
if (community.space) void openSpace(community.space.owner, community.space.space_id);
|
||||||
|
});
|
||||||
api.onEvent("community:shares", () => {
|
api.onEvent("community:shares", () => {
|
||||||
void refreshShares();
|
void refreshShares();
|
||||||
void refreshPublished();
|
void refreshPublished();
|
||||||
@@ -327,7 +336,7 @@ export async function initCommunity() {
|
|||||||
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(), refreshFriends(), refreshShares(), refreshPublished(), refreshInbox()]);
|
await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished(), refreshInbox(), refreshSpaces()]);
|
||||||
await refreshPersona();
|
await refreshPersona();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,7 +528,57 @@ export const friendshipForKey = (key) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// ---- spaces -------------------------------------------------------------------------
|
||||||
|
export async function refreshSpaces() {
|
||||||
|
try {
|
||||||
|
community.spaces = await api.spacesList();
|
||||||
|
} catch {
|
||||||
|
// stays as it was
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Read a Space through the node; the view is what the checkpoint says. */
|
||||||
|
export async function openSpace(owner, space) {
|
||||||
|
community.spaceError = "";
|
||||||
|
community.busy["space:" + space] = true;
|
||||||
|
try {
|
||||||
|
community.space = await api.spaceOpen(owner, space);
|
||||||
|
} catch (e) {
|
||||||
|
community.spaceError = String(e?.message ?? e);
|
||||||
|
} finally {
|
||||||
|
delete community.busy["space:" + space];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const createSpace = (name, description, isPrivate) =>
|
||||||
|
run("space:create", async () => {
|
||||||
|
community.space = await api.spaceCreate(name, description, isPrivate);
|
||||||
|
await refreshSpaces();
|
||||||
|
});
|
||||||
|
export const postToSpace = (owner, space, text) =>
|
||||||
|
run("space:post", async () => {
|
||||||
|
await api.spacePost(owner, space, text);
|
||||||
|
await openSpace(owner, space);
|
||||||
|
});
|
||||||
|
export const inviteToSpace = (owner, space, personaKey) =>
|
||||||
|
run("space:invite", async () => {
|
||||||
|
community.space = await api.spaceInvite(owner, space, personaKey);
|
||||||
|
});
|
||||||
|
export const removeFromSpace = (owner, space, personaKey) =>
|
||||||
|
run("space:remove", async () => {
|
||||||
|
community.space = await api.spaceRemoveMember(owner, space, personaKey);
|
||||||
|
});
|
||||||
|
export const pinToSpace = (owner, space, playlistOwner, playlistUuid) =>
|
||||||
|
run("space:pin", async () => {
|
||||||
|
community.space = await api.spaceSetPinned(owner, space, playlistOwner, playlistUuid);
|
||||||
|
});
|
||||||
|
export const leaveSpace = (owner, space) =>
|
||||||
|
run("space:leave", async () => {
|
||||||
|
await api.spaceLeave(owner, space);
|
||||||
|
community.space = null;
|
||||||
|
await refreshSpaces();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// Dev handle beside `__soulstream` (state.svelte.js): probes reach the store
|
// Dev handle beside `__soulstream` (state.svelte.js): probes reach the store
|
||||||
// through this, never through a bare import (which would be a second copy).
|
// through this, never through a bare import (which would be a second copy).
|
||||||
if (import.meta.env.DEV) globalThis.__community = community;
|
if (import.meta.env.DEV) globalThis.__community = community;
|
||||||
if (import.meta.env.DEV) globalThis.__communityActions = { friendRequest, friendRequestByKey, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished, publishInbox, checkInbox, refreshInbox };
|
if (import.meta.env.DEV) globalThis.__communityActions = { friendRequest, friendRequestByKey, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished, publishInbox, checkInbox, refreshInbox, refreshSpaces, openSpace, createSpace, postToSpace, inviteToSpace, removeFromSpace, pinToSpace, leaveSpace };
|
||||||
+187
-8
@@ -80,6 +80,14 @@
|
|||||||
publishInbox,
|
publishInbox,
|
||||||
checkInbox,
|
checkInbox,
|
||||||
friendRequestByKey,
|
friendRequestByKey,
|
||||||
|
refreshSpaces,
|
||||||
|
openSpace,
|
||||||
|
createSpace,
|
||||||
|
postToSpace,
|
||||||
|
inviteToSpace,
|
||||||
|
removeFromSpace,
|
||||||
|
pinToSpace,
|
||||||
|
leaveSpace,
|
||||||
} from "../lib/community.svelte.js";
|
} from "../lib/community.svelte.js";
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -445,6 +453,53 @@
|
|||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
});
|
});
|
||||||
let addKey = $state("");
|
let addKey = $state("");
|
||||||
|
// ---- spaces ------------------------------------------------------------
|
||||||
|
let spaceName = $state("");
|
||||||
|
let spacePrivate = $state(true);
|
||||||
|
let spaceText = $state("");
|
||||||
|
let spaceInviteKey = $state("");
|
||||||
|
let spaceTab = $state("chat");
|
||||||
|
let spaceCreating = $state(false);
|
||||||
|
const sv = $derived(community.space);
|
||||||
|
const spaceOwner = $derived(sv?.my_role === "owner");
|
||||||
|
let spaceEl = $state(null);
|
||||||
|
$effect(() => {
|
||||||
|
const n = sv?.messages.length;
|
||||||
|
if (n && spaceEl) spaceEl.scrollTop = spaceEl.scrollHeight;
|
||||||
|
});
|
||||||
|
// The open Space is re-read every 30 s while the section is showing.
|
||||||
|
$effect(() => {
|
||||||
|
if (community.section !== "spaces" || !sv || !nodeUp) return;
|
||||||
|
const { owner, space_id } = sv;
|
||||||
|
const t = setInterval(() => void openSpace(owner, space_id), 30_000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
});
|
||||||
|
async function sayInSpace() {
|
||||||
|
const t = spaceText.trim();
|
||||||
|
if (!t || !sv) return;
|
||||||
|
await postToSpace(sv.owner, sv.space_id, t);
|
||||||
|
spaceText = "";
|
||||||
|
}
|
||||||
|
async function makeSpace() {
|
||||||
|
const n = spaceName.trim();
|
||||||
|
if (!n) return;
|
||||||
|
await createSpace(n, "", spacePrivate);
|
||||||
|
spaceName = "";
|
||||||
|
spaceCreating = false;
|
||||||
|
}
|
||||||
|
const whoIs = (m) => (m.is_me ? "you" : m.username ? display(m.username) : fpShort(m.key ?? m.author));
|
||||||
|
const pinnedPreview = $derived(sv?.pinned_uuid ? community.sharePreviews["pin:" + sv.pinned_uuid] : null);
|
||||||
|
$effect(() => {
|
||||||
|
const s0 = sv;
|
||||||
|
if (s0?.pinned_uuid && !community.sharePreviews["pin:" + s0.pinned_uuid]) {
|
||||||
|
untrack(() => sharePreview({ id: "pin:" + s0.pinned_uuid, owner_key: s0.pinned_owner, playlist_uuid: s0.pinned_uuid }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
async function importPinned() {
|
||||||
|
if (!sv?.pinned_uuid) return;
|
||||||
|
const id = await shareImport({ id: "pin:" + sv.pinned_uuid, owner_key: sv.pinned_owner, playlist_uuid: sv.pinned_uuid, name: pinnedPreview?.name ?? "Pinned playlist" });
|
||||||
|
if (id) goToView("playlist", { playlistId: id, label: pinnedPreview?.name ?? "Pinned playlist" });
|
||||||
|
}
|
||||||
const isMe = (u) => !!me && (u ?? "").trim().toLowerCase() === me.trim().toLowerCase();
|
const isMe = (u) => !!me && (u ?? "").trim().toLowerCase() === me.trim().toLowerCase();
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const lk = drawerLink;
|
const lk = drawerLink;
|
||||||
@@ -1005,15 +1060,120 @@
|
|||||||
|
|
||||||
<!-- ── SPACES ─────────────────────────────────────────────────────── -->
|
<!-- ── SPACES ─────────────────────────────────────────────────────── -->
|
||||||
{:else if community.section === "spaces"}
|
{:else if community.section === "spaces"}
|
||||||
<div class="empty tight">
|
<div class="split" style="grid-template-columns:260px 1fr">
|
||||||
<span class="spico"><Icon name="sparkle" size={26} /></span>
|
<div class="col">
|
||||||
<div class="big">No Spaces yet</div>
|
<div class="grp">Your Spaces <span class="n">{community.spaces.length}</span><span class="ln"></span></div>
|
||||||
<div class="sub">
|
{#each community.spaces as sp0 (sp0.id)}
|
||||||
A Space is a persistent SoulStream community on Freenet with its own member keys — invitation only, encrypted
|
<button class="lrow" class:on={sv?.id === sp0.id} onclick={() => openSpace(sp0.owner, sp0.space_id)}>
|
||||||
when private, and never a Soulseek Room in disguise. The research behind it is done; the Space contract itself
|
<span class="spmini">{sp0.name.slice(0, 2).toUpperCase()}</span>
|
||||||
is the next piece of the Freenet track, so nothing here is live yet.
|
<span class="tx"><span class="l1"><span class="nm">{sp0.name}</span></span><span class="l2">{sp0.private ? "private" : "public"} · {sp0.my_role} · epoch {sp0.last_epoch}</span></span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{#if spaceCreating}
|
||||||
|
<form class="panel" style="margin-top:6px" onsubmit={(e) => { e.preventDefault(); void makeSpace(); }}>
|
||||||
|
<div class="field" style="margin-top:0"><label for="sp-name">Name</label><input id="sp-name" bind:value={spaceName} maxlength="64" placeholder="Late Night Ambient" /></div>
|
||||||
|
<label class="swrow" style="margin-top:10px"><button type="button" class="tog" class:on={spacePrivate} role="switch" aria-label="Private" aria-checked={spacePrivate} onclick={() => (spacePrivate = !spacePrivate)}></button><span>{spacePrivate ? "Private — bodies sealed to a key members receive" : "Public — anyone who finds it can read"}</span></label>
|
||||||
|
<div class="btns"><button class="ax-fill sm" type="submit" disabled={!spaceName.trim() || !nodeUp || !key || !!community.busy["space:create"]}>{community.busy["space:create"] ? "Creating…" : "Create"}</button><button class="ax-ghost sm" type="button" onclick={() => (spaceCreating = false)}>Cancel</button></div>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<button class="lrow" style="color:var(--text-secondary)" onclick={() => (spaceCreating = true)} disabled={!nodeUp || !key}>
|
||||||
|
<span class="av" style="border:1px dashed rgba(255,255,255,0.25);display:grid;place-items:center;width:40px;height:40px;border-radius:50%">{@html ""}<Icon name="plus" size={16} /></span>
|
||||||
|
<span class="tx"><span class="l1"><span class="nm">Create a Space</span></span><span class="l2">{key ? "on Freenet, under your ID" : "needs a SoulStream identity"}</span></span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<div class="panel" style="margin-top:14px"><h3>What a Space is</h3><p class="hint">A persistent SoulStream community on Freenet. The owner's signed checkpoint is the roster; a removal is a new epoch, and in a private Space every removal rotates the key. Not a Soulseek Room, and never mirrored to one. Invitations travel sealed through the encrypted inbox — the person you invite needs one.</p></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="chip up" style="margin-top:6px"><Icon name="sparkle" size={10} />beta · not live</span>
|
{#if sv}
|
||||||
|
<div>
|
||||||
|
<div class="sphead">
|
||||||
|
<div class="spico">{sv.name.slice(0, 2).toUpperCase()}</div>
|
||||||
|
<div>
|
||||||
|
<div class="nm">{sv.name} <span class="src space"><Icon name="sparkle" size={10} />Space</span></div>
|
||||||
|
<div class="sub">
|
||||||
|
<span>{#if sv.private}<Icon name="lock" size={12} /> private · sealed{:else}public{/if}</span><span>·</span>
|
||||||
|
<span>{sv.members.length} {sv.members.length === 1 ? "member" : "members"}</span><span>·</span>
|
||||||
|
<span>epoch {sv.epoch}</span><span>·</span>
|
||||||
|
<span>not a Soulseek Room</span>
|
||||||
|
{#if !sv.reachable}<span>·</span><span class="chip warn">not on this node yet</span>{:else}<span>·</span><span class="chip ok"><Icon name="check" size={10} />read from the network</span>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="r" style="margin-left:auto;display:flex;gap:6px">
|
||||||
|
<button class="ax-ghost sm" onclick={() => openSpace(sv.owner, sv.space_id)} disabled={!!community.busy["space:" + sv.space_id]}><Icon name="refresh" size={12} /></button>
|
||||||
|
{#if !spaceOwner}<button class="ax-ghost sm" onclick={() => leaveSpace(sv.owner, sv.space_id)}>Leave</button>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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 === "members"} onclick={() => (spaceTab = "members")}>Members<b>{sv.members.length}</b></button>
|
||||||
|
<button class="subtab" class:on={spaceTab === "about"} onclick={() => (spaceTab = "about")}>About</button>
|
||||||
|
<span class="subtab" style="opacity:0.55" title="The F7 proposal board is the next piece">Proposals · next</span>
|
||||||
|
</div>
|
||||||
|
{#if community.spaceError}<p class="hint warn">{community.spaceError}</p>{/if}
|
||||||
|
{#if spaceTab === "chat"}
|
||||||
|
{#if sv.pinned_uuid}
|
||||||
|
<div class="panel" style="margin-bottom:12px"><h3><Icon name="pin" size={13} /> Pinned playlist {#if pinnedPreview && !pinnedPreview.pending && !pinnedPreview.error}<span class="chip acc"><Icon name="disc" size={10} />revision {pinnedPreview.revision_seq}</span>{/if}</h3>
|
||||||
|
<div class="pinned" style="margin-top:8px">
|
||||||
|
<span class="mosaic-ph"><Icon name="playlist" size={22} /></span>
|
||||||
|
<div>
|
||||||
|
<div class="t">{pinnedPreview?.name ?? "Reading…"}</div>
|
||||||
|
<div class="s">{#if pinnedPreview?.entries}{pinnedPreview.entries.length} tracks · signed by {fpShort(sv.pinned_owner)}{:else if pinnedPreview?.error}{pinnedPreview.error}{:else if pinnedPreview?.withdrawn}withdrawn by its owner{:else}fetching through your node{/if}</div>
|
||||||
|
<div class="btns"><button class="ax-fill sm" onclick={importPinned} disabled={!pinnedPreview?.entries?.length || !!community.busy["import:pin:" + sv.pinned_uuid]}><Icon name="download" size={12} /> Import</button>{#if spaceOwner}<button class="ax-ghost sm" onclick={() => pinToSpace(sv.owner, sv.space_id, null, null)}>Unpin</button>{/if}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if spaceOwner && community.published.some((p) => !p.withdrawn)}
|
||||||
|
<div class="noderow" style="margin:0 0 12px"><span class="hint">Pin one of your public playlists:</span>{#each community.published.filter((p) => !p.withdrawn) as p (p.playlist_id)}<button class="fchip" onclick={() => pinToSpace(sv.owner, sv.space_id, key, p.uuid)}>{p.name}</button>{/each}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="thread" style="height:auto;min-height:360px">
|
||||||
|
<div class="chat" bind:this={spaceEl} style="max-height:420px">
|
||||||
|
{#if !sv.messages.length}<div class="cm sys"><span class="tm"></span><span class="body">Nothing said yet. The last {sv.message_cap} lines are kept; older ones fall away for everyone.</span></div>{/if}
|
||||||
|
{#each sv.messages as m (m.id)}
|
||||||
|
<div class="cm"><span class="tm">{fmtClock(m.created_at * 1000)}</span><span class="body"><span class="whoin" class:mine={m.is_me} class:c={!m.is_me && !!m.username}>{whoIs(m)}</span>{#if m.text != null}{m.text}{:else}<span class="muted"><Icon name="lock" size={10} /> sealed to a key this device does not hold (epoch {m.epoch})</span>{/if}</span></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<form class="compose" onsubmit={(e) => { e.preventDefault(); void sayInSpace(); }}>
|
||||||
|
<div class="qbox grow"><input placeholder={sv.can_post ? `Say something in ${sv.name}` : sv.has_key ? "You are not on the roster" : "No key for this epoch yet"} bind:value={spaceText} disabled={!sv.can_post || !nodeUp} /></div>
|
||||||
|
<span class="via">{sv.private ? "sealed · members only" : "public · signed"} · last {sv.message_cap} kept</span>
|
||||||
|
<button class="send" type="submit" disabled={!sv.can_post || !nodeUp || !spaceText.trim() || !!community.busy["space:post"]} title="Post"><Icon name="send" size={15} /></button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{:else if spaceTab === "members"}
|
||||||
|
<div class="panel">
|
||||||
|
<div class="members">
|
||||||
|
{#each sv.members as m (m.key)}
|
||||||
|
<div class="mem">
|
||||||
|
<button class="avbtn" onclick={() => m.username && (community.drawer = m.username)}><Identicon seed={m.key} size={56} /></button>
|
||||||
|
<span class="nm">{m.is_me ? "you" : m.username ? display(m.username) : fpShort(m.key)}</span>
|
||||||
|
<span class="role" class:own={m.role === "owner"}>{m.role}{#if m.since_epoch > 1} · since {m.since_epoch}{/if}</span>
|
||||||
|
{#if spaceOwner && !m.is_me}<button class="lnk" onclick={() => removeFromSpace(sv.owner, sv.space_id, m.key)} disabled={!!community.busy["space:remove"]}>remove</button>{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if spaceOwner}
|
||||||
|
<form class="inl" style="margin-top:14px" onsubmit={(e) => { e.preventDefault(); const k = spaceInviteKey.trim(); if (k) { void inviteToSpace(sv.owner, sv.space_id, k); spaceInviteKey = ""; } }}>
|
||||||
|
<input placeholder="Invite by SoulStream ID (they need an encrypted inbox)" bind:value={spaceInviteKey} />
|
||||||
|
<button class="ax-fill sm" type="submit" disabled={!spaceInviteKey.trim() || !nodeUp || !!community.busy["space:invite"]}>{community.busy["space:invite"] ? "Inviting…" : "Invite"}</button>
|
||||||
|
</form>
|
||||||
|
{#if community.friends.some((f) => f.state === "friends")}<p class="hint" style="margin-top:8px">Friends: {#each community.friends.filter((f) => f.state === "friends") as f (f.id)}<button class="lnk" onclick={() => (spaceInviteKey = f.persona_key)}>{f.username || fpShort(f.persona_key)}</button> {/each}</p>{/if}
|
||||||
|
{/if}
|
||||||
|
<p class="hint" style="margin-top:12px">Members are their SoulStream identities here — a joined Space is a linked identity. Removing someone is a new epoch{#if sv.private}, and in this private Space it also rotates the key: those who stay receive the new one sealed{/if}.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="panel">
|
||||||
|
<h3>About</h3>
|
||||||
|
<dl class="kv"><dt>Owner</dt><dd class="mono">{sv.owner}</dd><dt>Space id</dt><dd class="mono">{sv.space_id}</dd><dt>Mode</dt><dd>{sv.private ? "private — message bodies sealed to a per-epoch key" : "public — anyone who resolves the contract can read"}</dd><dt>Window</dt><dd>the last {sv.message_cap} messages</dd><dt>Epoch</dt><dd>{sv.epoch}</dd></dl>
|
||||||
|
<p class="hint" style="margin-top:10px">What the network can see: that the Space exists, its owner, its roster keys, and — for a private Space — how many sealed lines it holds. Leaving does not erase copies others hold.</p>
|
||||||
|
{#if sv.description}<p class="desc">{sv.description}</p>{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="empty tight">
|
||||||
|
<span class="spico"><Icon name="sparkle" size={26} /></span>
|
||||||
|
<div class="big">{community.spaces.length ? "Pick a Space" : "No Spaces yet"}</div>
|
||||||
|
<div class="sub">A Space is a persistent SoulStream community on Freenet with its own roster — invitation only, sealed when private, never a Soulseek Room in disguise.</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── YOU ────────────────────────────────────────────────────────── -->
|
<!-- ── YOU ────────────────────────────────────────────────────────── -->
|
||||||
@@ -1694,6 +1854,25 @@
|
|||||||
.emptyshelf b { color: var(--text-secondary); }
|
.emptyshelf b { color: var(--text-secondary); }
|
||||||
.emptyshelf .ax-ghost { margin-left: auto; }
|
.emptyshelf .ax-ghost { margin-left: auto; }
|
||||||
|
|
||||||
|
/* ── spaces ── */
|
||||||
|
.spmini { width: 40px; height: 40px; border-radius: 10px; flex: none; display: grid; place-items: center; background: color-mix(in srgb, var(--space) 16%, transparent); color: #d9a6ff; font-size: 13px; font-weight: 900; }
|
||||||
|
.sphead { display: flex; align-items: center; gap: 16px; padding-bottom: 14px; border-bottom: 1px solid var(--hair); }
|
||||||
|
.spico { width: 56px; height: 56px; border-radius: 16px; display: grid; place-items: center; flex: none; background: color-mix(in srgb, var(--space) 16%, transparent); color: #d9a6ff; font-size: 20px; font-weight: 900; }
|
||||||
|
.sphead .nm { font-size: 20px; font-weight: 800; display: flex; align-items: center; gap: 10px; }
|
||||||
|
.sphead .sub { font-size: 12px; color: var(--text-muted); margin-top: 4px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; max-width: none; }
|
||||||
|
.src.space { background: color-mix(in srgb, var(--space) 14%, transparent); color: #d9a6ff; }
|
||||||
|
.sptabs { display: flex; gap: 2px; margin: 12px 0 14px; align-items: center; }
|
||||||
|
.subtab { padding: 6px 13px; border-radius: 8px; font-size: 12px; font-weight: 800; color: var(--text-secondary); display: inline-flex; gap: 6px; align-items: center; }
|
||||||
|
.subtab.on { background: var(--surf-hi); color: #fff; }
|
||||||
|
.subtab b { font-size: 10px; }
|
||||||
|
.pinned { display: flex; gap: 14px; align-items: center; }
|
||||||
|
.pinned .t { font-size: 15px; font-weight: 800; }
|
||||||
|
.pinned .s { font-size: 12px; color: var(--text-secondary); margin-top: 2px; }
|
||||||
|
.members { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 12px 8px; }
|
||||||
|
.mem { display: flex; flex-direction: column; align-items: center; gap: 5px; text-align: center; }
|
||||||
|
.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.own { color: #d9a6ff; }
|
||||||
/* ── 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. */
|
||||||
|
|||||||
Reference in new issue
Block a user