mirror of
https://github.com/block/buzz.git
synced 2026-11-08 02:11:36 +00:00
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:
@@ -145,6 +145,12 @@ buzz sidebar sections create --name "Engineering" --icon "🛠"
|
||||
buzz sidebar sections move --section "Engineering" --channel <uuid> --channel <uuid>
|
||||
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:<section-id>
|
||||
buzz sidebar sort list
|
||||
|
||||
# Sidebar stars, mutes, and project pins
|
||||
buzz sidebar stars add --channel <uuid> --channel <uuid>
|
||||
buzz sidebar mutes remove --channel <uuid>
|
||||
@@ -182,6 +188,19 @@ evict its own ids fails. Project addresses are `30621:<owner-pubkey>:<d-tag>`.
|
||||
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:<id>` (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": {<key>: <mode>}}` 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:<id>` mode, and so does the
|
||||
CLI: it still shows in `sort list`, `sort set` refuses it, and `sort reset
|
||||
--group section:<id>` 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 |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
461
crates/buzz-cli/src/commands/sidebar_sort.rs
Normal file
461
crates/buzz-cli/src/commands/sidebar_sort.rs
Normal 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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user