spaces: a room carries what its members are playing, sealed in the window its chat already uses
This commit is contained in:
1 parent
4ca9ab043b
commit
df06f849e0
6 files changed
+459
-14
No files matched your search
@@ -173,6 +173,8 @@ pub fn run() {
|
||||
spaces::space_remove_member,
|
||||
spaces::space_set_pinned,
|
||||
spaces::space_leave,
|
||||
spaces::space_set_share_listens,
|
||||
spaces::space_report_listen,
|
||||
proposals::space_board,
|
||||
proposals::space_propose,
|
||||
proposals::space_decide,
|
||||
|
||||
+257
-13
@@ -61,6 +61,16 @@ struct SpaceRecord {
|
||||
/// OWNER ONLY: personas we have invited and will accept a join from.
|
||||
#[serde(default)]
|
||||
invited: BTreeMap<String, u64>,
|
||||
/// Whether this device posts what it plays into THIS Space. Per Space and
|
||||
/// off until asked: a listen is as personal as a message, and being in a
|
||||
/// room is not consent to be watched in it.
|
||||
#[serde(default)]
|
||||
share_listens: bool,
|
||||
/// The last listen posted here — what, and when. A reload or a restart
|
||||
/// re-reports whatever is already past the player's threshold, and this
|
||||
/// is the only side that knows what a room was last told.
|
||||
#[serde(default)]
|
||||
last_listen: Option<(String, u64)>,
|
||||
/// When we last told the owner which key we would wear. The answer is one
|
||||
/// sealed message and it can simply fail to land — the owner's descriptor
|
||||
/// may not resolve on a young node — and a member who is never seated has
|
||||
@@ -107,6 +117,21 @@ pub struct MessageView {
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ListenView {
|
||||
pub id: String,
|
||||
pub author: String,
|
||||
pub username: Option<String>,
|
||||
pub is_me: bool,
|
||||
pub created_at: u64,
|
||||
pub epoch: u64,
|
||||
pub title: String,
|
||||
pub artist: String,
|
||||
pub album: String,
|
||||
pub duration_secs: Option<u32>,
|
||||
pub track_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SpaceView {
|
||||
pub id: String,
|
||||
@@ -122,13 +147,67 @@ pub struct SpaceView {
|
||||
pub pinned_owner: Option<String>,
|
||||
pub pinned_uuid: Option<String>,
|
||||
pub messages: Vec<MessageView>,
|
||||
/// The room's trail. Same window as `messages` — a busy room's listens
|
||||
/// push its chat out, which is why posting them is throttled and opt-in.
|
||||
pub listens: Vec<ListenView>,
|
||||
pub can_post: bool,
|
||||
/// Whether THIS device posts what it plays here.
|
||||
pub share_listens: 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,
|
||||
}
|
||||
|
||||
/// What a listen carries. It travels as the message BODY, which the contract
|
||||
/// treats as opaque bytes — the same reason room-local member keys cost no
|
||||
/// contract generation. Sealed with everything else in a private Space.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ListenBody {
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub artist: String,
|
||||
#[serde(default)]
|
||||
pub album: String,
|
||||
#[serde(default)]
|
||||
pub duration_secs: Option<u32>,
|
||||
/// The catalog id when this device knows one, so a listen can be found
|
||||
/// again rather than only read.
|
||||
#[serde(default)]
|
||||
pub track_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Chat has always been the raw UTF-8 of what was typed, and every message
|
||||
/// already on the network is that. A typed body therefore has to be something
|
||||
/// chat can never be: posting trims and refuses empty, so no chat line starts
|
||||
/// with a NUL.
|
||||
const LISTEN_MAGIC: &[u8] = b"\0SSLISTEN1";
|
||||
/// Per field. Well under MAX_BODY_BYTES even when every one is full, so a
|
||||
/// listen can never be the thing that fails to fit.
|
||||
const MAX_LISTEN_FIELD: usize = 200;
|
||||
|
||||
fn encode_listen(l: &ListenBody) -> Result<Vec<u8>, String> {
|
||||
let mut v = LISTEN_MAGIC.to_vec();
|
||||
v.extend_from_slice(&serde_json::to_vec(l).map_err(|e| e.to_string())?);
|
||||
if v.len() > sp::MAX_BODY_BYTES {
|
||||
return Err("that track's details are too long to post".into());
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// A decoded body: a listen, or the chat line it has always been.
|
||||
enum Body {
|
||||
Listen(ListenBody),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
fn decode_body(bytes: &[u8]) -> Option<Body> {
|
||||
match bytes.strip_prefix(LISTEN_MAGIC) {
|
||||
Some(rest) => serde_json::from_slice(rest).ok().map(Body::Listen),
|
||||
None => String::from_utf8(bytes.to_vec()).ok().map(Body::Text),
|
||||
}
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -298,6 +377,15 @@ fn view_of(app: &tauri::AppHandle, rec: &SpaceRecord, state: &sp::SpaceState, me
|
||||
let key_for = |epoch: u64| -> Option<[u8; 32]> {
|
||||
rec.keys.get(&epoch.to_string()).and_then(|k| key_of(k).ok())
|
||||
};
|
||||
// The body as it was written. None means sealed to an epoch key this
|
||||
// device never received — which is a state to SHOW, not to guess past.
|
||||
let plain = |m: &sp::Message| -> Option<Vec<u8>> {
|
||||
if rec.private {
|
||||
key_for(m.epoch).and_then(|k| sp::open_body(&k, m))
|
||||
} else {
|
||||
Some(m.body.clone())
|
||||
}
|
||||
};
|
||||
let Some(c) = &state.checkpoint else {
|
||||
return SpaceView {
|
||||
id: record_id(&rec.owner, &rec.space_id),
|
||||
@@ -313,7 +401,9 @@ fn view_of(app: &tauri::AppHandle, rec: &SpaceRecord, state: &sp::SpaceState, me
|
||||
pinned_owner: None,
|
||||
pinned_uuid: None,
|
||||
messages: vec![],
|
||||
listens: vec![],
|
||||
can_post: false,
|
||||
share_listens: rec.share_listens,
|
||||
has_key: false,
|
||||
reachable,
|
||||
};
|
||||
@@ -355,13 +445,31 @@ fn view_of(app: &tauri::AppHandle, rec: &SpaceRecord, state: &sp::SpaceState, me
|
||||
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 {
|
||||
// A body we cannot open stays in the chat as the sealed line it
|
||||
// has always been: without the key there is no way to know it was
|
||||
// a listen, and guessing would quietly drop it from both lists.
|
||||
.filter(|m| !matches!(plain(m).as_deref().and_then(decode_body), Some(Body::Listen(_))))
|
||||
.map(|m| MessageView {
|
||||
id: b58(&m.msg_id),
|
||||
author: b58(&m.author),
|
||||
username: slsk_link::username_for_key(
|
||||
app,
|
||||
rec.roster.get(&b58(&m.author)).map(String::as_str).unwrap_or(&b58(&m.author)),
|
||||
),
|
||||
is_me: &m.author == me,
|
||||
created_at: m.created_at,
|
||||
epoch: m.epoch,
|
||||
text: plain(m).as_deref().and_then(decode_body).and_then(|b| match b {
|
||||
Body::Text(t) => Some(t),
|
||||
Body::Listen(_) => None,
|
||||
}),
|
||||
})
|
||||
.collect(),
|
||||
listens: state
|
||||
.messages
|
||||
.iter()
|
||||
.filter_map(|m| match plain(m).as_deref().and_then(decode_body) {
|
||||
Some(Body::Listen(l)) => Some(ListenView {
|
||||
id: b58(&m.msg_id),
|
||||
author: b58(&m.author),
|
||||
username: slsk_link::username_for_key(
|
||||
@@ -371,11 +479,17 @@ fn view_of(app: &tauri::AppHandle, rec: &SpaceRecord, state: &sp::SpaceState, me
|
||||
is_me: &m.author == me,
|
||||
created_at: m.created_at,
|
||||
epoch: m.epoch,
|
||||
text,
|
||||
}
|
||||
title: l.title,
|
||||
artist: l.artist,
|
||||
album: l.album,
|
||||
duration_secs: l.duration_secs,
|
||||
track_id: l.track_id,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
can_post: is_member && has_key,
|
||||
share_listens: rec.share_listens,
|
||||
has_key,
|
||||
reachable,
|
||||
}
|
||||
@@ -448,6 +562,8 @@ pub async fn create(app: &tauri::AppHandle, name: &str, description: &str, priva
|
||||
room_secret: None,
|
||||
roster: BTreeMap::new(),
|
||||
invited: BTreeMap::new(),
|
||||
share_listens: false,
|
||||
last_listen: None,
|
||||
join_sent_at: 0,
|
||||
};
|
||||
let _ledger = ledger::hold(LEDGER_FILE).await;
|
||||
@@ -495,9 +611,6 @@ pub async fn open(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> R
|
||||
}
|
||||
|
||||
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());
|
||||
@@ -505,6 +618,15 @@ pub async fn post(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, text
|
||||
if text.len() > sp::MAX_BODY_BYTES {
|
||||
return Err(format!("over {} bytes", sp::MAX_BODY_BYTES));
|
||||
}
|
||||
post_body(app, owner_b58, space_b58, text.as_bytes().to_vec()).await
|
||||
}
|
||||
|
||||
/// Everything a member puts in the window goes through here: chat, and the
|
||||
/// listens that ride the same sealed body.
|
||||
async fn post_body(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, body: Vec<u8>) -> Result<(), String> {
|
||||
let me = my_key(app).await?;
|
||||
let owner = key_of(owner_b58)?;
|
||||
let space_id = id_of(space_b58)?;
|
||||
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")?;
|
||||
let (worn, room, epoch_key) = {
|
||||
@@ -531,7 +653,7 @@ pub async fn post(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, text
|
||||
msg_id: rand16(),
|
||||
author: worn,
|
||||
created_at: now_secs(),
|
||||
body: text.as_bytes().to_vec(),
|
||||
body,
|
||||
sig: vec![],
|
||||
};
|
||||
if let Some(key) = epoch_key {
|
||||
@@ -702,6 +824,71 @@ pub async fn set_pinned(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str
|
||||
open(app, owner_b58, space_b58).await
|
||||
}
|
||||
|
||||
/// Turn posting-what-I-play on or off for one Space. Nothing is published
|
||||
/// by flipping it; it only decides what the next track does.
|
||||
pub async fn set_share_listens(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, on: bool) -> Result<SpaceView, String> {
|
||||
{
|
||||
let _ledger = ledger::hold(LEDGER_FILE).await;
|
||||
let mut l = load(app);
|
||||
let rec = l.spaces.get_mut(&record_id(owner_b58, space_b58)).ok_or("not a Space this device knows")?;
|
||||
rec.share_listens = on;
|
||||
save(app, &l)?;
|
||||
}
|
||||
open(app, owner_b58, space_b58).await
|
||||
}
|
||||
|
||||
/// The frontend played something through. Which Spaces hear about it is this
|
||||
/// side's business — the player knows the track, the ledger knows the rooms.
|
||||
///
|
||||
/// Best-effort per Space and deliberately quiet: a listen is not worth an
|
||||
/// error in front of someone who is only listening to music. A Space we
|
||||
/// cannot post to (no key for this epoch, not seated, node down) is skipped.
|
||||
pub async fn report_listen(app: &tauri::AppHandle, l: ListenBody) -> Result<u32, String> {
|
||||
let listen = ListenBody {
|
||||
title: cap(l.title.trim(), MAX_LISTEN_FIELD),
|
||||
artist: cap(l.artist.trim(), MAX_LISTEN_FIELD),
|
||||
album: cap(l.album.trim(), MAX_LISTEN_FIELD),
|
||||
duration_secs: l.duration_secs,
|
||||
track_id: l.track_id.filter(|t| !t.is_empty()).map(|t| cap(&t, MAX_LISTEN_FIELD)),
|
||||
};
|
||||
if listen.title.is_empty() {
|
||||
return Err("a listen needs a title".into());
|
||||
}
|
||||
let body = encode_listen(&listen)?;
|
||||
// You cannot have listened to the same track twice inside one play of it,
|
||||
// so a repeat that soon is the player saying it again — a reload, a
|
||||
// restart, a seek back — and not a second listen. Where the length is
|
||||
// unknown, ten minutes stands in.
|
||||
let key = format!("{}|{}", listen.title, listen.artist);
|
||||
let window = listen.duration_secs.map(u64::from).unwrap_or(600).max(60);
|
||||
let now = now_secs();
|
||||
let rooms: Vec<(String, String)> = load(app)
|
||||
.spaces
|
||||
.values()
|
||||
.filter(|r| r.share_listens)
|
||||
.filter(|r| !r.last_listen.as_ref().is_some_and(|(k, at)| k == &key && now.saturating_sub(*at) < window))
|
||||
.map(|r| (r.owner.clone(), r.space_id.clone()))
|
||||
.collect();
|
||||
let mut posted = Vec::new();
|
||||
for (owner, space) in rooms {
|
||||
match post_body(app, &owner, &space, body.clone()).await {
|
||||
Ok(()) => posted.push(record_id(&owner, &space)),
|
||||
Err(e) => log::info!("spaces: no listen into {space} ({e})"),
|
||||
}
|
||||
}
|
||||
if !posted.is_empty() {
|
||||
let _ledger = ledger::hold(LEDGER_FILE).await;
|
||||
let mut l = load(app);
|
||||
for rid in &posted {
|
||||
if let Some(rec) = l.spaces.get_mut(rid) {
|
||||
rec.last_listen = Some((key.clone(), now));
|
||||
}
|
||||
}
|
||||
save(app, &l)?;
|
||||
}
|
||||
Ok(posted.len() as u32)
|
||||
}
|
||||
|
||||
pub async fn leave(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<(), String> {
|
||||
let me = my_key(app).await?;
|
||||
let owner = key_of(owner_b58)?;
|
||||
@@ -758,6 +945,8 @@ pub(crate) async fn on_inbox_invite(app: &tauri::AppHandle, sender: [u8; 32], in
|
||||
room_secret: None,
|
||||
roster: BTreeMap::new(),
|
||||
invited: BTreeMap::new(),
|
||||
share_listens: false,
|
||||
last_listen: None,
|
||||
join_sent_at: 0,
|
||||
});
|
||||
rec.name = cap(&inv.name, sp::MAX_NAME_BYTES);
|
||||
@@ -970,6 +1159,26 @@ pub async fn space_leave(app: tauri::AppHandle, owner: String, space: String) ->
|
||||
leave(&app, &owner, &space).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn space_set_share_listens(app: tauri::AppHandle, owner: String, space: String, on: bool) -> Result<SpaceView, String> {
|
||||
set_share_listens(&app, &owner, &space, on).await
|
||||
}
|
||||
|
||||
/// Called by the player when a track has actually been listened to — not on
|
||||
/// every skip. Returns how many Spaces heard it, which is 0 whenever nothing
|
||||
/// is sharing, and that is the normal case.
|
||||
#[tauri::command]
|
||||
pub async fn space_report_listen(
|
||||
app: tauri::AppHandle,
|
||||
title: String,
|
||||
artist: String,
|
||||
album: String,
|
||||
duration_secs: Option<u32>,
|
||||
track_id: Option<String>,
|
||||
) -> Result<u32, String> {
|
||||
report_listen(&app, ListenBody { title, artist, album, duration_secs, track_id }).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -987,10 +1196,45 @@ mod tests {
|
||||
room_secret: secret.map(|s| b58(&s)),
|
||||
roster: BTreeMap::new(),
|
||||
invited: BTreeMap::new(),
|
||||
share_listens: false,
|
||||
last_listen: None,
|
||||
join_sent_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The window carries two kinds of body now. Chat has always been raw
|
||||
/// UTF-8, and every line already on the network is that, so the typed one
|
||||
/// has to be something a typed line can never be.
|
||||
#[test]
|
||||
fn a_listen_is_told_apart_from_a_chat_line() {
|
||||
let l = ListenBody {
|
||||
title: "Poa Alpina".into(),
|
||||
artist: "Biosphere".into(),
|
||||
album: "Substrata".into(),
|
||||
duration_secs: Some(253),
|
||||
track_id: None,
|
||||
};
|
||||
let bytes = encode_listen(&l).unwrap();
|
||||
match decode_body(&bytes) {
|
||||
Some(Body::Listen(got)) => {
|
||||
assert_eq!(got.title, "Poa Alpina");
|
||||
assert_eq!(got.duration_secs, Some(253));
|
||||
}
|
||||
_ => panic!("a listen did not come back as one"),
|
||||
}
|
||||
// Everything that is not prefixed stays the chat it was, including a
|
||||
// line that talks about the magic or looks like JSON.
|
||||
for line in ["hello", "SSLISTEN1", r#"{"title":"nice try"}"#, "0SSLISTEN1{}"] {
|
||||
match decode_body(line.as_bytes()) {
|
||||
Some(Body::Text(t)) => assert_eq!(t, line),
|
||||
_ => panic!("{line:?} was not read as chat"),
|
||||
}
|
||||
}
|
||||
// A body sealed to a key we lack never reaches here, and a truncated
|
||||
// one is not a listen we can show.
|
||||
assert!(matches!(decode_body(&bytes[..bytes.len() - 4]), None));
|
||||
}
|
||||
|
||||
/// Which key we wear where. An owner is their persona — the Space's
|
||||
/// address is derived from it, so there is nothing to hide there — and a
|
||||
/// member is the key minted for that Space and no other.
|
||||
|
||||
Reference in new issue
Block a user