feat(cli): sidebar sort mode per group (#8784)

> Stacks on #8781 (`bmcanally/cli-sidebar-stars-mutes`). The diff shows
only this PR's commits. Merge #8781 first.

## Summary

This change adds `buzz sidebar sort`. It sets how each sidebar group
orders its channels.

- `sidebar sort list` shows the groups that have a sort mode.
- `sidebar sort set --group <group> --mode <alpha|recent>` sets a mode.
- `sidebar sort reset --group <group>` returns a group to the default
mode, `alpha`.

A group is `starred`, `channels`, `forums`, `dms`, `section:<id>`, or a
section name.

The command edits the same synced document as Buzz desktop and mobile
(kind:30078, d=`channel-sort`).

This PR stacks on the stars, mutes, and project pins PR. That PR stacks
on the sections PR (#8759).

## Design

The sort document uses the same per-field last-writer-wins register tree
as the sections document. Each group key has one register. The value is
`"alpha"`, `"recent"`, or `null`. A `null` register means "use the
default".

`sidebar_doc.rs` now works with any document shape. A `Codec` describes
one document. It holds the tree shape, the import of meta-less payloads,
and the legacy fields to project. Sections and sort each have one
`Codec`. The decode, encode, and edit code is shared.

The shared head read and the single-publish edit moved to
`sidebar_sync.rs`. Sections and sort both call them.

`Edit::unassign` became `Edit::clear(path)`. Both sections and sort use
it. It writes a `null` register only when a register exists.

Safety rules are the same as for sections:

- A command makes one read of the sort head and one publish.
- A section name also reads the sections document.
- The CLI never publishes over a head it cannot decode.
- `created_at` is the larger of the current time and the head time plus
one.
- Each register version is newer than every register in the head.
- A dominated write exits with code 5.
- A command that changes nothing publishes nothing and prints
`"message": "unchanged"`.
- A result with more than 104 non-null groups fails with exit code 1
before it publishes.

Group names follow these rules:

- A fixed group name wins over a section with the same name.
- `section:<id>` must be an exact live section id when you set a mode. A
section name in this form is refused.
- Any `section:<id>` is accepted when you reset a mode.
- A section name must be unique.

Desktop does not remove the sort key of a deleted section. Only mobile
removes such keys, and only in its local copy. The CLI also does not
remove them. The stale key still shows in `sort list`. It still counts
toward the cap of 104. `sort reset --group section:<id>` clears it.

## Testing

What ran:

- `cargo test -p buzz-cli`: 526 passed.
- `cargo clippy -p buzz-cli --all-targets -- -D warnings`: clean.
- `cargo fmt --all --check`: clean.
- `just file-size-check`: passed.
- All existing sections, stars, mutes, and projects tests pass without a
change to their checks.

The new tests use the axum fake relay. They call the production `run`
function. They cover these cases:

- Set and reset by fixed group, section name, section id, and
`section:<id>`.
- The document format: kind, `d` and `t` tags, `groups`, and `meta.g`
registers.
- An unchanged repeat, and a reset of a group with no register.
- Bad input fails before any publish.
- Ambiguous section names.
- `section:<name>` and `section: <id>` are refused and publish nothing.
- A deleted section: the key stays, set is refused, reset clears it.
- A mobile head is imported, and its other groups survive an edit.
- An unreadable sort head, an unreadable sections head, and a tampered
head are never overwritten.
- `created_at` and register versions are newer than the head.
- The cap of 104 groups, and a reset that makes room.
- A dominated write exits with the conflict error.

Interop evidence:

- Node 24 ran desktop's own TypeScript: `channelSortSync.ts`,
`sidebarLaneStore.ts`, and `sidebarLwwMap.ts`.
- Desktop's `encodeDoc` made a head document. It had a tombstone and a
`section:` key.
- The CLI edited that head (set, reset, and set over a tombstone). It
also made a document from an empty head and from a mobile head.
- For each CLI document, desktop's `decodeDoc` read it as a meta
document.
- For each CLI document, desktop's `projectSort` equals the CLI `groups`
field.
- For each CLI document, desktop's `canonical(encodeDoc(...))` equals
the CLI bytes. Desktop would not publish again.
- Desktop's `mergeTrees` of the desktop head and the CLI edit gives the
CLI document. The reverse merge changes nothing.
- The run printed `INTEROP OK`.

Mutation checks. For each guard, I broke it, ran the sidebar tests, and
saw a failure. Then I restored it.

- Group cap check: `enforces_the_group_cap_and_refuses_bad_heads`
failed.
- Live-section requirement for set: the deleted-section test and the
main journey failed.
- Sort mode leaf validation: the sort document test failed.
- Clear only an existing register: the sections unassign test and the
main journey failed.
- Unchanged no-op: the sections journey and the main journey failed.
- Version newer than the newest register: the edit-version tests failed.
- `created_at` after the head: seven tests failed.
- Legacy mode filter (a `null` legacy value): the sort document test
failed. I added a row for this case after the first mutation survived.
- Fixed group wins over a section name: five tests failed.
- Null registers excluded from the projection: six tests failed.
- Refusal of an unknown `meta.v`: four tests failed.
- Exact live id match for `section:<id>`: the main journey failed when
the check also accepted names.

## Manual test steps

Use a test identity. These commands write to your own sidebar. Run them
with `BUZZ_RELAY_URL` and `BUZZ_PRIVATE_KEY` set. Open Buzz desktop on
the same identity.

1. Build the CLI: `cargo build --release -p buzz-cli`. Use
`./target/release/buzz` below.
2. Run `buzz sidebar sort list`. Success: it prints
`{"default":"alpha","groups":{...}}`.
3. Run `buzz sidebar sections create --name "Sort test"`. Note the
`section_id`.
4. Run `buzz sidebar sort set --group dms --mode recent`. Success: the
output has `"group":"dms"` and an `event_id`. In desktop, the Direct
Messages group changes to Recent within a few seconds.
5. Run the same command again. Success: the output has
`"message":"unchanged"` and `"event_id":null`.
6. Run `buzz sidebar sort set --group "Sort test" --mode recent`.
Success: the output has `"group":"section:<id>"`. The section in desktop
sorts by Recent.
7. Run `buzz sidebar sort list`. Success: `groups` has `dms` and
`section:<id>`, both `recent`.
8. In desktop, set the Starred group to Recent. Run `buzz sidebar sort
list`. Success: `starred` shows `recent`.
9. Run `buzz sidebar sort reset --group dms`. Success: `dms` is back to
A to Z in desktop. `sort list` no longer shows `dms`.
10. Run `buzz sidebar sections delete --section "Sort test"`. Run `buzz
sidebar sort list`. Success: `section:<id>` is still there.
11. Run `buzz sidebar sort set --group section:<id> --mode alpha`.
Success: it fails with exit code 1 and a not-found message.
12. Run `buzz sidebar sort reset --group section:<id>`. Success: it
publishes. `sort list` no longer shows the key.
13. Run `buzz sidebar sort set --group nope --mode recent`. Success: it
fails with exit code 1 and publishes nothing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Blake McAnally <bmcanally@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Blake McAnally
2026-11-05 19:02:04 -06:00
parent 859cb02ec1
commit dbf5c06b74
7 changed files with 773 additions and 108 deletions

View File

@@ -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;

View File

@@ -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<Tree, CliError> {
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<Head, CliError> {
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<Projection, CliError> {
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<Value, CliError> {
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::<u64>()),
};
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<Value, CliError> {
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<Value, CliE
let channels = channel_ids(&channels)?;
edit(client, |edit, _| {
for channel in &channels {
edit.unassign(channel);
edit.clear(&["a", channel]);
}
Ok(())
})
@@ -193,6 +183,7 @@ async fn run(cmd: SidebarSectionsCmd, client: &BuzzClient) -> Result<Value, CliE
pub async fn dispatch(cmd: SidebarCmd, client: &BuzzClient) -> 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};

View File

@@ -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<String, Value>;
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<Item = (&String, &Value)> {
@@ -103,34 +142,34 @@ fn entries(node: &Value) -> impl Iterator<Item = (&String, &Value)> {
.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<Tree> {
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<Value> {
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<Tree> {
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<String, Value> = 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<Tree> {
pub(crate) fn decode(codec: &Codec, json: &Value, legacy_v: i64) -> Option<Tree> {
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<String, String> {
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());
}
}

View File

@@ -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:<id>` 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:<id>` so a deleted section's key can be cleared.
async fn group_key(
client: &BuzzClient,
group: &str,
needs_live_section: bool,
) -> Result<String, CliError> {
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<String, CliError> {
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<Value, CliError> {
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<Value, CliError> {
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-<name>`, 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::<serde_json::Map<_, _>>()
.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:?}");
}
}

View File

@@ -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<Value, CliError> {
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::<u64>()),
};
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"})

View File

@@ -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:<id>`, 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 {