diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index 5ee8101dd4..def499b1cc 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -145,6 +145,12 @@ buzz sidebar sections create --name "Engineering" --icon "🛠" buzz sidebar sections move --section "Engineering" --channel --channel buzz sidebar sections list +# Sidebar sort modes (alpha or recent per group) +buzz sidebar sort set --group dms --mode recent +buzz sidebar sort set --group "Engineering" --mode recent +buzz sidebar sort reset --group section: +buzz sidebar sort list + # Sidebar stars, mutes, and project pins buzz sidebar stars add --channel --channel buzz sidebar mutes remove --channel @@ -182,6 +188,19 @@ evict its own ids fails. Project addresses are `30621::`. Pass the same ids in any case; channel UUIDs and project owners are stored in lowercase. If the relay reports a newer update (exit 5), run the command again. +`sidebar sort` sets how one sidebar group orders its channels: `alpha` (name, +the default) or `recent` (last message, newest first). `--group` takes +`starred`, `channels`, `forums`, `dms`, `section:` (an exact live id), or a +section name (or bare id). A fixed group name wins over a section with the same name. `sort list` +prints `{"default": "alpha", "groups": {: }}` with only the groups +that have a mode set. `sort reset` writes a `null` register that returns the +group to the default; resetting a group with no register changes nothing. A +write is one sort-head read and one publish; a section name also reads your +sections. Desktop keeps a deleted section's `section:` mode, and so does the +CLI: it still shows in `sort list`, `sort set` refuses it, and `sort reset +--group section:` clears it. The sidebar keeps at most 104 sort modes +(stale keys count), and a command that would pass that fails. + `protect set` replaces every existing rule for the exact ref pattern. Any constraint omitted from the command is removed. `protect list` reports malformed stored rules in `validation_error` so an owner can remove and repair them. @@ -229,6 +248,9 @@ The table below mirrors that tree for readers who are not at a terminal. | | `sections reorder` | Put sections in order | | | `sections move` | Move one or more channels into a section | | | `sections unassign` | Return channels to the default channel list | +| | `sort list` | List the groups with a sort mode | +| | `sort set` | Sort a group by `alpha` or `recent` | +| | `sort reset` | Return a group to the default sort | | | `stars list` / `add` / `remove` | List, star, or unstar channels | | | `mutes list` / `add` / `remove` | List, mute, or unmute channels | | | `projects list` / `add` / `remove` | List, pin, or unpin sidebar projects | diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index dd63145441..8578eeaaf3 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -28,6 +28,7 @@ pub(crate) mod sent_from_thread; pub mod sidebar; mod sidebar_doc; mod sidebar_flags; +mod sidebar_sort; mod sidebar_sync; pub mod social; pub mod upload; diff --git a/crates/buzz-cli/src/commands/sidebar.rs b/crates/buzz-cli/src/commands/sidebar.rs index 4057ad0269..bd4882f443 100644 --- a/crates/buzz-cli/src/commands/sidebar.rs +++ b/crates/buzz-cli/src/commands/sidebar.rs @@ -8,9 +8,9 @@ use serde_json::{json, Value}; -use super::sidebar_doc::{self, Edit, Projection, Section, Stamp, Tree}; -use super::sidebar_flags; -use super::sidebar_sync::{self, channel_ids, now, Lane}; +use super::sidebar_doc::{self, Edit, Projection, Section, Tree}; +use super::sidebar_sync::{self, channel_ids, Lane}; +use super::{sidebar_flags, sidebar_sort}; use crate::client::BuzzClient; use crate::error::CliError; use crate::{SidebarCmd, SidebarSectionsCmd}; @@ -20,27 +20,21 @@ const LANE: Lane = Lane { label: "sidebar sections", }; -struct Head { - created_at: u64, - tree: Tree, +async fn fetch_head(client: &BuzzClient) -> Result { + Ok( + sidebar_sync::fetch_tree(client, LANE, &sidebar_doc::SECTIONS) + .await? + .1, + ) } -/// The relay's current sections event for this identity, decoded; an absent -/// head is an empty tree. -async fn fetch_head(client: &BuzzClient) -> Result { - let head = sidebar_sync::fetch_head(client, LANE, |json, created_at| { - let legacy_v = i64::try_from(created_at.saturating_mul(1_000)).unwrap_or(i64::MAX); - sidebar_doc::decode(json, legacy_v) - }) - .await?; - Ok(Head { - created_at: head.created_at, - tree: head.doc.unwrap_or_default(), - }) +/// The live sections and assignments in your current sections document. +pub(super) async fn fetch_view(client: &BuzzClient) -> Result { + Ok(sidebar_doc::project(&fetch_head(client).await?)) } /// A live section by exact id, or by exact name when that name is unique. -fn resolve<'a>(view: &'a Projection, key: &str) -> Result<&'a Section, CliError> { +pub(super) fn resolve<'a>(view: &'a Projection, key: &str) -> Result<&'a Section, CliError> { if let Some(section) = view.sections.iter().find(|s| s.id == key) { return Ok(section); } @@ -80,34 +74,30 @@ async fn edit( client: &BuzzClient, apply: impl FnOnce(&mut Edit, &Projection) -> Result<(), CliError>, ) -> Result { - let head = fetch_head(client).await?; - let view = sidebar_doc::project(&head.tree); - let mut tree = head.tree.clone(); - let stamp = Stamp { - now_ms: i64::try_from(now().as_millis()).unwrap_or(i64::MAX), - dev: format!("{:016x}", rand::random::()), - }; - apply(&mut Edit::new(&mut tree, &stamp), &view)?; - if tree == head.tree { - return Ok(sidebar_sync::unchanged()); - } - if !sidebar_doc::project(&tree).within_limits() { - return Err(CliError::Usage(format!( - "the sidebar supports at most {} sections and {} channel assignments", - sidebar_doc::MAX_SECTIONS, - sidebar_doc::MAX_ASSIGNMENTS - ))); - } - let plaintext = serde_json::to_string(&sidebar_doc::encode(&tree)) - .map_err(|e| CliError::Other(format!("failed to encode sidebar sections: {e}")))?; - sidebar_sync::publish(client, LANE, plaintext, head.created_at).await + sidebar_sync::edit_tree( + client, + LANE, + &sidebar_doc::SECTIONS, + |tree| { + if sidebar_doc::project(tree).within_limits() { + return Ok(()); + } + Err(CliError::Usage(format!( + "the sidebar supports at most {} sections and {} channel assignments", + sidebar_doc::MAX_SECTIONS, + sidebar_doc::MAX_ASSIGNMENTS + ))) + }, + |edit, head| apply(edit, &sidebar_doc::project(head)), + ) + .await } async fn run(cmd: SidebarSectionsCmd, client: &BuzzClient) -> Result { match cmd { SidebarSectionsCmd::List => { - let head = fetch_head(client).await?; - serde_json::to_value(sidebar_doc::project(&head.tree)) + let tree = fetch_head(client).await?; + serde_json::to_value(sidebar_doc::project(&tree)) .map_err(|e| CliError::Other(format!("failed to encode sections: {e}"))) } SidebarSectionsCmd::Create { name, icon } => { @@ -181,7 +171,7 @@ async fn run(cmd: SidebarSectionsCmd, client: &BuzzClient) -> Result Result Result<(), CliError> { let output = match cmd { SidebarCmd::Sections(cmd) => run(cmd, client).await?, + SidebarCmd::Sort(cmd) => sidebar_sort::run(cmd, client).await?, SidebarCmd::Stars(cmd) => { sidebar_flags::run(&sidebar_flags::STARS, cmd.into(), client).await? } @@ -209,6 +200,8 @@ pub async fn dispatch(cmd: SidebarCmd, client: &BuzzClient) -> Result<(), CliErr #[cfg(test)] mod tests { + use super::super::sidebar_doc::Stamp; + use super::super::sidebar_sync::now; use super::super::sidebar_sync::test_support::{self, Shared}; use super::*; use nostr::Keys; @@ -235,7 +228,7 @@ mod tests { for name in names { edit.create_section(&format!("id-{name}"), name, None); } - sidebar_doc::encode(&tree).to_string() + sidebar_doc::encode(&sidebar_doc::SECTIONS, &tree).to_string() } use test_support::{published, published_count}; diff --git a/crates/buzz-cli/src/commands/sidebar_doc.rs b/crates/buzz-cli/src/commands/sidebar_doc.rs index ab02788340..16c3a40e73 100644 --- a/crates/buzz-cli/src/commands/sidebar_doc.rs +++ b/crates/buzz-cli/src/commands/sidebar_doc.rs @@ -1,9 +1,11 @@ -//! The synced channel-sections document (kind:30078, d=`channel-sections`). +//! The synced register-tree documents: channel sections (kind:30078, +//! d=`channel-sections`) and sort modes (d=`channel-sort`). //! -//! Wire-compatible with desktop `channelSectionsSync.ts`, `sidebarLaneStore.ts` -//! and `sidebarLwwMap.ts`: a tree of per-field last-writer-wins registers -//! `[v, dev, value]` under `meta`, plus the projected `sections`/`assignments` -//! fields that meta-less clients (mobile) read and write. +//! Wire-compatible with desktop `channelSectionsSync.ts`, `channelSortSync.ts`, +//! `sidebarLaneStore.ts` and `sidebarLwwMap.ts`: a tree of per-field +//! last-writer-wins registers `[v, dev, value]` under `meta`, plus the +//! projected legacy fields that meta-less clients (mobile) read and write. +//! A [`Codec`] describes one document's shape, legacy import and projection. use std::collections::BTreeMap; @@ -16,6 +18,8 @@ pub(crate) type Tree = Map; pub(crate) const MAX_SECTIONS: usize = 100; /// Live-assignment cap shared with desktop. pub(crate) const MAX_ASSIGNMENTS: usize = 1_000; +/// Cap on non-null sort registers shared with desktop. +pub(crate) const MAX_SORT_GROUPS: usize = 104; /// Device id desktop stamps on registers imported from meta-less payloads. const LEGACY_DEV: &str = "0000000000000000"; @@ -85,15 +89,50 @@ fn is_order(value: &Value) -> bool { fn is_bool(value: &Value) -> bool { value.is_boolean() } +fn is_sort_mode(value: &Value) -> bool { + value.is_null() || matches!(value.as_str(), Some("alpha" | "recent")) +} type Leaf = fn(&Value) -> bool; -const SECTION_FIELDS: [(&str, Leaf); 4] = [ - ("name", is_string), - ("icon", is_string_or_null), - ("order", is_order), - ("live", is_bool), -]; +/// Shape of a register-tree node, as desktop `Shape`: a leaf register, fixed +/// fields, or a map whose every entry has one shape. +pub(crate) enum Shape { + Leaf(Leaf), + Fields(&'static [(&'static str, Shape)]), + Map(&'static Shape), +} + +/// One synced document: its tree shape, how a meta-less payload becomes +/// registers, and the legacy fields projected from them. +pub(crate) struct Codec { + shape: Shape, + from_legacy: fn(&Value, i64) -> Value, + project: fn(&Tree) -> Value, +} + +pub(crate) const SECTIONS: Codec = Codec { + shape: Shape::Fields(&[ + ( + "s", + Shape::Map(&Shape::Fields(&[ + ("name", Shape::Leaf(is_string)), + ("icon", Shape::Leaf(is_string_or_null)), + ("order", Shape::Leaf(is_order)), + ("live", Shape::Leaf(is_bool)), + ])), + ), + ("a", Shape::Map(&Shape::Leaf(is_string_or_null))), + ]), + from_legacy: sections_from_legacy, + project: sections_view, +}; + +pub(crate) const SORT: Codec = Codec { + shape: Shape::Fields(&[("g", Shape::Map(&Shape::Leaf(is_sort_mode)))]), + from_legacy: sort_from_legacy, + project: |tree| json!({ "groups": sort_groups(tree) }), +}; /// Own entries of a JSON object, skipping the `__proto__` key desktop drops. fn entries(node: &Value) -> impl Iterator { @@ -103,34 +142,34 @@ fn entries(node: &Value) -> impl Iterator { .filter(|(key, _)| key.as_str() != "__proto__") } -/// Keeps only well-formed registers; an invalid one is dropped while its -/// valid siblings survive (desktop `validTree` with the sections shape). -fn valid_tree(fields: &Value) -> Option { - fields.as_object()?; - let mut tree = Tree::new(); - if let Some(s) = fields.get("s").filter(|s| s.is_object()) { - let mut sections = Map::new(); - for (id, node) in entries(s) { - if !node.is_object() { - continue; - } - let mut out = Map::new(); - for (field, leaf) in SECTION_FIELDS { - if let Some(reg) = node.get(field).and_then(|n| valid_reg(n, leaf)) { - out.insert(field.to_string(), reg); - } - } - sections.insert(id.clone(), Value::Object(out)); +/// Keeps only well-formed nodes of `shape`; an invalid register or record is +/// dropped while its valid siblings survive (desktop `validTree`). +fn valid_node(node: &Value, shape: &Shape) -> Option { + if let Shape::Leaf(leaf) = shape { + return valid_reg(node, *leaf); + } + node.as_object()?; + let mut out = Map::new(); + for (key, child) in entries(node) { + let sub = match shape { + Shape::Map(sub) => Some(*sub), + Shape::Fields(fields) => fields + .iter() + .find_map(|(name, sub)| (*name == key).then_some(sub)), + Shape::Leaf(_) => None, + }; + if let Some(valid) = sub.and_then(|sub| valid_node(child, sub)) { + out.insert(key.clone(), valid); } - tree.insert("s".into(), Value::Object(sections)); } - if let Some(a) = fields.get("a").filter(|a| a.is_object()) { - let assignments = entries(a) - .filter_map(|(id, node)| Some((id.clone(), valid_reg(node, is_string_or_null)?))) - .collect(); - tree.insert("a".into(), Value::Object(assignments)); + Some(Value::Object(out)) +} + +fn valid_tree(fields: &Value, shape: &Shape) -> Option { + match valid_node(fields, shape)? { + Value::Object(tree) => Some(tree), + _ => None, } - Some(tree) } /// JS `Math.round`, as an integer when it is a safe one. @@ -143,8 +182,9 @@ fn js_round(f: f64) -> Value { } } -/// Registers for a meta-less (legacy/mobile) payload, every value stamped at `legacy_v`. -fn from_legacy(json: &Value, legacy_v: i64) -> Value { +/// Registers for a meta-less (legacy/mobile) sections payload, every value +/// stamped at `legacy_v`. +fn sections_from_legacy(json: &Value, legacy_v: i64) -> Value { let stamp = |value: Value| json!([legacy_v, LEGACY_DEV, value]); let mut s = Map::new(); for entry in json @@ -182,19 +222,29 @@ fn from_legacy(json: &Value, legacy_v: i64) -> Value { json!({ "s": s, "a": a }) } +/// Registers for a meta-less sort payload; unknown group keys are kept and +/// unknown modes dropped, as desktop `parseChannelSortPayload`. +fn sort_from_legacy(json: &Value, legacy_v: i64) -> Value { + let g: Map = entries(json.get("groups").unwrap_or(&Value::Null)) + .filter(|(_, mode)| matches!(mode.as_str(), Some("alpha" | "recent"))) + .map(|(key, mode)| (key.clone(), json!([legacy_v, LEGACY_DEV, mode]))) + .collect(); + json!({ "g": g }) +} + /// Decodes a `version: 1` payload, importing a meta-less one at `legacy_v`. /// Returns `None` when unreadable, including an unknown `meta.v`. -pub(crate) fn decode(json: &Value, legacy_v: i64) -> Option { +pub(crate) fn decode(codec: &Codec, json: &Value, legacy_v: i64) -> Option { let is_one = |value: Option<&Value>| value.and_then(safe_int) == Some(1); if !is_one(json.get("version")) { return None; } match json.get("meta") { - None => valid_tree(&from_legacy(json, legacy_v)), + None => valid_tree(&(codec.from_legacy)(json, legacy_v), &codec.shape), Some(meta) if is_one(meta.get("v")) => { let mut fields = meta.as_object()?.clone(); fields.remove("v"); - valid_tree(&Value::Object(fields)) + valid_tree(&Value::Object(fields), &codec.shape) } Some(_) => None, } @@ -265,20 +315,35 @@ pub(crate) fn project(tree: &Tree) -> Projection { } } -/// The plaintext document: projected legacy fields plus the register tree. -pub(crate) fn encode(tree: &Tree) -> Value { +fn sections_view(tree: &Tree) -> Value { let Projection { sections, assignments, } = project(tree); + json!({ "sections": sections, "assignments": assignments }) +} + +/// Non-null sort modes by group key; `null` registers are explicit resets. +pub(crate) fn sort_groups(tree: &Tree) -> BTreeMap { + tree.get("g") + .map(entries) + .into_iter() + .flatten() + .filter_map(|(key, reg)| Some((key.clone(), reg.get(2)?.as_str()?.to_string()))) + .collect() +} + +/// The plaintext document: projected legacy fields plus the register tree. +pub(crate) fn encode(codec: &Codec, tree: &Tree) -> Value { + let mut doc = match (codec.project)(tree) { + Value::Object(fields) => fields, + _ => Map::new(), + }; let mut meta = tree.clone(); meta.insert("v".into(), json!(1)); - json!({ - "version": 1, - "sections": sections, - "assignments": assignments, - "meta": meta, - }) + doc.insert("version".into(), json!(1)); + doc.insert("meta".into(), Value::Object(meta)); + Value::Object(doc) } fn max_version(node: &Value) -> i64 { @@ -382,11 +447,17 @@ impl<'a> Edit<'a> { } } - /// Clears an assignment that exists (as a value or tombstone). - pub(crate) fn unassign(&mut self, channel_id: &str) { - let exists = self.tree.get("a").and_then(|a| a.get(channel_id)).is_some(); + /// Tombstones the register at `path` when one exists (as a value or tombstone). + pub(crate) fn clear(&mut self, path: &[&str]) { + let Some((first, rest)) = path.split_first() else { + return; + }; + let exists = rest + .iter() + .fold(self.tree.get(*first), |node, key| node?.get(key)) + .is_some(); if exists { - self.set(&["a", channel_id], Value::Null); + self.set(path, Value::Null); } } } @@ -438,7 +509,7 @@ mod tests { #[test] fn decodes_desktop_document_and_projects_live_state() { - let tree = decode(&desktop_doc(), 0).expect("readable"); + let tree = decode(&SECTIONS, &desktop_doc(), 0).expect("readable"); let view = project(&tree); let names: Vec<_> = view .sections @@ -463,11 +534,11 @@ mod tests { json!({"meta": {"v": 1}}), json!("text"), ] { - assert!(decode(&payload, 0).is_none(), "{payload}"); + assert!(decode(&SECTIONS, &payload, 0).is_none(), "{payload}"); } let bad_version = json!({"version": 1.0, "meta": {"v": 1, "s": {"x": { "order": [i64::MIN, DEV, 0], "live": [-1, DEV, true], "name": [1, DEV, "X"]}}}}); - let tree = decode(&bad_version, 0).expect("readable"); + let tree = decode(&SECTIONS, &bad_version, 0).expect("readable"); assert_eq!(tree["s"]["x"], json!({"name": [1, DEV, "X"]})); } @@ -482,7 +553,7 @@ mod tests { ], "assignments": {"c1": "x", "c2": "missing"}, }); - let tree = decode(&mobile, 42_000).expect("readable"); + let tree = decode(&SECTIONS, &mobile, 42_000).expect("readable"); assert_eq!(tree["s"]["x"]["order"], json!([42_000, LEGACY_DEV, 2])); assert_eq!(tree["s"]["x"]["icon"], json!([42_000, LEGACY_DEV, null])); let view = project(&tree); @@ -493,18 +564,18 @@ mod tests { #[test] fn encode_round_trips_and_carries_the_projection() { - let tree = decode(&desktop_doc(), 0).expect("readable"); - let doc = encode(&tree); + let tree = decode(&SECTIONS, &desktop_doc(), 0).expect("readable"); + let doc = encode(&SECTIONS, &tree); assert_eq!(doc["meta"]["v"], json!(1)); assert_eq!(doc["sections"][0]["name"], json!("Alpha")); assert!(doc["sections"][1].get("icon").is_none()); assert_eq!(doc["assignments"], json!({"c1": "a"})); - assert_eq!(decode(&doc, 0).expect("readable"), tree); + assert_eq!(decode(&SECTIONS, &doc, 0).expect("readable"), tree); } #[test] fn edits_share_one_stamp_newer_than_every_register() { - let mut tree = decode(&desktop_doc(), 0).expect("readable"); + let mut tree = decode(&SECTIONS, &desktop_doc(), 0).expect("readable"); let mut edit = Edit::new(&mut tree, &stamp(1)); edit.create_section("n", "New", None); edit.set(&["a", "c9"], json!("n")); @@ -548,9 +619,48 @@ mod tests { edit.create_section("s1", "S", None); edit.set(&["a", "c1"], json!("s1")); let mut edit = Edit::new(&mut tree, &stamp(2)); - edit.unassign("c1"); - edit.unassign("never"); + edit.clear(&["a", "c1"]); + edit.clear(&["a", "never"]); assert_eq!(tree["a"]["c1"], json!([2, DEV, null])); assert!(tree["a"].get("never").is_none()); } + + #[test] + fn sort_documents_decode_project_and_round_trip() { + let desktop = json!({ + "version": 1, + "groups": {"dms": "recent"}, + "meta": {"v": 1, "g": { + "dms": reg(4, json!("recent")), + "forums": reg(5, Value::Null), + "starred": reg(6, json!("newest")), + "section:x": reg(7, json!("alpha")), + }, "future": reg(1, json!("ignored"))}, + }); + let tree = decode(&SORT, &desktop, 0).expect("readable"); + assert_eq!(tree["g"]["forums"], reg(5, Value::Null)); + assert!(tree["g"].get("starred").is_none()); + assert!(!tree.contains_key("future")); + assert_eq!( + sort_groups(&tree), + BTreeMap::from([ + ("dms".to_string(), "recent".to_string()), + ("section:x".to_string(), "alpha".to_string()), + ]) + ); + let doc = encode(&SORT, &tree); + assert_eq!( + doc["groups"], + json!({"dms": "recent", "section:x": "alpha"}) + ); + assert_eq!(decode(&SORT, &doc, 0).expect("readable"), tree); + + let mobile = json!({"version": 1, "groups": {"dms": "recent", "x": "bogus", "n": null}}); + let tree = decode(&SORT, &mobile, 42_000).expect("readable"); + assert_eq!(tree["g"]["dms"], json!([42_000, LEGACY_DEV, "recent"])); + assert!(tree["g"].get("x").is_none() && tree["g"].get("n").is_none()); + let bare = decode(&SORT, &json!({"version": 1, "groups": []}), 1).expect("readable"); + assert!(bare["g"].as_object().is_some_and(Map::is_empty)); + assert!(decode(&SORT, &json!({"version": 1, "meta": {"v": 2}}), 0).is_none()); + } } diff --git a/crates/buzz-cli/src/commands/sidebar_sort.rs b/crates/buzz-cli/src/commands/sidebar_sort.rs new file mode 100644 index 0000000000..122d76a14a --- /dev/null +++ b/crates/buzz-cli/src/commands/sidebar_sort.rs @@ -0,0 +1,461 @@ +//! `buzz sidebar sort` — how each sidebar group orders its channels, the same +//! synced preference the desktop sidebar edits (kind:30078, d=`channel-sort`, +//! NIP-44 encrypted to yourself). +//! +//! One register per group key, merged per field like the sections document. +//! Every write is one sort-head read and one publish; naming a section also +//! reads the sections document. Desktop never prunes the `section:` key of +//! a deleted section, so neither does this command. + +use serde_json::{json, Value}; + +use super::sidebar::{fetch_view, resolve}; +use super::sidebar_doc::{self, Edit, MAX_SORT_GROUPS, SORT}; +use super::sidebar_sync::{self, Lane}; +use crate::client::BuzzClient; +use crate::error::CliError; +use crate::{SidebarSortCmd, SidebarSortMode}; + +const LANE: Lane = Lane { + d_tag: "channel-sort", + label: "sidebar sort modes", +}; + +const FIXED_GROUPS: [&str; 4] = ["starred", "channels", "forums", "dms"]; +const SECTION_PREFIX: &str = "section:"; + +fn wire(mode: SidebarSortMode) -> &'static str { + match mode { + SidebarSortMode::Alpha => "alpha", + SidebarSortMode::Recent => "recent", + } +} + +/// The group key desktop stores for `group`. A fixed group name wins over a +/// section of the same name. Setting a mode needs a live section; resetting +/// accepts any `section:` so a deleted section's key can be cleared. +async fn group_key( + client: &BuzzClient, + group: &str, + needs_live_section: bool, +) -> Result { + let group = group.trim(); + if FIXED_GROUPS.contains(&group) { + return Ok(group.to_string()); + } + if let Some(id) = group.strip_prefix(SECTION_PREFIX) { + if id.is_empty() { + return Err(CliError::Usage("section: needs a section id".into())); + } + if needs_live_section + && !fetch_view(client) + .await? + .sections + .iter() + .any(|s| s.id == id) + { + return Err(CliError::NotFound(format!( + "no live section has the id \"{id}\"" + ))); + } + return Ok(group.to_string()); + } + let id = resolve_section(client, group).await?; + Ok(format!("{SECTION_PREFIX}{id}")) +} + +async fn resolve_section(client: &BuzzClient, key: &str) -> Result { + let view = fetch_view(client).await?; + Ok(resolve(&view, key)?.id.clone()) +} + +async fn edit( + client: &BuzzClient, + apply: impl FnOnce(&mut Edit) -> Result<(), CliError>, +) -> Result { + sidebar_sync::edit_tree( + client, + LANE, + &SORT, + |tree| { + if sidebar_doc::sort_groups(tree).len() <= MAX_SORT_GROUPS { + return Ok(()); + } + Err(CliError::Usage(format!( + "the sidebar keeps at most {MAX_SORT_GROUPS} sort modes; reset unused groups with `buzz sidebar sort reset`" + ))) + }, + |edit, _| apply(edit), + ) + .await +} + +pub(super) async fn run(cmd: SidebarSortCmd, client: &BuzzClient) -> Result { + match cmd { + SidebarSortCmd::List => { + let (_, tree) = sidebar_sync::fetch_tree(client, LANE, &SORT).await?; + Ok(json!({"default": "alpha", "groups": sidebar_doc::sort_groups(&tree)})) + } + SidebarSortCmd::Set { group, mode } => { + let key = group_key(client, &group, true).await?; + let mut response = edit(client, |edit| { + edit.set(&["g", &key], json!(wire(mode))); + Ok(()) + }) + .await?; + response["group"] = json!(key); + Ok(response) + } + SidebarSortCmd::Reset { group } => { + let key = group_key(client, &group, false).await?; + let mut response = edit(client, |edit| { + edit.clear(&["g", &key]); + Ok(()) + }) + .await?; + response["group"] = json!(key); + Ok(response) + } + } +} + +#[cfg(test)] +mod tests { + use super::super::sidebar_doc::{Stamp, Tree, SECTIONS}; + use super::super::sidebar_sync::now; + use super::super::sidebar_sync::test_support::{self, published, published_count, Shared}; + use super::*; + + const DEV: &str = "0123456789abcdef"; + const SECTIONS_LANE: Lane = Lane { + d_tag: "channel-sections", + label: "sidebar sections", + }; + + fn seed_sort(client: &BuzzClient, relay: &Shared, plaintext: &str, created_at: u64) -> Value { + test_support::seed_at(client, relay, LANE, plaintext, created_at) + } + + /// Seeds a sections document; ids are `id-`, and names listed in + /// `dead` are deleted. + fn seed_sections(client: &BuzzClient, relay: &Shared, names: &[&str], dead: &[&str]) { + let mut tree = Tree::new(); + let stamp = Stamp { + now_ms: 1, + dev: DEV.into(), + }; + let mut edit = Edit::new(&mut tree, &stamp); + for name in names { + edit.create_section(&format!("id-{name}"), name, None); + } + let mut edit = Edit::new(&mut tree, &stamp); + for name in dead { + edit.set(&["s", &format!("id-{name}"), "live"], json!(false)); + } + let plaintext = sidebar_doc::encode(&SECTIONS, &tree).to_string(); + test_support::seed_at(client, relay, SECTIONS_LANE, &plaintext, 5); + } + + fn set(group: &str, mode: SidebarSortMode) -> SidebarSortCmd { + SidebarSortCmd::Set { + group: group.into(), + mode, + } + } + + fn reset(group: &str) -> SidebarSortCmd { + SidebarSortCmd::Reset { + group: group.into(), + } + } + + fn groups(list: &Value) -> Value { + list["groups"].clone() + } + + #[tokio::test] + async fn sets_and_resets_group_sort_modes_in_desktop_format() { + let (client, relay) = test_support::setup().await; + seed_sections(&client, &relay, &["Eng", "Ops"], &[]); + assert_eq!( + run(SidebarSortCmd::List, &client).await.unwrap(), + json!({"default": "alpha", "groups": {}}) + ); + + let before = published_count(&relay); + let response = run(set("dms", SidebarSortMode::Recent), &client) + .await + .unwrap(); + assert_eq!(response["group"], json!("dms")); + assert_eq!(published_count(&relay), before + 1); + let (event, doc) = published(&client, &relay); + assert_eq!(event.kind.as_u16(), 30078); + assert!(event + .tags + .iter() + .any(|t| t.as_slice() == ["d", "channel-sort"])); + assert!(event + .tags + .iter() + .any(|t| t.as_slice() == ["t", "channel-sort"])); + assert_eq!(doc["version"], json!(1)); + assert_eq!(doc["groups"], json!({"dms": "recent"})); + assert_eq!(doc["meta"]["v"], json!(1)); + assert_eq!(doc["meta"]["g"]["dms"][2], json!("recent")); + + // A section is addressed by name, by id, or by its stored key. + let by_name = run(set("Eng", SidebarSortMode::Recent), &client) + .await + .unwrap(); + assert_eq!(by_name["group"], json!("section:id-Eng")); + run(set("section:id-Ops", SidebarSortMode::Alpha), &client) + .await + .unwrap(); + run(set(" starred ", SidebarSortMode::Recent), &client) + .await + .unwrap(); + let list = run(SidebarSortCmd::List, &client).await.unwrap(); + assert_eq!( + groups(&list), + json!({ + "dms": "recent", "starred": "recent", + "section:id-Eng": "recent", "section:id-Ops": "alpha", + }) + ); + + // A repeat publishes nothing; an explicit alpha is a real register. + let count = published_count(&relay); + let again = run(set("dms", SidebarSortMode::Recent), &client) + .await + .unwrap(); + assert_eq!(again["message"], json!("unchanged")); + assert_eq!(published_count(&relay), count); + + // A reset is a null register that every projection drops. + run(reset("dms"), &client).await.unwrap(); + let (_, doc) = published(&client, &relay); + assert_eq!(doc["meta"]["g"]["dms"][2], Value::Null); + assert!(doc["groups"].get("dms").is_none()); + let count = published_count(&relay); + for group in ["dms", "forums"] { + let response = run(reset(group), &client).await.unwrap(); + assert_eq!(response["message"], json!("unchanged"), "{group}"); + } + assert_eq!(published_count(&relay), count); + run(set("dms", SidebarSortMode::Alpha), &client) + .await + .unwrap(); + + // Bad input fails before any publish. + let count = published_count(&relay); + for (group, not_found) in [ + ("Nope", true), + ("section:", false), + ("section:missing", true), + ("section:Eng", true), + ("section: id-Eng", true), + ] { + let err = run(set(group, SidebarSortMode::Recent), &client) + .await + .unwrap_err(); + let expected = if not_found { + matches!(err, CliError::NotFound(_)) + } else { + matches!(err, CliError::Usage(_)) + }; + assert!(expected, "{group}: {err:?}"); + } + assert_eq!(published_count(&relay), count); + } + + #[tokio::test] + async fn ambiguous_section_names_are_refused() { + let (client, relay) = test_support::setup().await; + let mut tree = Tree::new(); + let stamp = Stamp { + now_ms: 1, + dev: DEV.into(), + }; + let mut edit = Edit::new(&mut tree, &stamp); + edit.create_section("a", "Same", None); + edit.create_section("b", "Same", None); + let plaintext = sidebar_doc::encode(&SECTIONS, &tree).to_string(); + test_support::seed_at(&client, &relay, SECTIONS_LANE, &plaintext, 5); + let err = run(set("Same", SidebarSortMode::Recent), &client) + .await + .unwrap_err(); + assert!(matches!(err, CliError::Usage(_)), "{err:?}"); + assert_eq!(published_count(&relay), 1); + } + + #[tokio::test] + async fn a_deleted_sections_key_stays_until_reset() { + let (client, relay) = test_support::setup().await; + seed_sections(&client, &relay, &["Old"], &[]); + run(set("Old", SidebarSortMode::Recent), &client) + .await + .unwrap(); + relay + .lock() + .unwrap() + .events + .retain(|e| e["tags"][0][1] != json!("channel-sections")); + seed_sections(&client, &relay, &["Old"], &["Old"]); + + // Desktop leaves the register behind and still projects it. + let list = run(SidebarSortCmd::List, &client).await.unwrap(); + assert_eq!(groups(&list), json!({"section:id-Old": "recent"})); + let err = run(set("section:id-Old", SidebarSortMode::Alpha), &client) + .await + .unwrap_err(); + assert!(matches!(err, CliError::NotFound(_)), "{err:?}"); + let err = run(set("Old", SidebarSortMode::Alpha), &client) + .await + .unwrap_err(); + assert!(matches!(err, CliError::NotFound(_)), "{err:?}"); + + run(reset("section:id-Old"), &client).await.unwrap(); + let list = run(SidebarSortCmd::List, &client).await.unwrap(); + assert_eq!(groups(&list), json!({})); + } + + #[tokio::test] + async fn keeps_a_mobile_head_and_never_overwrites_an_unreadable_one() { + let (client, relay) = test_support::setup().await; + seed_sort( + &client, + &relay, + r#"{"version":1,"groups":{"dms":"recent","stale":"alpha","bad":"newest"}}"#, + now().as_secs(), + ); + let list = run(SidebarSortCmd::List, &client).await.unwrap(); + assert_eq!(groups(&list), json!({"dms": "recent", "stale": "alpha"})); + run(set("forums", SidebarSortMode::Recent), &client) + .await + .unwrap(); + let (_, doc) = published(&client, &relay); + assert_eq!( + doc["groups"], + json!({"dms": "recent", "forums": "recent", "stale": "alpha"}) + ); + + seed_sort( + &client, + &relay, + r#"{"version":1,"meta":{"v":2}}"#, + now().as_secs() + 100, + ); + let count = published_count(&relay); + assert!(run(SidebarSortCmd::List, &client).await.is_err()); + assert!(run(set("dms", SidebarSortMode::Alpha), &client) + .await + .is_err()); + assert!(run(reset("dms"), &client).await.is_err()); + assert_eq!(published_count(&relay), count); + + // An unreadable sections document blocks only a section lookup. + relay.lock().unwrap().events.clear(); + test_support::seed_at( + &client, + &relay, + SECTIONS_LANE, + r#"{"version":1,"meta":{"v":2}}"#, + 5, + ); + assert!(run(set("Eng", SidebarSortMode::Recent), &client) + .await + .is_err()); + run(set("dms", SidebarSortMode::Recent), &client) + .await + .unwrap(); + } + + #[tokio::test] + async fn an_edit_is_newer_than_the_head_and_its_registers() { + let (client, relay) = test_support::setup().await; + let future = now().as_secs() + 1_000; + let far_v = (future as i64 + 5_000) * 1_000; + let doc = json!({ + "version": 1, "groups": {"dms": "recent"}, + "meta": {"v": 1, "g": {"dms": [far_v, DEV, "recent"]}}, + }); + seed_sort(&client, &relay, &doc.to_string(), future); + + run(set("dms", SidebarSortMode::Alpha), &client) + .await + .unwrap(); + let (event, doc) = published(&client, &relay); + assert_eq!(event.created_at.as_secs(), future + 1); + assert_eq!(doc["meta"]["g"]["dms"][0], json!(far_v + 1)); + + // Another author's head is never read as ours. + let other = BuzzClient::new(String::new(), nostr::Keys::generate(), None, None).unwrap(); + seed_sort( + &other, + &relay, + r#"{"version":1,"groups":{"dms":"recent"}}"#, + future + 9, + ); + let list = run(SidebarSortCmd::List, &client).await.unwrap(); + assert_eq!(groups(&list), json!({"dms": "alpha"})); + } + + #[tokio::test] + async fn enforces_the_group_cap_and_refuses_bad_heads() { + let (client, relay) = test_support::setup().await; + let full: Value = (0..MAX_SORT_GROUPS) + .map(|i| (format!("section:{i}"), json!("recent"))) + .collect::>() + .into(); + seed_sort( + &client, + &relay, + &json!({"version": 1, "groups": full}).to_string(), + now().as_secs(), + ); + let count = published_count(&relay); + let err = run(set("dms", SidebarSortMode::Recent), &client) + .await + .unwrap_err(); + assert!(matches!(err, CliError::Usage(_)), "{err:?}"); + assert_eq!(published_count(&relay), count); + + // Resetting a stale key makes room; a change at the cap still works. + run(reset("section:0"), &client).await.unwrap(); + run(set("dms", SidebarSortMode::Recent), &client) + .await + .unwrap(); + assert_eq!(published_count(&relay), count + 2); + + let mut head = test_support::seed_at( + &client, + &relay, + LANE, + r#"{"version":1,"groups":{}}"#, + now().as_secs() + 50, + ); + head["content"] = json!("tampered"); + relay + .lock() + .unwrap() + .events + .retain(|e| e["tags"][0][1] != json!("channel-sort")); + relay.lock().unwrap().events.push(head); + let count = published_count(&relay); + let err = run(set("dms", SidebarSortMode::Recent), &client) + .await + .unwrap_err(); + assert!(matches!(err, CliError::Other(_)), "{err:?}"); + assert_eq!(published_count(&relay), count); + } + + #[tokio::test] + async fn a_dominated_write_is_a_conflict() { + let (client, relay) = test_support::setup().await; + relay.lock().unwrap().duplicate = true; + let err = run(set("dms", SidebarSortMode::Recent), &client) + .await + .unwrap_err(); + assert!(matches!(err, CliError::Conflict(_)), "{err:?}"); + } +} diff --git a/crates/buzz-cli/src/commands/sidebar_sync.rs b/crates/buzz-cli/src/commands/sidebar_sync.rs index 9e1896ed69..f0bb9faba5 100644 --- a/crates/buzz-cli/src/commands/sidebar_sync.rs +++ b/crates/buzz-cli/src/commands/sidebar_sync.rs @@ -9,6 +9,7 @@ use nostr::{EventBuilder, Kind, Tag, Timestamp}; use serde_json::{json, Value}; use super::parse_write_response; +use super::sidebar_doc::{self, Codec, Edit, Stamp, Tree}; use crate::client::BuzzClient; use crate::error::CliError; @@ -158,6 +159,48 @@ pub(crate) async fn publish( .map_err(|e| CliError::Other(format!("relay response is not JSON: {e}"))) } +/// The current register-tree document of `lane` and the `created_at` of its +/// head; an absent head is an empty tree at `created_at` 0. +pub(crate) async fn fetch_tree( + client: &BuzzClient, + lane: Lane, + codec: &Codec, +) -> Result<(u64, Tree), CliError> { + let head = fetch_head(client, lane, |json, created_at| { + let legacy_v = i64::try_from(created_at.saturating_mul(1_000)).unwrap_or(i64::MAX); + sidebar_doc::decode(codec, json, legacy_v) + }) + .await?; + Ok((head.created_at, head.doc.unwrap_or_default())) +} + +/// Reads the head tree, applies `apply` (which sees the unedited head), and +/// publishes the result once. `check` rejects an edited tree over the +/// document's limits. Returns the write response, or an `unchanged` response +/// without publishing. +pub(crate) async fn edit_tree( + client: &BuzzClient, + lane: Lane, + codec: &Codec, + check: impl FnOnce(&Tree) -> Result<(), CliError>, + apply: impl FnOnce(&mut Edit, &Tree) -> Result<(), CliError>, +) -> Result { + let (head_created_at, head) = fetch_tree(client, lane, codec).await?; + let mut tree = head.clone(); + let stamp = Stamp { + now_ms: i64::try_from(now().as_millis()).unwrap_or(i64::MAX), + dev: format!("{:016x}", rand::random::()), + }; + apply(&mut Edit::new(&mut tree, &stamp), &head)?; + if tree == head { + return Ok(unchanged()); + } + check(&tree)?; + let plaintext = serde_json::to_string(&sidebar_doc::encode(codec, &tree)) + .map_err(|e| CliError::Other(format!("failed to encode {}: {e}", lane.label)))?; + publish(client, lane, plaintext, head_created_at).await +} + /// The response of a command that changed nothing and published nothing. pub(crate) fn unchanged() -> Value { json!({"event_id": null, "accepted": true, "message": "unchanged"}) diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 2379262013..546fb76ef2 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -284,7 +284,7 @@ enum Cmd { /// Get and set channel canvas documents #[command(subcommand)] Canvas(CanvasCmd), - /// Organize your own sidebar: sections, stars, mutes, and project pins + /// Organize your own sidebar: sections, sort modes, stars, mutes, and project pins #[command(subcommand)] Sidebar(SidebarCmd), /// Add, remove, and list emoji reactions @@ -1034,6 +1034,9 @@ pub enum SidebarCmd { /// each other, and the last publish wins. #[command(subcommand)] Sections(SidebarSectionsCmd), + /// List and set how each sidebar group is sorted (synced with Buzz desktop) + #[command(subcommand)] + Sort(SidebarSortCmd), /// List, star, and unstar your channels (synced with Buzz desktop) #[command(subcommand)] Stars(SidebarChannelFlagCmd), @@ -1045,6 +1048,38 @@ pub enum SidebarCmd { Projects(SidebarProjectsCmd), } +/// How a sidebar group orders its channels. +#[derive(Clone, Copy, clap::ValueEnum)] +pub enum SidebarSortMode { + /// By name, A to Z + Alpha, + /// By last message, newest first + Recent, +} + +/// `--group` takes `starred`, `channels`, `forums`, `dms`, `section:`, or +/// the name of a section. +#[derive(Subcommand)] +pub enum SidebarSortCmd { + /// List the groups that have a sort mode set + List, + /// Sort a group by name or by recent activity + Set { + /// Group to sort + #[arg(long)] + group: String, + /// Sort mode + #[arg(long, value_enum)] + mode: SidebarSortMode, + }, + /// Return a group to the default sort (alpha) + Reset { + /// Group to reset + #[arg(long)] + group: String, + }, +} + /// Shared by `sidebar stars` and `sidebar mutes`. #[derive(Subcommand)] pub enum SidebarChannelFlagCmd {