mirror of
https://github.com/block/buzz.git
synced 2026-11-06 17:31:19 +00:00
## Summary
This change adds `buzz channels unread`. The command is read-only. It
shows how many unread messages each channel has. It uses the read state
that desktop and mobile already sync (NIP-RS). It never publishes an
event. It never marks anything read.
Output: `[{channel_id, name, last_read_at, unread_count, capped}]`. The
most unread channel is first.
## Design
The command has two parts.
**Part 1: read state** (`commands/read_state.rs`)
- The command queries your own `kind:30078` events with the `read-state`
tag.
- It verifies each signature. It rejects events from other authors.
- It keeps the newest version of each `d` tag.
- It decrypts each slot with NIP-44 to yourself. It applies desktop's
validation rules.
- It merges all slots with `max`.
- It ignores `ov_*` keys. Desktop and mobile do not read them either.
**Part 2: counting** (`commands/channel_unread.rs`)
The rules follow desktop's `communityUnreadObserver.ts`.
- Without `--channel`, the command uses your non-archived channel
memberships.
- It reads message kinds 9, 40002, 45001, and 45003. For a DM it also
reads huddle starts (48100).
- It reads only events after the channel marker (`since = marker + 1`).
- Your own messages do not count.
- A message is read when its time is at or below the greatest of three
markers: the channel marker, its `msg:<id>` marker, and its
`thread:<root>` marker. A broadcast reply ignores the thread marker.
**Bounds and signals**
- At most 200 channels per run. `--limit` sets the number. The maximum
is 200.
- At most 1000 events per channel. A channel at that cap shows
`"capped": true`. Its count is a lower bound.
- If you have more channels than `--limit`, the extra channels are not
counted. Stderr shows
`{"warning":"channels_truncated","counted":N,"dropped":M}`.
- More than `--limit` `--channel` values is an error. The command never
drops a named channel.
- An unknown named channel exits with code 1.
**Failure rules**
- A slot that cannot be verified, decrypted, or decoded fails the
command with exit 4. Desktop skips such a slot. A skip would make the
count too high, with no sign of it.
- A slot with a newer `v` is skipped. Stderr shows
`read_state_slots_skipped`.
**Deviations from desktop**
- The command has no 7-day fetch horizon. It reads all slots. This can
only add markers.
- It does not apply desktop's notification gate. That gate uses
desktop-local data (followed threads, muted channels). So a count can be
higher than the desktop badge.
- It does not read mobile's `activity:` markers. Desktop does not read
them.
The README describes the output and the gotchas. The stable-inventory
test lists the new subcommand. This PR does not change `desktop/`
(deprecated; see #8840).
## Testing
**What ran**
- `cargo test -p buzz-cli`: 510 passed.
- `cargo clippy -p buzz-cli --all-targets -- -D warnings`: clean.
- `cargo fmt --all --check`: clean.
- `just file-size-check`: passed.
**Unit tests**
The tests run the production functions against a fake axum relay. The
fake relay applies the real filters (`#h`, `#d`, `#p`, `#t`, `since`,
`limit`).
- Merge: max across slots, newest version wins, bad values dropped,
`ov_*` ignored.
- Failures: bad JSON, wrong shape, bad `client_id`, wrong author, bad
signature, and undecryptable content all fail.
- Counting: own messages, `msg:` markers, `thread:` markers, broadcast
replies, archived channels, DM kinds, and sort order.
- Bounds: channel limit, event cap, `capped`, and explicit-channel
limits.
- Query shape: each channel query has `#h`, a bounded `limit`, and
`since = marker + 1`.
- A bad slot stops the command before any channel query.
**Interop evidence**
A node script runs the real `buzz` binary against a fake relay. Events
are made and encrypted by `nostr-tools`. This is an independent NIP-44
code base. The script compares the CLI with desktop's own TypeScript:
- `mergeReadStateEvents` gives the same frontier as `last_read_at` for
six channels.
- `fetchCommunityUnread` gives the same read/unread result as
`unread_count > 0` for each channel.
The six channels cover: read, unread, never read, own message only, two
slots with max-merge, and a `msg:` marker. The script prints `INTEROP
OK`. The script is not part of the repository.
**Mutation checks**
I broke each guard once and ran the tests. Each break made a test fail:
- Own-message exclusion.
- `msg:` marker, `thread:` marker, and broadcast rule.
- `since = marker + 1`.
- `capped` signal and channel limit.
- Explicit channels over the limit.
- Archived skip and DM kinds.
- Max-merge, newest version wins, and the `t` tag requirement.
- Signature check, author check, unknown `v`, and the 256-byte key
limit.
A mutation of the decrypt error path did not fail a test. The JSON step
fails the same way, so the break changes nothing. I removed that check
from the list.
## Manual test steps
You need a relay and an identity that has used desktop or mobile on that
relay.
1. Build the CLI:
```bash
. ./bin/activate-hermit && cargo build -p buzz-cli
```
2. Set the identity and the relay:
```bash
export BUZZ_RELAY_URL=<relay-url>
export BUZZ_PRIVATE_KEY=<your-key>
```
3. Run the command:
```bash
./target/debug/buzz channels unread | jq .
```
Success: you see one object per channel. Each object has `channel_id`,
`name`, `last_read_at`, `unread_count`, and `capped`.
4. Compare with the app. Open desktop or mobile on the same identity. A
channel with a badge must show `unread_count` above 0. A channel with no
badge must show 0. Counts can be higher than the badge for muted
channels and followed threads. This is expected.
5. Read a channel in the app. Wait a few seconds. Run the command again.
Success: the count for that channel is 0 and `last_read_at` is higher.
6. Test the channel limit:
```bash
./target/debug/buzz channels unread --limit 1 | jq length
```
Success: the command prints `1`. If you have more than one channel,
stderr shows `channels_truncated`.
7. Test an unknown channel:
```bash
./target/debug/buzz channels unread --channel
00000000-0000-4000-8000-000000000000; echo $?
```
Success: the command prints a not-found error and the exit code is `1`.
8. Check that nothing was written. The command sends no events. The app
badges stay the same after the run.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Signed-off-by: bmcanally <bmcanally@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>