//! The proposal board (F7 → the app). A Space's pinned playlist gets a //! multi-writer board beside it, keyed by (playlist owner, uuid). Members //! propose bounded edits — add, remove, move — against the EXACT revision //! they were looking at; the playlist's owner accepts or rejects, and every //! decision is a signed line in the audit trail. Accepting applies the ops //! to the owner's local playlist and publishes it as the next revision; the //! decision then names that revision's hash. The canonical playlist never //! lives in here. //! //! The roster is the Space's: when the playlist owner opens the board and //! the contributors differ from the Space's members, a new epoch is signed. //! Dropping a member is revocation — their pending proposals leave the board //! on every node, deterministically. //! //! The contract is a bundled commitment (`assets/proposal_contract.wasm`, //! built from crates/proposal-contract; its code hash is pinned by a test). use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use freenet_adapter::FreenetAdapter; use library::{NewPlaylistItem, PlaylistItem}; use persona_state::playlist as pl; use persona_state::proposals as pr; use serde::{Deserialize, Serialize}; use tauri::{Emitter, Manager}; use crate::persona; use crate::pipeline::LibraryState; use crate::playlists; use crate::shared_playlists::{self, Published}; use crate::inbox::{self, Cargo, ProposalPing}; use crate::ledger; use crate::slsk_link; use crate::spaces; use crate::state::AppState; const BOARD_WASM: &[u8] = include_bytes!("../assets/proposal_contract.wasm"); /// A proposal landed on a board of ours. The UI answers with a toast. pub const EVENT: &str = "community:proposal"; #[derive(Default, Clone, Serialize, Deserialize)] struct BoardRecord { #[serde(default)] last_epoch: u64, #[serde(default)] last_seq: u64, } #[derive(Default, Serialize, Deserialize)] struct Ledger { #[serde(default)] boards: BTreeMap, } // ---------------------------------------------------------------- views #[derive(Debug, Serialize)] pub struct BoardEntry { pub entry_id: String, pub position: u32, pub title: String, pub artist: String, pub album: String, pub duration_secs: Option, } #[derive(Debug, Serialize)] pub struct OpView { /// "add" | "remove" | "move" pub kind: String, pub position: Option, pub entry_id: Option, pub title: String, pub artist: String, /// False when a remove/move names an entry the current revision no /// longer has (a stale proposal). pub known: bool, } #[derive(Debug, Serialize)] pub struct ProposalView { pub id: String, pub author: String, pub username: Option, pub is_me: bool, pub created_at: u64, pub note: String, pub base: String, /// Its base is not the current revision: the owner can only reject it /// and ask for a re-submit. pub stale: bool, pub ops: Vec, } #[derive(Debug, Serialize)] pub struct DecisionView { pub proposal_id: String, pub accept: bool, pub seq: u64, pub reason: String, pub result_hash: Option, } #[derive(Debug, Serialize)] pub struct ContributorView { pub key: String, pub username: Option, pub is_me: bool, } #[derive(Debug, Serialize)] pub struct BoardView { pub owner: String, pub uuid: String, pub name: String, pub revision_seq: u64, /// The current revision's content hash (b58) — what a proposal binds to. pub base: String, pub withdrawn: bool, pub entries: Vec, /// The board contract exists on the network. pub reachable: bool, pub epoch: u64, pub contributors: Vec, pub i_am_owner: bool, pub can_propose: bool, pub proposals: Vec, /// Newest first. pub decisions: Vec, pub pending_mine: u32, } /// One op as the composer sends it. #[derive(Debug, Deserialize)] #[serde(tag = "kind", rename_all = "lowercase")] pub enum OpInput { Add { position: u32, title: String, artist: String, #[serde(default)] album: String, #[serde(default)] duration_secs: Option, #[serde(default)] track_id: Option, /// The composer's handle for this row ("new:3"), so a later remove /// or move in the same proposal can name a track it just added. #[serde(default)] token: Option, }, Remove { entry_id: String, }, Move { entry_id: String, to_position: u32, }, } // ---------------------------------------------------------------- helpers 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 b58(bytes: &[u8]) -> String { bs58::encode(bytes).into_string() } fn key_of(s: &str) -> Result<[u8; 32], String> { let v = bs58::decode(s.trim()).into_vec().map_err(|_| "not a base58 key")?; v.try_into().map_err(|_| "a key is 32 bytes".to_string()) } fn id_of(s: &str) -> Result<[u8; 16], String> { let v = bs58::decode(s.trim()).into_vec().map_err(|_| "not a base58 id")?; v.try_into().map_err(|_| "an id is 16 bytes".to_string()) } fn cap(s: &str, max: usize) -> String { if s.len() <= max { return s.to_string(); } let mut end = max; while !s.is_char_boundary(end) { end -= 1; } s[..end].to_string() } fn board_id(owner: &[u8; 32], uuid: &[u8; 16]) -> String { format!("{}/{}", b58(owner), b58(uuid)) } /// Both the file the ledger lives in and the key its write lock is taken on. const LEDGER_FILE: &str = "boards.json"; fn path(app: &tauri::AppHandle) -> Result { Ok(crate::paths::app_data(app).map_err(|e| e.to_string())?.join("freenet").join(LEDGER_FILE)) } fn load(app: &tauri::AppHandle) -> Ledger { path(app) .ok() .and_then(|p| std::fs::read(p).ok()) .and_then(|b| serde_json::from_slice(&b).ok()) .unwrap_or_default() } fn save(app: &tauri::AppHandle, l: &Ledger) -> Result<(), String> { let p = path(app)?; if let Some(dir) = p.parent() { std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; } std::fs::write(&p, serde_json::to_vec_pretty(l).map_err(|e| e.to_string())?).map_err(|e| e.to_string()) } async fn my_key(app: &tauri::AppHandle) -> Result<[u8; 32], String> { let adapter = app.state::().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) -> Result, String> { let adapter = app.state::().freenet.clone(); persona::sign(&adapter, pr::SIGN_DOMAIN, payload).await } async fn fetch_board(app: &tauri::AppHandle, owner: &[u8; 32], uuid: &[u8; 16]) -> Result<(pr::BoardState, bool), String> { let adapter = app.state::().freenet.clone(); let ids = FreenetAdapter::contract_ids(BOARD_WASM.to_vec(), &pr::make_params(owner, uuid)); match adapter.contract_get(&ids.instance, false).await { Ok(bytes) => Ok((pr::merge_board(owner, uuid, &[&bytes]), true)), Err(e) if e.to_string().contains("not found") => Ok((pr::BoardState::default(), false)), Err(e) => Err(e.to_string()), } } async fn offer_board(app: &tauri::AppHandle, owner: &[u8; 32], uuid: &[u8; 16], state: &pr::BoardState, first: bool) -> Result<(), String> { let adapter = app.state::().freenet.clone(); let bytes = bincode::serialize(state).map_err(|e| e.to_string())?; let params = pr::make_params(owner, uuid); if first { adapter.contract_put(BOARD_WASM.to_vec(), ¶ms, bytes, false).await.map(|_| ()) } else { adapter.get_then_update(BOARD_WASM.to_vec(), ¶ms, bytes).await.map(|_| ()) } .map_err(|e| e.to_string()) } fn partial(owner: &[u8; 32], uuid: &[u8; 16], authority: Option, proposals: Vec, decisions: Vec) -> pr::BoardState { pr::BoardState { schema_version: pr::SCHEMA_VERSION, owner: *owner, playlist_uuid: *uuid, authority, proposals, decisions } } /// What a Space pins, plus its roster — the board's owner and contributors. struct Pinned { owner: [u8; 32], uuid: [u8; 16], members: Vec<[u8; 32]>, } async fn pinned_of(app: &tauri::AppHandle, space_owner_b58: &str, space_b58: &str) -> Result { let owner = key_of(space_owner_b58)?; let space_id = id_of(space_b58)?; let (state, _) = spaces::fetch_state(app, &owner, &space_id).await?; let c = state.checkpoint.ok_or("the Space has not reached this node yet")?; let p = c.pinned.ok_or("this Space has no pinned playlist — a board lives beside one")?; Ok(Pinned { owner: p.owner, uuid: p.uuid, members: c.members.iter().map(|m| m.key).collect() }) } type Current = (pl::ManifestV1, [u8; 32]); #[allow(clippy::too_many_arguments)] fn view_of( app: &tauri::AppHandle, pin: &Pinned, current: Option<&Current>, board: &pr::BoardState, me: &[u8; 32], worn: &[u8; 32], space: (&str, &str), reachable: bool, ) -> BoardView { // A room key has a name only on the device that seated it — the Space's // owner. Everyone else sees the key, which is what unlinkable means. let name_of = |k: &[u8; 32]| -> Option { let b = b58(k); let persona = spaces::persona_behind(app, space.0, space.1, &b).unwrap_or(b); slsk_link::username_for_key(app, &persona) }; let entries: Vec = current .map(|(m, _)| { m.entries .iter() .map(|e| BoardEntry { entry_id: b58(&e.entry_id), position: e.position, title: e.recording.title.clone(), artist: e.recording.artists.join(", "), album: e.recording.album.clone(), duration_secs: e.recording.duration_ms.map(|ms| ms / 1000), }) .collect() }) .unwrap_or_default(); // A remove or move may name a row the same proposal added a step earlier. let named = |p: &pr::Proposal, id: &[u8; 16]| -> Option<(String, String)> { current .and_then(|(m, _)| m.entries.iter().find(|e| &e.entry_id == id)) .or_else(|| { p.ops.iter().find_map(|op| match op { pr::ProposalOp::Add { entry, .. } if &entry.entry_id == id => Some(entry), _ => None, }) }) .map(|e| (e.recording.title.clone(), e.recording.artists.join(", "))) }; let roster: Vec<[u8; 32]> = board.authority.as_ref().map(|a| a.contributors.clone()).unwrap_or_default(); let proposals = board .proposals .iter() .map(|p| ProposalView { id: b58(&p.proposal_id), author: b58(&p.author), username: name_of(&p.author), is_me: &p.author == worn, created_at: p.created_at, note: p.note.clone(), base: b58(&p.base_revision_hash), stale: current.map(|(_, h)| h != &p.base_revision_hash).unwrap_or(true), ops: p .ops .iter() .map(|op| match op { pr::ProposalOp::Add { position, entry } => OpView { kind: "add".into(), position: Some(*position), entry_id: None, title: entry.recording.title.clone(), artist: entry.recording.artists.join(", "), known: true, }, pr::ProposalOp::Remove { entry_id } => { let n = named(p, entry_id); OpView { kind: "remove".into(), position: None, entry_id: Some(b58(entry_id)), title: n.as_ref().map(|x| x.0.clone()).unwrap_or_default(), artist: n.as_ref().map(|x| x.1.clone()).unwrap_or_default(), known: n.is_some(), } } pr::ProposalOp::Move { entry_id, to_position } => { let n = named(p, entry_id); OpView { kind: "move".into(), position: Some(*to_position), entry_id: Some(b58(entry_id)), title: n.as_ref().map(|x| x.0.clone()).unwrap_or_default(), artist: n.as_ref().map(|x| x.1.clone()).unwrap_or_default(), known: n.is_some(), } } }) .collect(), }) .collect(); let decisions = board .decisions .iter() .rev() .map(|d| DecisionView { proposal_id: b58(&d.proposal_id), accept: d.accept, seq: d.seq, reason: d.reason.clone(), result_hash: d.result_revision_hash.as_ref().map(|h| b58(h)), }) .collect(); BoardView { owner: b58(&pin.owner), uuid: b58(&pin.uuid), name: current.map(|(m, _)| m.name.clone()).unwrap_or_default(), revision_seq: current.map(|(m, _)| m.revision_seq).unwrap_or(0), base: current.map(|(_, h)| b58(h)).unwrap_or_default(), withdrawn: current.is_none(), entries, reachable, epoch: board.authority.as_ref().map(|a| a.epoch).unwrap_or(0), contributors: roster .iter() .map(|k| ContributorView { key: b58(k), username: name_of(k), is_me: k == worn }) .collect(), i_am_owner: &pin.owner == me, can_propose: reachable && current.is_some() && roster.contains(worn), proposals, decisions, // `worn`, not `me`: on someone else's Space we author under a // room-local key, so counting against the persona counts nothing. pending_mine: board.proposals.iter().filter(|p| &p.author == worn).count() as u32, } } // ---------------------------------------------------------------- actions /// Read the board beside a Space's pinned playlist. The playlist's owner /// also brings the roster in line with the Space's members here — a new /// signed epoch whenever they differ. pub async fn open(app: &tauri::AppHandle, space_owner_b58: &str, space_b58: &str) -> Result { let me = my_key(app).await?; let pin = pinned_of(app, space_owner_b58, space_b58).await?; let current = shared_playlists::current_revision(app, &pin.owner, &pin.uuid).await?; let (mut board, mut exists) = fetch_board(app, &pin.owner, &pin.uuid).await?; if pin.owner == me { let want: Vec<[u8; 32]> = pin.members.iter().copied().take(pr::MAX_CONTRIBUTORS).collect(); let have: Vec<[u8; 32]> = board.authority.as_ref().map(|a| a.contributors.clone()).unwrap_or_default(); let same = want.iter().collect::>() == have.iter().collect::>(); if !same { let _ledger = ledger::hold(LEDGER_FILE).await; let bid = board_id(&pin.owner, &pin.uuid); let mut l = load(app); let rec = l.boards.get(&bid).cloned().unwrap_or_default(); let epoch = board.authority.as_ref().map(|a| a.epoch).unwrap_or(0).max(rec.last_epoch) + 1; let mut c = pr::AuthorityCheckpoint { epoch, contributors: want, sig: vec![] }; c.sig = sign(app, pr::sign_payload(&pr::checkpoint_sig_body(&c))).await?; let offer = partial(&pin.owner, &pin.uuid, Some(c), vec![], vec![]); offer_board(app, &pin.owner, &pin.uuid, &offer, !exists).await?; l.boards.insert(bid, BoardRecord { last_epoch: epoch, ..rec }); save(app, &l)?; let a = bincode::serialize(&board).map_err(|e| e.to_string())?; let b = bincode::serialize(&offer).map_err(|e| e.to_string())?; board = pr::merge_board(&pin.owner, &pin.uuid, &[&a, &b]); exists = true; log::info!("proposals: roster epoch {} for board {}", epoch, b58(&pin.uuid)); } } let (worn, _) = spaces::worn_identity(app, space_owner_b58, space_b58, &me); Ok(view_of(app, &pin, current.as_ref(), &board, &me, &worn, (space_owner_b58, space_b58), exists)) } /// A member proposes: the ops are dry-run against the exact base first, so /// a broken composition never leaves this device. pub async fn propose(app: &tauri::AppHandle, space_owner_b58: &str, space_b58: &str, base_b58: &str, ops: Vec, note: &str) -> Result { let me = my_key(app).await?; let pin = pinned_of(app, space_owner_b58, space_b58).await?; if ops.is_empty() { return Err("nothing proposed yet".into()); } if ops.len() > pr::MAX_OPS_PER_PROPOSAL { return Err(format!("over {} changes in one proposal", pr::MAX_OPS_PER_PROPOSAL)); } let (manifest, hash) = shared_playlists::current_revision(app, &pin.owner, &pin.uuid) .await? .ok_or("the owner withdrew this playlist")?; if b58(&hash) != base_b58.trim() { return Err("the playlist moved to a newer revision while you were composing — reload the board and try again".into()); } let (board, exists) = fetch_board(app, &pin.owner, &pin.uuid).await?; if !exists { return Err("the owner has not opened this board yet".into()); } // The board's roster is the Space's roster, so we appear on it as the key // we wear there — a room-local one unless the Space is ours. let (worn, room) = spaces::worn_identity(app, space_owner_b58, space_b58, &me); let auth = board.authority.as_ref().ok_or("the board has no roster yet")?; if !auth.contributors.contains(&worn) { return Err("you are not on this board's roster — the owner refreshes it from the Space's members".into()); } let mut fresh: BTreeMap = BTreeMap::new(); let resolve = |fresh: &BTreeMap, s: &str| -> Result<[u8; 16], String> { match fresh.get(s.trim()) { Some(id) => Ok(*id), None => id_of(s), } }; let mut pops = Vec::with_capacity(ops.len()); for op in ops { pops.push(match op { OpInput::Add { position, title, artist, album, duration_secs, track_id, token } => { let title = cap(title.trim(), pl::MAX_FIELD_BYTES); let artist = cap(artist.trim(), pl::MAX_FIELD_BYTES); if title.is_empty() { return Err("an added track needs a title".into()); } let entry_id = rand16(); if let Some(t) = token { fresh.insert(t, entry_id); } pr::ProposalOp::Add { position, entry: pl::EntryV1 { entry_id, position, recording: pl::RecordingRef { musicbrainz_recording_id: None, isrcs: vec![], deezer_id: track_id.filter(|t| !t.is_empty()), apple_music_id: None, title: title.clone(), artists: vec![artist.clone()], album: cap(album.trim(), pl::MAX_FIELD_BYTES), duration_ms: duration_secs.map(|s| s.saturating_mul(1000)), release_year: None, }, note: None, slsk_hint: Some(cap(&format!("{artist} {title}"), pl::MAX_FIELD_BYTES)), }, } } OpInput::Remove { entry_id } => pr::ProposalOp::Remove { entry_id: resolve(&fresh, &entry_id)? }, OpInput::Move { entry_id, to_position } => pr::ProposalOp::Move { entry_id: resolve(&fresh, &entry_id)?, to_position }, }); } let mut p = pr::Proposal { proposal_id: rand16(), author: worn, authority_epoch: auth.epoch, base_revision_hash: hash, ops: pops, note: cap(note.trim(), pl::MAX_DESC_BYTES), created_at: now_secs(), sig: vec![], }; pr::apply_proposal(&manifest, &hash, &p).map_err(|e| e.to_string())?; p.sig = match room { // sign_payload() first: everything here signs blake3(body), and the // verifier hashes before it checks. Handing over the raw body makes a // signature that validates nowhere. Some(secret) => { persona_state::sign_local(pr::SIGN_DOMAIN, &secret, &pr::sign_payload(&pr::proposal_sig_body(&p))) } None => sign(app, pr::sign_payload(&pr::proposal_sig_body(&p))).await?, }; if !pr::proposal_valid(&p) { return Err("the proposal did not validate (a size or field cap)".into()); } let pid = p.proposal_id; offer_board(app, &pin.owner, &pin.uuid, &partial(&pin.owner, &pin.uuid, None, vec![p], vec![]), false).await?; log::info!("proposals: offered a proposal to board {}", b58(&pin.uuid)); // The board only gets read on open and Refresh, so tell the owner it is // there. Best-effort by design: it needs their published inbox, and the // proposal stands on the board whether or not this lands. if pin.owner != me { let ping = Cargo::ProposalPing(ProposalPing { playlist_uuid: pin.uuid, name: manifest.name.clone(), proposal_id: pid, }); if let Err(e) = inbox::send(app, &pin.owner, &ping).await { log::info!("proposals: no ping to the owner ({e}) — the board still holds it"); } } open(app, space_owner_b58, space_b58).await } /// The owner decides. Accepting brings the local playlist to the proposed /// order, publishes it as the next revision, and the decision names that /// revision; rejecting records the reason. Either way it is signed. pub async fn decide(app: &tauri::AppHandle, space_owner_b58: &str, space_b58: &str, proposal_b58: &str, accept: bool, reason: &str) -> Result { let me = my_key(app).await?; let pin = pinned_of(app, space_owner_b58, space_b58).await?; if pin.owner != me { return Err("only the playlist's owner decides".into()); } let pid = id_of(proposal_b58)?; let (board, exists) = fetch_board(app, &pin.owner, &pin.uuid).await?; if !exists { return Err("the board has not reached this node".into()); } let p = board.proposals.iter().find(|p| p.proposal_id == pid).cloned().ok_or("that proposal is no longer pending")?; let ledger = ledger::hold(LEDGER_FILE).await; let bid = board_id(&pin.owner, &pin.uuid); let mut l = load(app); let rec = l.boards.get(&bid).cloned().unwrap_or_default(); let seq = board.decisions.iter().map(|d| d.seq).max().unwrap_or(0).max(rec.last_seq) + 1; let mut reason = cap(reason.trim(), pr::MAX_REASON_BYTES); let result = if accept { let (manifest, hash) = shared_playlists::current_revision(app, &pin.owner, &pin.uuid) .await? .ok_or("the playlist is withdrawn — publish it again before accepting")?; let entries = pr::apply_proposal(&manifest, &hash, &p).map_err(|e| e.to_string())?; let published = apply_locally(app, &pin.uuid, &manifest, entries).await?; if reason.is_empty() { reason = format!("applied as revision {}", published.revision_seq); } Some(key_of(published.current_hash.as_deref().unwrap_or_default())?) } else { if reason.is_empty() { reason = "declined".into(); } None }; let mut d = pr::Decision { proposal_id: pid, accept, seq, reason, result_revision_hash: result, sig: vec![] }; d.sig = sign(app, pr::sign_payload(&pr::decision_sig_body(&d))).await?; offer_board(app, &pin.owner, &pin.uuid, &partial(&pin.owner, &pin.uuid, None, vec![], vec![d]), false).await?; l.boards.insert(bid, BoardRecord { last_seq: seq, ..rec }); save(app, &l)?; log::info!("proposals: decision {} ({}) on board {}", seq, if accept { "accept" } else { "reject" }, b58(&pin.uuid)); // open() takes this same ledger; the write is done, so let go first. drop(ledger); open(app, space_owner_b58, space_b58).await } /// Sealed cargo from inbox.rs: someone proposed against a playlist of ours. /// Nothing is stored — the board is the record; this only raises the toast. pub(crate) fn on_inbox_proposal(app: &tauri::AppHandle, sender: [u8; 32], ping: ProposalPing) { let key = b58(&sender); let who = slsk_link::username_for_key(app, &key).unwrap_or(key); log::info!("proposals: {who} proposed a change to {:?}", ping.name); let _ = app.emit( EVENT, ProposalPingEvent { from: who, playlist: ping.name, proposal_id: b58(&ping.proposal_id) }, ); } #[derive(Debug, Clone, Serialize)] struct ProposalPingEvent { from: String, playlist: String, proposal_id: String, } /// Bring the owner's local playlist to `entries`, then publish it as the /// next revision. Refused when the local copy has drifted from the base /// revision — the exact-base rule holds on this side too. async fn apply_locally(app: &tauri::AppHandle, uuid: &[u8; 16], base: &pl::ManifestV1, entries: Vec) -> Result { let published = shared_playlists::published_for_uuid(app, &b58(uuid)).ok_or("this device did not publish that playlist")?; let playlist_id = published.playlist_id; let lib = app.state::(); let l = lib.library.clone(); let items: Vec = tauri::async_runtime::spawn_blocking(move || l.playlist_items(playlist_id).map_err(|e| e.to_string())) .await .map_err(|e| e.to_string())??; let local_ids: Vec<[u8; 16]> = items.iter().map(|it| shared_playlists::entry_id_for(uuid, it.id)).collect(); let base_ids: Vec<[u8; 16]> = base.entries.iter().map(|e| e.entry_id).collect(); if local_ids != base_ids { return Err(format!( "your local copy of this playlist has changed since revision {} was published — publish it again, then ask for a re-submit", base.revision_seq )); } let uuid = *uuid; let keep: BTreeSet<[u8; 16]> = entries.iter().map(|e| e.entry_id).collect(); let remove: Vec = items.iter().zip(&local_ids).filter(|(_, id)| !keep.contains(*id)).map(|(it, _)| it.id).collect(); let existing: BTreeSet<[u8; 16]> = local_ids.iter().copied().collect(); let adds: Vec = entries .iter() .filter(|e| !existing.contains(&e.entry_id)) .map(|e| NewPlaylistItem { title: e.recording.title.clone(), artist: e.recording.artists.join(", "), album: Some(e.recording.album.clone()).filter(|a| !a.is_empty()), duration_secs: e.recording.duration_ms.map(|ms| ms / 1000), path: None, art_path: None, kind: String::new(), source_line: e.slsk_hint.clone(), start_ms: None, note: e.note.clone(), track_id: e.recording.deezer_id.clone(), art_url: None, }) .collect(); let before: BTreeSet = items.iter().map(|it| it.id).collect(); let now = now_secs() as i64; let l = lib.library.clone(); let after: Vec = tauri::async_runtime::spawn_blocking(move || -> Result, String> { if !remove.is_empty() { l.remove_playlist_items(playlist_id, &remove, now).map_err(|e| e.to_string())?; } if !adds.is_empty() { l.add_playlist_items(playlist_id, &adds, None, now).map_err(|e| e.to_string())?; } l.playlist_items(playlist_id).map_err(|e| e.to_string()) }) .await .map_err(|e| e.to_string())??; let mut fresh: VecDeque = after.iter().filter(|it| !before.contains(&it.id)).map(|it| it.id).collect(); let by_entry: BTreeMap<[u8; 16], i64> = after.iter().map(|it| (shared_playlists::entry_id_for(&uuid, it.id), it.id)).collect(); let mut ordered = Vec::with_capacity(entries.len()); for e in &entries { match by_entry.get(&e.entry_id) { Some(id) => ordered.push(*id), None => ordered.push(fresh.pop_front().ok_or("the added rows did not come back from the library")?), } } let l = lib.library.clone(); tauri::async_runtime::spawn_blocking(move || l.set_playlist_order(playlist_id, &ordered, now).map_err(|e| e.to_string())) .await .map_err(|e| e.to_string())??; playlists::changed(app); shared_playlists::publish(app, &lib, playlist_id).await } // ---------------------------------------------------------------- commands #[tauri::command] pub async fn space_board(app: tauri::AppHandle, owner: String, space: String) -> Result { open(&app, &owner, &space).await } #[tauri::command] pub async fn space_propose(app: tauri::AppHandle, owner: String, space: String, base: String, ops: Vec, note: String) -> Result { propose(&app, &owner, &space, &base, ops, ¬e).await } #[tauri::command] pub async fn space_decide(app: tauri::AppHandle, owner: String, space: String, proposal: String, accept: bool, reason: String) -> Result { decide(&app, &owner, &space, &proposal, accept, &reason).await } #[cfg(test)] mod tests { use super::*; #[test] fn contract_generation_is_pinned() { let ids = FreenetAdapter::contract_ids(BOARD_WASM.to_vec(), &pr::make_params(&[7u8; 32], &[1u8; 16])); assert_eq!(ids.code_hash, "3oBYSW1pP9GfX7v4Q1CtP2ikiKcc1yT48U7wxMnTTFho"); } }