Commit Graph

3205 Commits

Author SHA1 Message Date
Salman Mohammed
d3a4909cca perf(search): rank only the newest prefix matches and plan with the real tsquery (#8976)
## Summary

Message search is one of the slowest relay queries. Short typeahead
prefixes make it worst: `a:*` matches most of a community.

**Why it was slow:** the tsquery was built inside a `CROSS JOIN LATERAL`
subquery, so the planner saw an InitPlan and couldn't read the
`search_tsv` statistics. It fell back to the default 0.5% selectivity
for `@@`: for `a:*` on 1M rows it estimated 4,497 rows when 517,065
matched. Every search therefore took every GIN match, ran `ts_rank_cd`
on each one, and top-N sorted them before `LIMIT 20`.

This PR:

1. **Binds the real tsquery.** `push_tsquery` is unchanged. A separate
`SELECT (…)::text` round trip resolves the tsquery, and the search binds
it as `$q::tsquery`. An empty tsquery returns no hits without running
the search.
2. **Plans each search for its own tsquery.** The search runs with
`.persistent(false)`. A cached prepared statement can switch to a
generic plan after five runs, and a generic plan can't see the tsquery.
Forcing a generic plan lost the gain in testing.
3. **Ranks only the newest 1,000 prefix matches.** Prefix-mode relevance
search ranks the newest `PREFIX_RANK_WINDOW` (1,000) matches. The inner
`ORDER BY created_at DESC, id DESC LIMIT 1000` matches each partition's
primary key read backwards, so for common prefixes Postgres streams the
newest rows and stops at 1,000. For rarer terms it keeps the GIN bitmap
plan, now with an accurate estimate.

Full-text search and profile (kind:0) typeahead still rank every match.

### Behavior change: every human message search (product decision
needed)

Prefix mode isn't limited to keystroke typeahead. buzz-app
(`useSearchMessages.ts`) and legacy desktop
(`desktop/src-tauri/src/commands/messages.rs:184`) send `search_mode:
"prefix"` for **all** message search. Full-text search runs only on the
WS REQ path, which agents and the CLI use. So for people:

- Results are the best-ranked of the newest 1,000 matches in the
search's scope, not of all matches. If more than 1,000 newer messages
also match, an older, more relevant message drops out of ⌘K results. The
scope includes `#channel`, `from:` and date bounds, so a narrower search
has the same limit within it. People have no full-text fallback.
- The bridge honors `page`, and buzz-app loads later pages at 20 per
page. Every page ranks the same window, so paging stays consistent, but
results end after 1,000 matches (page 50).

Full-text search (agents/CLI) and profile (kind:0) typeahead still rank
every match.

### Perf risk: matches the backward scan has to walk far to find

The planner picks the backward scan when it expects 1,000 matches near
the top of the community, and it estimates that by treating each filter
as independent. Two known shapes break that assumption.

**Terms common in old messages but absent from recent ones.** The
planner assumes matches are spread evenly over time. For a term that was
common long ago but doesn't appear recently (an old project name, a
former teammate), it can pick the backward primary-key scan and walk
every newer row before reaching the first match. Reviewers measured this
on 1.2M synthetic rows: about 2.05–2.1 s, against 0.7–1.8 s for the old
query shape and 1.2 s with the GIN plan forced. The 20 s statement
timeout and the #8954 cancel still bound it. The worst case is close to
the old `a:*` worst case, but it now scales with community size, not
match count. The planned EXPLAIN on production-shaped data will include
such a term.

**Terms common in channels the searcher can't read.** Human search
always adds `channel_id = ANY(<readable channels>) OR channel_id IS
NULL` (`build_search_channel_scope_filter`). A term that is common in
private channels or other people's DMs can get a high match estimate,
take the backward scan, and walk the community's rows to find a few
readable matches, where the old shape used the GIN bitmap. This hasn't
been reproduced yet; the production-shaped EXPLAIN will include such a
term next to the old-only one. The same timeout and cancel bound it.

### Cost

- One extra round trip per search to resolve the tsquery (about 0.03 ms
of execution). It runs on the same pool checkout as the search
(`cancel::fetch_all` takes the caller's connection), so a busy pool
queues each search once. An IMMUTABLE SQL function could remove the
round trip, but that needs a migration, so it's not done here.

### Related issue

- Follows #8954 (cancel abandoned searches, bound the search pool) and
#8964 (closed in favor of #8954).
- Builds on #8947 (3-character server-side prefix minimum), merged from
main. The resolve step passes its minimum. When the trailing prefix is
too short, the resolved tsquery is NULL, and the search returns no hits
without running the second query.
- Client-side pairing: block/buzz-app#2210 (3-character last-word
minimum for ⌘K message search).

## Testing

**EXPLAIN ANALYZE on a synthetic 1M-row `events` table** (monthly
partitions, GIN on `search_tsv`, kinds 9/40002/40008, custom plans, warm
cache):

| Prefix | Matches | Before | After | Plan after |
|---|---|---|---|---|
| `a` | 517k | 322 ms | **0.7 ms** | backward primary-key scan per
partition, stops at 1,000 |
| `th` | 137k | 127 ms | **1.2 ms** | backward primary-key scan, stops
at 1,000 |
| `a` (community with 10% of rows) | — | ~100 ms | **1–2 ms** | backward
primary-key scan |
| `deploy` | 8.2k | 15 ms | 5–10 ms | GIN bitmap, accurate estimate |
| `d278` | 500 | 1.1 ms | 0.5 ms | GIN bitmap |
| rare word | 0 | 0.07 ms | 0.03 ms | GIN bitmap |

**Postgres integration tests:**

- New test `prefix_relevance_ranks_only_the_newest_matches`. An old,
highly relevant message drops out of prefix results behind 1,000 newer
matches but still ranks first in full-text search. Prefix pages end at
page 50. An old, best-ranked profile still tops profile typeahead behind
1,000 newer profile matches. Removing the window fails the test, and so
does applying it to profiles.
- `abandoned_search_cancels_its_statement`: the `pg_stat_activity`
matcher now looks for `ts_rank_cd(`, because the old
`search_query.query` text no longer exists.
- Locally, each test ran against a fresh database because
`cargo-nextest` isn't installed, so the hosted lane hasn't run yet. 26
of 27 pass. `artifact_search_uses_only_live_current_head` also fails on
unmodified main in that local setup, so the failure isn't from this
change.

**Checks:** `cargo fmt --check` and `cargo clippy -p buzz-search
--all-targets -D warnings` are clean.

**Not yet done:**

- hosted CI;
- a manual check against a local relay with ⌘K typeahead;
- EXPLAIN against production-shaped data, including a term common only
in old messages and a term common only in channels the searcher can't
read.

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

---------

Signed-off-by: Salman Mohammed <smohammed@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:25:27 -05:00
klopez4212
3f03aa0450 fix(mobile): restore ordinary DM behavior for Bestie (#8987)
Mobile Bestie used a special flat timeline that hid reply threads. It
now opens the normal DM screen, matching desktop’s pinned Bestie
(block/buzz-app#1935), with existing threads, reply actions, and
top-level sending in the same conversation.

Thread-scoped agent typing in DMs stays beneath its originating message.
Working avatars and mobile’s bouncing dots share a compact neutral
container, matching desktop’s subtle activity control. The elbow is
visible from the start, with the working container already at the final
thread-footer position. For a single-agent reply, the same avatar stays
mounted while the container and dots fade out over 200 ms, the avatar
slides into its summary position, and the reply count fades/scales in
from 95%. The elbow stays fixed. Reduced motion switches immediately.
The avatar-to-dots gap is 4 points. Reply text uses its natural line
height so its glyphs center on the avatar and elbow rather than drawing
at the top of the footer. Multi-participant summaries retain their
grouped layout. The accessible View thread action, reaction placement,
and final reply-footer position are preserved.

Validation:
- The final commit (`c6fef5d80`) passed full analysis, 4,220 Flutter
tests (40 skipped), 4 push-configuration tests, and 27 crypto tests.
- Container alignment checks pass in both directions, including avatar
continuity, intermediate fade and final geometry, reduced motion, and
thread actions. Glyph-box alignment is checked against the avatar
center. An additional regression covers a reply arriving before typing
stops and work restarting mid-transition.
- Updated DM journeys cover opening Bestie, existing threads, threaded
and top-level sending, swipe-to-reply, and absence of duplicate main-row
thread typing.
- Installed the final product source (`c6fef5d80`) in place on Pixel 10
and iPhone Air previews. The iPhone uses a release-mode preview so it
launches from the home screen; saved Kenny Lopez / Starry Night
conversation verified after install. Pixel retains Testing Agent
Sessions. The pending/midpoint/completed handoff was rendered in widget
checks, and the corrected reply-label alignment was verified on the
Pixel against the same messages before and after; human acceptance of
this final animation remains pending.
- Mandatory push hooks passed, including file-size and branch checks. A
rendered widget preview verifies the container and elbow; live
acceptance remains pending.

Self-review found no blockers in the changed paths. Review status is
preserved.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-03 18:16:14 +00:00
klopez4212
a66cb5f7ab Modernize mobile channel and DM creation sheets (#8942)
Modernizes New Channel and New DM with full-height forms and X/Create or
X/checkmark actions. iOS uses UIKit sheets throughout; Android uses the
Flutter fallback. Channel menus default to Ongoing/Public, and native
menu previews lift only the trailing value while labels stay fixed.

Channel names disable automatic capitalization, autocorrect, and
spellcheck; both platforms use descriptive placeholders.

Preserves recipient search and limits, creation error retries, and
navigation after native dismissal.

Validation: 4,154 mobile tests passed (38 skipped), auxiliary suites and
static checks passed; iOS device/simulator and Android builds passed.
Exercised menus, dismissal, DM search and selection in the simulator and
Android form behavior on Pixel; iPhone UI tested and approved by the
author.

| iOS native form (Simulator) | Android Flutter form (Pixel) |
| --- | --- |
| ![Native iOS channel
form](https://github.com/user-attachments/assets/a3ee78c9-510d-488c-979e-64fa3bded686)
| ![Android channel
form](https://github.com/user-attachments/assets/a2d65f1c-752c-4cef-b659-2b396cbbbcc9)
|

Native menu with the left label fixed:

![Native value-only menu
preview](https://github.com/user-attachments/assets/c4fe8659-e46c-409f-b866-c9568b26ccc1)

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-03 18:11:15 +00:00
klopez4212
344f007f36 Add mobile Bestie onboarding and fix remote agent presence (#8957)
An empty Bestie slot cycles still posters from Fuzzies, Gloopies and
Figgies every five seconds, without creating video players. The next
poster is precached; reduced motion and hidden-screen pauses are
preserved. The launcher uses the neutral “Bestie” label until a
selection is available, then “Chat with …”.

For supported communities with no owned agents, tapping opens Create
agent directly. A successful first-agent setup saves that agent as
Bestie without replacing an existing preference; existing-agent and
unsupported-community paths keep the Agents list.

Verified Builderlab registrations can show sleeping remote agents as
Online. Owner-scoped saved runtime availability now prevents a known
absent or deleted runtime from returning Online after Builderlab
sign-out or a fresh load, even when another community retains its
registration.

Validation: mobile static checks; 4,274 Flutter tests passed with hosted
flows enabled; launcher → cancel → create → Home “Chat with Scout”,
including encrypted persisted selection; sign-out, fresh-load, owner
isolation and deletion regressions. Restoring the old carousel produced
three video-player requests; removing the saved-availability guard
reproduced incorrect Online after sign-out.

The earlier iPhone recording below shows the original carousel and entry
flow and stops before creation. The new poster-only behavior and
completed creation are covered by widget tests, not a new device
recording.


https://github.com/user-attachments/assets/94fcff72-967a-44c2-99d5-27c715e196a2

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-03 18:10:28 +00:00
Tom Brow
0e5a69962e perf(mobile): evict verified-profile interest in constant time (#8931)
## Summary

`VerifiedProfilesNotifier.track` trims its interest map to 1,024
subjects, but it finds the oldest subject by scanning every entry, once
per eviction.

Opening #announcements (4,302 members) evicts about 3,300 subjects,
which is roughly 3.4 million comparisons. On a 12 mini Profile build
that was 316 ms of main thread, inside the channel's 134 to 167 ms
opening frames. It runs whether or not the relay serves verified
profiles, so it also affects the prod Block community.

A subject's interest is set once, on insertion (`??=`), so the map's
insertion order is interest order. The oldest subject is therefore
`keys.first`, and eviction is constant time. Which subjects are kept
does not change.

### Related issue
None found.

### Testing
- New `verified_profile_directory_test.dart` case: tracking 4,300
subjects keeps exactly the 1,024 most recently tracked ones, and
tracking a subject again does not refresh its age. This test checks that
behavior is preserved, and it passes on main too. The speedup comes from
removing the per-eviction scan, which the device CPU profile attributed.
- `just mobile-check` passes. `flutter test
test/shared/verified_profiles/` passes (30 tests). The pre-push hook ran
the mobile unit tests.
- Device (12 mini, Profile build, opening #announcements, first 5 s;
runs not matched): `VerifiedProfilesNotifier.track` inclusive CPU 316 ms
before, 14 ms after. Opening frames of 134 to 141 ms remain, mostly the
channel name scope (follow-up).

Not covered: a human test (waived under the mobile testing exception),
Android. Separately, the 1,024 cap still churns subscriptions for
rosters larger than that. This PR doesn't change that design.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:16:43 -08:00
Luke Tornquist
d44031f418 perf(relay): cut writer-pool round trips past the 48k relay ceiling (#8846)
## Summary

Five changes that take load off the writer pool and the relay's
connection bookkeeping. On a single-relay load rig, the clean ceiling
moves from 40k to about 48k WebSocket clients. In the A/B, connect p99
at 48k fell from 4.1 s (abort) to 1.65 s.

1. **Ban/mute restriction cache with a 1 s max age.** HTTP API requests,
publishes and ephemeral writes read `(community, pubkey)` restriction
state through one cache. An entry is served for 1 s from when its read
began, matching the open-channel cache's bound. Socket admission,
moderation-command actor checks and the `/buzz/v1` ban recheck still
read fresh. An admitted socket outlives the 1 s window, so a banned
client that reconnects inside it would otherwise keep a socket open
indefinitely. A ban drops the target's entry, and each owned agent's
entry, on every pod as part of the live-disconnect fan-out, best-effort.
Timeouts, untimeouts and unbans drop it on the pod that handles them.
2. **Fewer round trips per event read.** Replica reads skip the per-read
`statement_timeout` tighten, because the reader pool installs the
deadline at connect. They keep the savepoint, so a failed or cancelled
read leaves the session usable and releases its locks. That takes them
from 4 round trips to 3. Writer reads send `BEGIN` and the tighten as
one message, which also takes them from 4 to 3.
3. **Concurrent open-channel cache misses share one read.** The first
miss in a community takes a per-community gate and reads; the others
wait for it and use its result. A waiter whose leader failed reads once
on its own and does not queue again. A waiter also stops waiting after
the cache's 1 s max age and reads on its own, because the leader's set
would be stale by then anyway. So a leader whose read hangs stalls only
its own request.
4. **`has_admitted_connection` is one map lookup.** A `(community,
pubkey) → admitted count` index is maintained under each socket's pubkey
lock in `mark_admitted`, `set_authenticated_identity` and `deregister`.
It replaces a scan of every connection on each disconnect.
5. **Host → community cache with a 1 s max age.** Only successful
lookups are cached; unmapped hosts and errors go to the database every
time.

## What got simpler

- The ban handler's separate pod-local cache drop is gone; the
disconnect every ban already runs covers it on every pod.
- One write-restriction gate instead of two.
`enforce_cached_write_restriction`, `enforce_relay_membership_ephemeral`
and the `cached_ban` parameter chain through `enforce_membership` /
`admit_member` / `deny_banned` are deleted.
- The ephemeral path's separate 30 s ban cache is gone, so the relay has
one staleness rule for restriction state instead of two.

## Notes

- Bans reach the restriction cache on every pod. Every ban path (signed
command, report action, relay admin) closes the target's and its agents'
sockets clusterwide, and each pod drops those entries in the same step.
A dropped Redis publish, or a restriction read already in flight when
the drop lands (it can refill the old row), falls back to the 1 s bound.
Timeouts on other pods also rely on the 1 s bound, and so do unbans and
untimeouts, which fail closed.
- On the load rig the restriction-cache hit rate was 28%, because HTTP
agents poll every 5 s. A longer TTL with Redis invalidation would raise
it, but that is a separate change.

## Load test

Single relay pinned to 8 cores, native Postgres 16, a seeded fixture
with 2,000 open channels, and a 6-shard generator. Steady ramp with 120
s holds; a step aborts at p99 > 3 s. Each step adds 10% HTTP agents.

| clients | base connect p99 | this PR connect p99 | base relay cores |
this PR relay cores |
|---|---|---|---|---|
| 32k | 34 ms | 31 ms | 1.71 | 1.66 |
| 40k | 45 ms | 26 ms | 2.33 | 2.15 |
| 48k | 4,107 ms (abort) | 1,652 ms | 4.7 | 3.0 |
| 56k | n/a | 4,535 ms (abort) | n/a | 4.0 |

Open-channel re-reads over the run fell from 72k to 1.6k. The tenant
cache hit rate was 94%.

48k is this branch's knee. A later run of the same build aborted at 48k:
connect p99 2.2 s, but 29% subscribe errors from the concurrent-handler
cap. With a 10 s restriction max age, it passed 48k, at connect p99 1.56
s, 60% hit rate and 10% fewer relay cores, and both arms stopped at 56k.

## Testing

- `cargo fmt --check`, `cargo clippy -p buzz-relay -p buzz-db
--all-targets -D warnings`
- `cargo test -p buzz-db --lib` and `-p buzz-relay --lib`, run in
parallel. The only failures were 8 DB-seeding tests that need a migrated
default database; the Postgres lane covers them. Commits after that run
changed the replica savepoint, the `/buzz/v1` recheck revert and some
test edits.
- Postgres lane at head: `scripts/postgres-test-run.sh -p buzz-relay -p
buzz-db --lib --bins --tests`. All 1,183 tests passed.
- Mutants. Each one must turn its new test red:
  - waiters skipping the gate
  - a no-op index removal
  - the tenant cache ignoring age
  - the restriction cache ignoring age
  - a waiter waiting on the gate with no limit
  - a disconnect that keeps the cached restriction row
  - replica reads dropping the savepoint

buzz-review-completed

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

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:15:49 -05:00
Luke Tornquist
d005c7bb9c fix(search): skip message prefix search below three characters (#8947)
## Summary

Typeahead surfaces (the buzz-app and desktop topbars) send a NIP-50
search with `search_mode: "prefix"` whenever typing pauses. A one- or
two-character trailing `:*` prefix matches a large share of every
indexed message. The GIN index on `search_tsv` can't stop early or
return rows in rank order, so Postgres fetches, rechecks and ranks every
match before it applies `LIMIT`. In a large community, each of these
queries costs seconds of database CPU.

With this change, prefix-mode `search()` returns no hits when the
trailing whitespace token yields a lexeme shorter than three characters
(`PREFIX_MIN_CHARS`). The check runs inside the prefix tsquery SQL, on
the output of the same `to_tsvector('simple', …)` call that decides
which lexemes get `:*`. Punctuation therefore can't pad the count:
`pr.`, `@pr`, `#pr`, `c++`, `don't` and `pre-p` are all held back, and
`project ?` still runs, because `?` yields no `:*` lexeme. When the rule
applies, the tsquery is NULL, so it matches nothing and the GIN index
lookup returns no rows. Completed words in front of the trailing token
don't matter: `"the p"` runs the same broad `p:*` lookup as `"p"`.
Kind:0-only queries (profile typeahead) are exempt because there are few
profiles, and the short-name exact-lexeme ordering relies on one- and
two-character queries. `search_profile_names` is unchanged. Full-text
mode is unchanged.

Only the HTTP bridge (`POST /query` with `search_mode: "prefix"`)
reaches prefix mode. WebSocket REQ search always uses full-text mode, so
this change doesn't affect it.

## What got simpler

The kind:0-only predicate is now computed once (`profile_only`) and
shared by the new minimum and the existing exact-lexeme ordering. The
minimum lives in the one SQL fragment that already tokenizes the query,
so there is no second tokenizer in Rust to keep in sync with Postgres.
No new concepts beyond one constant.

## Known limits

- This bounds the worst case only. A common three-character prefix can
still match enough rows to be expensive. Bounding that needs a product
decision, such as a recency window for typeahead or a different index.
- Two-character CJK words (for example `東京`) get no message typeahead
until a third character. A trailing contraction or hyphenated word with
a short part (`don't`, `e-m`) gets none until the next word is started.
Full-text mode is unaffected.
- Clients should not show "no results" for one- and two-character input.
A client-side minimum is a companion change. That includes the legacy
desktop topbar in this repo (`desktop/src/features/search/hooks.ts`,
which searches at 2 characters globally and 1 in a channel); it is left
out here to keep this PR relay-only.

## Testing

- Extended
`prefix_mode_matches_final_token_prefix_without_changing_full_text` with
a table of prefix queries and the exact hit sets they must return. It
pins no hits for a trailing token that yields a lexeme below three
characters: alone, after a completed word (`project pr`), padded with
punctuation (`pr.`, `@pr`, `#pr`, `...p`), as a contraction (`don't`),
and hyphenated (`pre-p`). It covers `kinds = [9]`, no kinds, mixed `[0,
9]`, and `Newest` sort. Each of those rows has data it would match
without the minimum. The same table has controls that must still match:
`pro.`, `project ?` (punctuation-only trailing token), `pre-pro`, `t
pan` (a short completed word stays exact and isn't limited), and a
one-character kind:0 `p`. A full-text `don't` control shows that
full-text mode is unaffected.
`short_kind0_prefix_prioritizes_exact_lexeme_on_a_noisy_page` pins the
kind:0 exemption.
- Mutation-checked on Blox. Each of these fails a test: removing the
minimum, removing the kind:0 exemption, and applying the minimum to
every lexeme instead of only the trailing ones.
- Synthetic plan check on Blox Postgres (300k rows, GIN on a generated
`simple` tsvector, same `CROSS JOIN LATERAL` query shape). `'pr':*` took
3.6 s and read 10,411 buffers. The NULL tsquery took 0.01 ms: the bitmap
scan returned 0 rows and read no buffers.
- Two existing prefix tests used a two-character trailing token
(`project pl`, `alpha be`). They now use three or more characters
(`project plan`, `alpha bet`) and assert the same behavior.
- Blox: `cargo fmt --check`, `cargo clippy -p buzz-search --all-targets
-D warnings`, `buzz-search` unit tests, and the `buzz-search` Postgres
lane (`scripts/postgres-test-run.sh`).

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

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:08:00 -05:00
Tom Brow
58eb36dd57 perf(mobile): cheaper identity-name rebuilds for large rosters (#8936)
## Summary

Every identity-name source change (a profile batch, a verified name)
rebuilds a channel's naming scope over its whole member list. In a
4,000-member channel, each rebuild derived every key's naming fact more
than once: owner loading and resolution each derived it, and so did each
label for a key outside the scope. It also checked every key against a
regular expression several times, and iOS AOT builds run regular
expressions in an interpreter.

- `IdentityNames` derives each key's naming fact once per scope.
- `isHexPubkey`: a character-loop hex check, used by the identity-name
code instead of the regular expressions.

Labels are unchanged.

## Measurements

These were taken with an earlier version of this PR that also cached
npub encoding. That cache was removed to keep the change small, so the
remaining gain is smaller than shown. I haven't re-measured it.

- **Synthetic** (Mac, `flutter test`, 4,391 members, 60% with profiles;
scope, `missingOwnerProfiles` and every label for each new sources
object): a warm rebuild took 63 ms before and 12 ms with the cache. The
first rebuild took 120 ms before and 91 ms with the cache.
- **Device** (iPhone 12 mini, Profile build, first 5 s after opening
#announcements with 4,391 members; runs not matched):
`channelIdentityNamesProvider` plus `watchChannelIdentityLabel`
inclusive CPU was 148 ms before and 81 ms with the cache. Of that,
`fullNpub` (npub encoding, the cached part) went from 62 ms to 22 ms.
The identity path no longer calls `RegExp.hasMatch`.

## Testing

- A new test counts profile lookups. Owner loading, every label and an
outside key read each member's profile once. Without the memo, each is
read 3 times.
- New `isHexPubkey` tests cover both cases, wrong lengths, characters
just outside each range, and a bad character at every position.
- `just mobile-check mobile-test` passes.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 08:00:41 -08:00
Tom Brow
369e1ef322 perf(mobile): stop re-parsing message markdown on every rebuild (#8928)
## Context

Each message in a channel shows its text as Markdown. The `GptMarkdown`
widget parses that text. Parsing is slow for long messages.

`GptMarkdownTheme` gives Markdown colours and styles to the widgets
below it. When it gets a new theme data object, it tells all
`GptMarkdown` widgets below it to parse their text again. It compares
the old and new objects by identity, because `GptMarkdownThemeData` has
no value equality. Thus a new object with the same values also causes a
parse.

## Problem

Two widgets made a new theme data object on each build:
- `MessageContent` made one for each message bubble.
- `AppMarkdownTheme` made one for the full app.

Thus each rebuild parsed all messages on the screen again. Many events
cause a rebuild: a batch of profiles, a read marker, a keyboard frame.
In a large channel such as #4things (1,235 members), these events occur
almost all the time.

## Change

- `MessageContent` keeps its bubble theme until one of its inputs
changes. The inputs are: the app Markdown theme, the link colour, own or
other message, the ink colour and `onSurface`.
- `AppMarkdownTheme` makes new theme data only when `ThemeData` changes
or when an outer Markdown theme changes.

A change of colour, of brightness, or of own or other message still
makes new theme data. Then the Markdown parses again with the new
colours. This is correct.

### Related issue

None found.

## Testing

- New regression tests fail on main. They pass on this branch.
- `message_content_stability_test.dart`: When a bubble rebuilds with the
same inputs, it keeps the same theme object and the same parsed
`TextSpan`. The test does this for own messages and for other messages.
When own or other changes, the bubble gets a new theme.
- `app_markdown_theme_test.dart`: The app keeps one theme object when it
rebuilds. It makes a new one when light or dark mode changes.
- `just mobile-install mobile-check mobile-test` passes.
- Device test: iPhone 12 mini, Profile build, Tom's Block community,
open #4things. The two runs had different live traffic and different
cached content. Thus they show the cause and the fix. They do not give a
controlled speed measurement.
- Before: In the first 2.4 s, parses from rebuilds (`performRebuild`)
used approximately 90 ms. One frame used 116 ms. Approximately 48 ms of
that frame parsed visible messages again.
- After: In 30 s, rebuilds caused no parses. Only the first build of
each message (`_firstBuild`) parsed.
- An independent agent review found no defects. It found one small item:
`ChatColors` has no value equality. Thus a rebuild of `MaterialApp`
still makes new app theme data one time. This costs only a little
performance. We did not change it.

Not tested: A human test did not occur. The mobile testing exception
waives it, and an agent did the device test. No test occurred on an
Android device.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 08:00:33 -08:00
Tom Brow
eee75a1c7b perf(mobile): verify owned-agent registrations natively (#8938)
## Summary

`loadOwnedAgentDirectory` checks the signature of each kind:30177 agent
registration before it trusts the registration's `model`. It did this
with `nostr.Event.fromJson`, which runs the `nostr` package's pure-Dart
Schnorr code on the UI isolate.

This change uses `verifiedNostrEventFromJson`, which the app's other
verification sites already use. It makes the same timestamp, event id
and BIP-340 signature checks with the bundled libsecp256k1. It also
requires lowercase hex for the pubkey and signature, which relay events
already use.

A registration that fails verification still lists its agent without a
model, as before.

## Measurements

- **Synthetic** (Mac, `flutter test`, 20 signed registrations, warm):
pure Dart 163 ms, native 2.6 ms.
- **Device** (iPhone 12 mini, Profile build, first channel open after
launch, first 5 s; runs not matched): `loadOwnedAgentDirectory`
inclusive CPU was 71 ms before (69 ms in `Event._assertValid`) and 2 ms
after.

## Testing

- New test: a registration with a wrong signature still lists the agent,
with no model. The correctly signed registration keeps its model.
- `just mobile-check mobile-test` passes (pre-push hook).

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

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 08:00:26 -08:00
Tom Brow
cad7f1d53d perf(mobile): build composer channel names once per channel list (#8943)
## Summary

`_useComposerChannelNames` ran on every `ComposeBar` build. Each time it
built a lowercase-name to id map over every channel and joined every
entry into a string key, only so a `useEffect` could skip calling
`setChannelNames`. The composer rebuilds on every keystroke and keyboard
animation frame.

This memoizes the map and key on the channel list instance. The effect
is still gated on the content key, so an equal new list still doesn't
call `setChannelNames` again.

## Measurements

iPhone 12 mini, Profile build, Builderlab, raising the composer keyboard
in #4things: `_useComposerChannelNames` was 26 of 31 `ComposeBar.build`
CPU samples (about 26 ms of UI work across the 600 ms raise).

Limitation: I haven't re-measured on the device after the change. The
removal is shown by the test below.

## Testing

- New test: rebuilding the composer 5 times (keyboard insets and typing)
with the same channel list doesn't iterate the list again. It fails with
the change reverted (8 iterations instead of 3).
- New test: changing the channel list still updates the composer's
channel chips.
- `just mobile-check mobile-test` passes (pre-push hook).

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

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 08:00:19 -08:00
Tom Brow
fc9e652c0e perf(mobile): format a channel timeline only when its events change (#8929)
## Summary

The channel page ran `formatTimeline` on every build. The page also
rebuilds for changes unrelated to messages: read state, presence, the
roster, keyboard and layout. `formatTimeline` walks every loaded event
and JSON-decodes each system row.

In #4things (1,235 members, mostly join rows), on a 12 mini Profile
build:
- 58 ms of main thread over 21 idle seconds.
- About 11 ms per frame while the keyboard animated.

The messages notifiers publish a new list for every event change, so
this memoizes the result on that list, the current pubkey, and the forum
flag.

`buildMainTimelineEntries` and the relay thread summaries it reads stay
outside the memo. A summary-only update that republishes the same list
still shows up.

### Related issue
None found.

### Testing
- Updated `loading_review_tests.dart`: unrelated page rebuilds now
format the timeline 0 times, where main formats once per rebuild (the
test fails on main). A new live event formats exactly once, and its
message appears.
- `just mobile-install mobile-check mobile-test` passes.
- Device (12 mini, Profile build with this change, #4things idle). Runs
are not matched, so this shows the mechanism, not a controlled speedup.
  - Before: 58 ms over 21 s.
  - After: 7 ms over 18 s, all from real event changes.
- An independent agent review found no defects. It checked that
`formatTimeline` reads only its arguments, that every publish path
creates a new list or republishes unchanged events, and that nothing
downstream mutates the shared result.

Not covered: a human test (waived under the mobile testing exception;
this was an agent-driven device run), and Android.

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

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 08:00:11 -08:00
Tom Brow
478cf0a97e docs(mobile): recommend Xcode 27.1 (#8949)
Updates the recommended Xcode from 27.0 to 27.1 in `mobile/README.md`
(iOS toolchain section and the `DEVELOPER_DIR` example) and the
`CONTRIBUTING.md` prerequisites table.

Release pipelines move to Xcode 27.1.0 in
[squareup/buzz-releases#118](https://github.com/squareup/buzz-releases/pull/118),
because mobuild CI images no longer install 27.0. This repo's GitHub CI
uses `macos-latest` with no Xcode pin, so no workflow changes are
needed.

## Testing

- With Xcode 27.1 (27A9275) selected, a clean `flutter build ipa
--release --no-codesign` of `mobile/` on main succeeded
(`Buzz.xcarchive`, 95s). That is the release pipeline's IPA build
without signing.
- Docs-only change, so no app test run.

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

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 08:00:04 -08:00
Tom Brow
76bc66acfc mobile: hold a queued list read for the 10s gap (#8958)
## Summary

Follow-up to [#8910](https://github.com/block/buzz/pull/8910) for the
two optional notes from its review:

- **A queued list read now waits out the 10s gap**
([note](https://github.com/block/buzz/pull/8910#discussion_r4447689472)).
If a change comes due while a slow read is still running, the read is
queued. When the slow read finished, the queued one used to start right
away, even if the gap after the previous read hadn't passed. That broke
the "reads start at least 10s apart" rule: a read could start 9s after
the one before. The queue now drains through `_readListIfChanged`, which
holds the read until the gap ends. If the finished read already covered
the change, nothing is read.
- **A test for a partly failed unread catch-up**
([note](https://github.com/block/buzz/pull/8910#discussion_r4447689485)).
Previously, deleting the line that retries a catch-up when only some
channels' fallback reads failed left the suite passing.

### Related issue

N/A. Follow-up to [#8910](https://github.com/block/buzz/pull/8910).

### Testing

- New step in the open-list journey: the relay answers each of a read's
two queries in 7s. A change that comes due while that read runs is read
once, after the gap, not as soon as the slow read ends. Restoring the
old drain fails the step.
- New step in the resume journey: after a reconnect, the batched
catch-up fails over to per-channel reads, and one channel's read is
refused. That channel turns unread on the retry, and nothing more is
read over the next 10 minutes. Removing the partial-failure retry fails
the step.
- `FakeRelay.holdQuery()` holds HTTP queries, so a test can make a read
slow.
- The full mobile suite passes, and `dart format` and `flutter analyze`
are clean.
- This is a timing-only change to background list reads, with no UI
change. The journeys above drive the real notifiers against a fake
relay. I did no device run.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 07:01:07 -08:00
Tom Brow
ff6ab96e42 fix(mobile): drop the # from Solo chat names in push notifications (#8955)
## Summary

Push notifications for Solo chats were titled like channels (`#Trip
planning`). The iOS notification extension added `#` to every stream and
forum conversation name, but the app presents Solo chats as personal
chats, not channels.

- `BuzzPushCachedChannel` gains optional `isSolo` and `creatorPubkey`,
derived from relay kind 39000 metadata. `isSolo` uses the same rule as
the app's `Channel.isSolo`: a private stream whose NIP-CL labels (every
`t` after the leading type) include `buzz.solo`. `creatorPubkey` is the
relay-authored `P` tag.
- The resolver uses the bare channel name only for the viewer's own Solo
chats (matching `Channel.isPersonalChat`, which requires `createdBy ==
viewer`). Someone else's Solo chat, and every other channel, keeps
`#name`.
- `isSolo` is optional, so existing App Group caches still decode.
Entries cached before this change have no flag and keep the `#` until
their metadata is refreshed.

## Validation

- `swift test --package-path mobile/ios/BuzzPushKit`: 104 XCTest and 66
Swift Testing tests pass, including two new ones:
- `testOnlyOwnSoloChatConversationNameDropsChannelHash`: a fresh Solo
metadata fetch yields `Trip planning` when the viewer created the chat
and `#Trip planning` when someone else did. Removing the creator check
makes it fail.
- `soloClassification`: only private `stream` channels labelled
`buzz.solo` are marked Solo (open, forum, label-only and unlabelled
private streams are not), and the `P` creator is cached lowercased.
- Live relay check: a local relay with synthetic identities, a Solo chat
("Weekend plans") and a private channel ("design-review"), each with a
second member who posts. The production `BuzzPushNotificationResolver`
(the code the notification extension calls), run against relay-built
kind 39000 metadata, gives:

  | | Solo chat | Channel |
  |---|---|---|
  | main | `#Weekend plans` | `#design-review` |
  | this PR | `Weekend plans` | `#design-review` |

- Not covered: a banner rendered by the notification extension. `simctl
push` delivers to the simulator as a local notification and never
launches the extension, and a real APNs push needs credentials this
machine doesn't have.

## Follow-up after merge

Merging on the live relay check above. Still to do once a dogfood build
includes this change:

- [ ] On-device push check on a BLIMP: a Solo chat push shows the bare
name and a channel push keeps `#name`.
- [ ] Before/after notification screenshots.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
mobile-v0.27.0-rc.18
2026-11-02 18:50:08 -08:00
John Matthew Tennant
972e89ac89 fix(mobile): allow community removal without a cached push grant (#8978)
## Summary

Fix community removal failing with “Expected exactly one endpoint grant
for push lease revocation” when saved push state exists but the current
build has no cached grant, including builds without a push gateway.

When the community has a saved installation ID, journal its targeted
revocation without requiring the grant. An unknown grant expiry stays
unknown: retain cleanup credentials until the relay accepts the
revocation. Existing known-expiry cleanup and legacy grant-based
addressing remain unchanged.

### Related issue

No matching open issue found. Follows [the durable revocation
outbox](https://github.com/block/buzz/pull/8706).

### Testing

The new regression fails with the reported error on the preceding
implementation. It now verifies missing-grant enqueue, offline retry,
persistence across restart, the exact lease address and eventual cleanup
after acceptance. On a physical iPhone with the fix applied to the
onboarding test build, the previously failing community removal
succeeded.

Unknown-expiry records retain their signing key in secure storage until
successful revocation. Downgrading to a client that requires an integer
expiry will reject those pending records.

Signed-off-by: John Tennant <jtennant@squareup.com>
Co-authored-by: Aaron <f425b86c3b780d43f8d9cf1802429d04dd985bf92eac44fca9468b5ad3887c5e@buzz.block.builderlab.xyz>
2026-11-02 18:46:49 -08:00
John Matthew Tennant
09b21cbe63 fix(mobile): label community switching consistently (#8979)
## Summary

Rename the mobile community menu action from **Switch workspace** to
**Switch community**, matching the Switch Community page it opens and
avoiding confusion with billing workspaces.

### Related issue

N/A. No duplicate found.

### Testing

The existing home-page widget journey opens the community menu and
verifies the new label. Before/after captures show the production
Flutter menu with disposable fixture data.

## Screenshots

### Before

![Community menu
before](https://raw.githubusercontent.com/block/buzz/c587b7ce64b8e6d46604a29ff0d22d2cae3bd7ba/pr-8979--BEFORE.png)

### After

![Community menu
after](https://raw.githubusercontent.com/block/buzz/c587b7ce64b8e6d46604a29ff0d22d2cae3bd7ba/pr-8979--AFTER.png)

Signed-off-by: John Tennant <jtennant@squareup.com>
Co-authored-by: Aaron <f425b86c3b780d43f8d9cf1802429d04dd985bf92eac44fca9468b5ad3887c5e@buzz.block.builderlab.xyz>
2026-11-02 18:46:42 -08:00
John Matthew Tennant
6684f7e2f0 fix(mobile): make the Join button readable in dark mode (#8969)
## Summary

After scanning or pasting a community invite in dark mode, the native
Join button can display white text on a white background. This fix
passes the theme’s foreground color to native confirmation and applies
it alongside the button’s background color.

### Related issue

Related invite handoff: https://github.com/block/buzz/pull/8601.

### Testing

A widget regression checks the foreground and background colors sent to
native confirmation. The isolated PR build shows readable black Join
text on the white native button in dark mode.

### Screenshots

Before (consent-only build `3d566de`):

![Join label invisible in dark
mode](https://raw.githubusercontent.com/block/buzz/e12a750a31badc9e2a95a18c277b33968c7ec1f5/pr-8969--DARK_JOIN_BEFORE.png)

After (this PR):

![Readable Join label in dark
mode](https://raw.githubusercontent.com/block/buzz/e12a750a31badc9e2a95a18c277b33968c7ec1f5/pr-8969--DARK_JOIN_AFTER.png)

Signed-off-by: John Tennant <jtennant@block.xyz>
Signed-off-by: John Tennant <jtennant@squareup.com>
Co-authored-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz>
Co-authored-by: Aaron <f425b86c3b780d43f8d9cf1802429d04dd985bf92eac44fca9468b5ad3887c5e@buzz.block.builderlab.xyz>
2026-11-02 18:46:36 -08:00
Luke Tornquist
83bd8b1cae fix(loadgen): sign public URLs when dialling connect_addr in plaintext (#8926) 2026-11-03 02:04:19 +00:00
John Matthew Tennant
1c143a40b7 fix(mobile): show the Join prompt when an invite launches Buzz (#8970)
## Summary

Opening an invite while Buzz is closed can leave the Join prompt
invisible and block later attempts. Native confirmation can attach to a
temporary system dialog that then closes, leaving the join result
pending.

This fix uses Flutter confirmation when the native presenter is
occupied. If UIKit rejects presentation, it clears the pending session
so the user can try again.

### Related issue

Related invite handoff: https://github.com/block/buzz/pull/8601.

### Testing

The native regression fails before the fix and passes afterward. It
covers rejected presentation, a second attempt, and an existing
temporary dialog.

The isolated PR build passed a fresh-user journey from Safari consent
through launching Buzz, visible confirmation, Join, starter channels and
seeded history.

### Screenshots

Visible Join prompt after Safari opens Buzz, from this PR build:

![Join prompt after cold
launch](https://raw.githubusercontent.com/block/buzz/f4d083ffaa2b2d2d2672d2b5c9a5f9f3690d75b1/pr-8970--COLD_CONFIRMATION.png)

### Remaining work

Finish Dart validation and review before readiness.

Signed-off-by: John Tennant <jtennant@block.xyz>
Co-authored-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz>
2026-11-02 20:38:37 -05:00
John Matthew Tennant
205ff47744 fix(mobile): retry failed channel searches after reconnecting (#8968)
## Summary

Searching while disconnected leaves “Open channel search couldn't
finish” visible after the connection returns. Users must change the
query or tap Retry channels to recover.

This fix automatically retries the open-channel directory search when
the relay reconnects, using the current query. It does not rerun message
search or explain the separate onboarding setup failure.

### Related issue

None found.

### Testing

The widget regression searches while disconnected, reconnects without
typing, and checks that the error clears and an unjoined open channel
appears. It fails on main and passes with this fix.

On the iOS simulator at this PR head, restarting the local relay cleared
the error within about five seconds without changing the query; the
unjoined channel remained listed.

### Screenshots

Relay down: the error shows under the cached channel.

![Relay down: open channel search
error](https://github.com/user-attachments/assets/9014735e-2743-444a-aa77-7d9d2126f8b3)

Relay back, same query, no typing: the error is gone and the unjoined
channel stays listed.

![After reconnect: error
cleared](https://github.com/user-attachments/assets/beed543f-a6a4-4e88-aae1-3c061bb016b2)

---------

Signed-off-by: John Matthew Tennant <jtennant@squareup.com>
Co-authored-by: Simone <0828be588d5c9c4d092c5b1cf01761d5aef63d6b104807fd8e8284d517dd1a99@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:29:13 -05:00
John Matthew Tennant
cdd2af1a35 fix(mobile): let starter setup retry after a connection failure (#8967)
## Summary

After joining a community, “Retry setup” can keep failing until Buzz
restarts because it reuses an earlier connection error. A previously
successful wait can also let a retry proceed while disconnected.

Each setup attempt now waits for the current relay connection, retaining
the community check and 15-second timeout. This fixes retry recovery;
the original reported setup failure remains unexplained.

### Related issue

None found.

### Testing

Two provider regressions fail on main and pass with this fix: retry
after an interrupted wait, and retry after a previously connected
session disconnects.

In an iOS simulator comparison with an injected first-wait error, the
old build failed every retry. The fixed build completed setup and
displayed both starter channels without restarting. This proves recovery
from the injected error, not the original failure. Reconnect timing
after a previously successful wait is covered by the provider regression
only.

## Screenshots

iOS simulator against a local relay, running head 5a95f8a85 plus an
uncommitted first-wait failure injection (the injection fails only the
first connection wait).

### First attempt fails and offers Retry setup

![First attempt fails and offers Retry
setup](https://github.com/user-attachments/assets/09256e06-e73d-4f9b-9225-75c8e03b21a5)

### Retry completes and shows both starter channels

![Retry completes and shows both starter
channels](https://github.com/user-attachments/assets/b31e4b0e-0a07-4b8b-b55d-8a1ca4608bda)

Signed-off-by: John Tennant <jtennant@squareup.com>
Co-authored-by: Damon <8a08290d889f1c68921542bfe9631d4cd671be9bea5f1114951f386819e4c10a@buzz.block.builderlab.xyz>
2026-11-02 20:27:31 -05:00
John Matthew Tennant
0ec74838a5 fix(mobile): open consent for scanned and pasted invites (#8952)
## Summary

Scanning or pasting a valid community invite can show “approval has
expired” before the user has reviewed the community’s requirements. This
fix opens browser consent, preserves the joining identity, and resumes
joining when approval returns. If the browser cannot open, Review
requirements remains available to retry.

### Related issue

Related invite handoff: https://github.com/block/buzz/pull/8601.

### Testing

Provider and widget regressions cover missing or expired approval,
browser-launch failure and retry, identity preservation, and native
dismissal without reporting a successful join.

The isolated PR build passed actual clipboard entry and Allow Paste,
browser consent, return to Join, membership, starter channels and seeded
history. Camera scanning remains untested.

### Screenshots

Browser requirements opened from the native Join prompt:

![Review community
requirements](https://raw.githubusercontent.com/block/buzz/37c30af1fc3bf2c2c97a1c19ce48eea63971206c/pr-8952--BROWSER_REQUIREMENTS.png)

Joining resumes after approval:

![Join after browser
approval](https://raw.githubusercontent.com/block/buzz/37c30af1fc3bf2c2c97a1c19ce48eea63971206c/pr-8952--RESUMED_JOIN.png)

Joined with starter-channel history:

![Welcome history after
joining](https://raw.githubusercontent.com/block/buzz/37c30af1fc3bf2c2c97a1c19ce48eea63971206c/pr-8952--RECOVERY_HISTORY.png)

Signed-off-by: John Tennant <jtennant@block.xyz>
Co-authored-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz>
2026-11-02 20:25:16 -05:00
Luke Tornquist
46da35810d db: recycle pool connections after 5 minutes instead of 30 (#8951) 2026-11-02 19:35:27 -05:00
Luke Tornquist
02925ceb6e fix(relay): cap bridge searches in flight per user (#8956)
## Summary

Typeahead starts a bridge `/query` search on almost every keystroke. The
client aborts the previous request. But if the abort never reaches the
relay, for example because an intermediary keeps the upstream request
open, every one of those searches keeps running.

Each `(community, pubkey)` may now have **4** bridge searches in flight.
The next search supersedes the oldest one. The superseded request
returns **409** `{"error":"search superseded by a newer search"}`. It is
deliberately not a 429: desktop arms its relay-wide admission gate on
any 429, which would hold every relay request for 10 s, and the CLI
retries a `rate-limited:` 429. Its future is dropped, so #8954's
cancel-on-drop cancels its statement on the server.

- **Newest wins.** The newcomer is never rejected, because the newest
keystroke is the one the user is waiting for.
- **The cap is above 1.** Sessions that share a pubkey keep their
parallel searches: several agent sessions, or people search and message
search running side by side.
- **Bookkeeping lives in process.** `SearchSlots` holds a
`Mutex<HashMap<key, VecDeque<(id, CancellationToken)>>>`. A slot removes
itself by id when its search finishes, fails, is dropped or is
superseded. So a superseded search can never remove the newer search
that replaced it, and a user's entry is removed when their last search
leaves.
- **A search that finishes as it is superseded still returns its
result.** The `select!` is biased toward the search.

The limit is per relay process, not global. That is enough to bound how
much one user can pile onto one process's search pool.

## What got simpler

Nothing was removed. The change adds one small, self-contained module
and one call site. It also wires the new tests into the unit lane
(`Justfile test-unit` and `scripts/run-tests.sh`). The relay's unit lane
is an allowlist, so without that wiring the tests would run nowhere in
CI.

## Tests

`api::bridge::search_slots::tests`:
- `the_search_past_the_cap_supersedes_the_oldest`
- `a_finished_search_frees_its_slot`
- `a_superseded_search_leaves_its_successor_in_place`
- `no_entry_outlives_its_searches`
- `a_superseded_search_is_a_conflict_not_back_pressure`

Each of these mutants made at least one test fail:
- Removing the cap.
- Replacing the by-id removal with `pop_front`.
- Leaving empty user entries in the map.
- Answering supersession with 429.

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

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:33:09 +00:00
Luke Tornquist
949f61dfab fix(search): cancel abandoned searches and bound the search pool (#8954) 2026-11-02 23:19:23 +00:00
Tom Brow
009dc103f6 perf(mobile): read the channel list after live changes, not every minute (#8910)
## Summary

While the app is open, a 60s timer reads the whole channel list again:
every membership roster, the channel metadata, and the unread catch-up.
On the test account that is **about 4MB of JSON every minute** on the 12
mini, about 240MB an hour, whether or not anything changed.

The live subscriptions already deliver what the list is built from, so
this PR removes the timer. The list is read again when a live event says
it may have changed:

- a relay notice that tags this reader: added to a channel or a new DM
(kind 44100), removed from one (44101), or a DM hidden or shown on
another device (30622)
- a metadata edit on a listed channel (kind 9002, now part of the live
filter)
- a system notice on a listed channel that changes the channel itself:
archived (including the relay auto-archiving an idle ephemeral channel),
unarchived, deleted, visibility, topic, purpose, TTL and similar. Member
joins and leaves don't count.

The read starts 2s after the last such event, and no later than 10s
after the first, so a burst costs one read. Reads start at least 10s
apart however busy the relay is. A read that fails is retried with
backoff (5s, doubling up to 5 minutes), including the read after a
resume or reconnect. Nothing is read in the background: launch, resume,
reconnect and pull to refresh read the list as before.

Live subscriptions ask for no stored events (`limit: 0`), so nothing
sent while one is down is replayed. So:

- Each list read opens the notice subscription before it reads, and the
read covers anything sent before.
- If the relay closes the notice subscription or a channel chunk for a
reason that may clear (busy, rate-limited, timed out), the relay session
reopens it itself, as before. The channel list now hears about that
through `subscribeWithStatus`.
- If the relay refuses one as over its subscription limit, only that REQ
is retried, after a jittered backoff (5s, doubling up to 5 minutes). The
backoff starts over once a reopened subscription has stayed up for a
minute. Any other refusal (`restricted:`, `invalid:` and so on) can't
succeed unchanged, so it is left alone until the next connection, as on
main.
- A reopened subscription that is still up when its read comes due
counts as a change, so the list is read once to catch up on what it
missed. If the relay admits a REQ and then closes it again, it triggers
no reads.

Known gaps, accepted: deleting a channel or kicking a member sends this
reader no notice, and archived channels have no live coverage, so an
unarchive done on another device isn't seen. An edit to a newly added
channel that lands in the round trip between reading its metadata and
its live subscription starting is also missed. The list catches up on
the next launch, resume, reconnect or pull to refresh. Archiving or
unarchiving on this device re-reads the list at once, as before.

## Measurements

iPhone 12 mini on Wi-Fi against the internal relay, app open and idle,
instrumented profile build (HTTP response bodies after decompression,
websocket text in). Measured on an earlier revision of this PR, which
read on a 60s tick only after a change. Idle behavior is the same now
(no change, no read).

| 10 idle minutes | main | this PR |
| --- | --- | --- |
| Channel list reads | 10 | 1 |
| HTTP queries | 40 | 5 |
| HTTP response bytes | 39.9MB | 4.0MB |
| Websocket bytes in | 4.4MB | 1.1MB |

On main, every tick read memberships (348KB), another 310KB query and
the unread catch-up (3.3MB). With this PR, the only read in the window
came right after another device hid or showed a DM (kind 30622), which
is exactly the kind of change it should pick up. Sizes are JSON after
decompression; the relay gzips responses, so wire bytes are smaller (not
measured). [#8901](https://github.com/block/buzz/pull/8901) separately
shrinks each read's unread catch-up; the two are independent.

## Testing

- New journey test: with the app open and nothing changing, nothing is
read. Adding the reader to a channel, renaming a listed channel,
archiving or auto-archiving one and removing the reader each cause one
read about 2s later, and a member joining causes none. A change right
after a read waits out the 10s gap. A steady stream of changes is read
after 10s and then no more than every 10s. A burst costs one read. A
failed read is retried with backoff and then stops.
- New journey test, for the notice subscription and for a channel chunk:
the relay refuses it twice as over its limit while the list changes. It
is retried with backoff, and once it has stayed up the list is read once
to catch up. Later the relay drops it, and having stayed up it is
reopened within about 5s and catches up again. Then it delivers live.
The relay then closes it as busy while the list changes, and the list
catches up once it is back; closed again right after reopening, it
triggers no read until it stays up. Refused as `restricted:`, it is not
retried.
- The resume journey: a reconnect read and a resume read each fail once
while a channel is added, and each is retried and shows the channel.
- Each guard was removed once to confirm a test fails: the debounce, the
max wait, the minimum gap, the read retry (including after reconnect and
resume), the catch-up reads after a retry or a relay-session reopen, the
still-up checks, the backoff restart, the background check, retrying
only capacity refusals, and clearing refusals on a new connection.
- The terminal-rejection tests expect a chunk refused as `restricted:`
never to be retried on that connection or to cause a list or history
read, and a new connection to retry it at once.
- `just mobile-check` and `just mobile-test` pass.
- Device: on the 12 mini, 10 idle minutes with the app open, before and
after (table above).

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:13:16 -08:00
Tom Brow
fa395e9e80 feat(mobile): open a prefilled Create agent form from Builderlab agent cards (#8912)
Builderlab agent cards (printed for the event, and generated at the
kiosk) carry a QR to a share page, `app.builderlab.xyz/a/<id>`. Its
**Add to Buzz** button opens `buzz://agent/new?card=<id>`, which no Buzz
client handled until now. This PR makes mobile handle it.

### Behavior

- Opening the link shows the Agents page, then a Create agent form
prefilled from the card. Everything stays editable, and nothing is
created until the user taps Create.
- The app fetches the card's `agent.json` from the avatars CDN for this
build's Builderlab environment (production or staging). The link carries
only the card id, never a host, so the QR stays small no matter how long
a card's instructions get.
- `agent.json` is validated against the card schema. `id` (matching the
link), `name` and `avatarUrl` (an https URL) are required.
`instructions`, `model` and `reasoningEffort` are optional (published
cards don't have them yet) and left for the user when absent. A missing
required field or a wrongly typed known field rejects the card; any
other key is ignored whatever its type, including `v` and `description`
(Buzz agents have no description to prefill), so Builderlab can add
fields without breaking released apps. The model and effort are
preselected only if the live catalog offers them. The card id is opaque:
any non-empty id is accepted and percent-encoded into the URL.
- The fetch uses the same limits as image downloads, now shared in
`bounded_fetch.dart`: aborted after 20 s without a byte or 5 min
overall, and rejected over 2 MB. `MediaImageProvider` now calls the same
helper, with no behavior change.
- The link waits for sign-in, then reloads the registry for the
signed-in account before anything else. It's dropped with a message if
the card is unavailable (403/404), invalid, can't be loaded (any other
response), or an agent setup is unfinished, including a setup saved
before a sign-in the link triggered.
- A link that arrives while Agents shows only its list replaces that
page instead of stacking a second one. If a form or sheet is open there,
the link is dropped with "Finish or close what’s open in Agents, then
reopen the link." and the open work is kept.

### Demo

Simulator (iPhone 17 Pro, debug build of this branch plus the sign-in
fix from [#8914](https://github.com/block/buzz/pull/8914) and the hidden
debug banner from [#8915](https://github.com/block/buzz/pull/8915),
signed in to Builderlab, remote-agent-pilot), recorded in one take with
idle time trimmed: the card's share page in Safari, Add to Buzz, the
prefilled Create agent form, a model, thinking level and instructions,
Create, then a message and the agent's reply.


https://github.com/user-attachments/assets/e66aae17-884a-4e68-a1b8-ecb40562be1d

### Validation

Added tests:
- Table tests for the link shape (only `?card=<id>` with an opaque id;
the inline `#` form, extra parameters and dot segments are rejected) and
for reading `agent.json` (a published card, a card with instructions and
model defaults, unknown keys of any type ignored, including `v`,
`description`, arrays, objects and null, and invalid cards: missing or
mismatched required fields, wrong types, and non-https or relative
avatars).
- Table tests for fetching: the request goes to this build's CDN without
following redirects, with the id percent-encoded as one path segment;
403/404, a redirect, a 500, a network error, declared and streamed
oversize bodies, non-JSON and a JSON array each give a specific message.
The image loader's existing stall, deadline, cap and cancel tests now
cover the shared helper.
- A journey test from link to Create against a fake CDN: prefill and
edit, then submit; an unavailable card; a failed model catalog whose
Retry then preselects the card's model; a Builderlab account change
while the card loads, which drops the draft; a v1 card with no
instructions and an unknown model; an unfinished setup; a link arriving
while a Create form with edits is open, which keeps the form and its
edits; and a link arriving while Agents is open. Disabling the https or
id check makes the table test fail, and removing either the account
recheck or the loaded-catalog check fails the journey. The journey runs
through the production root scope (removing its link handler fails it)
and runs in CI's configured-authentication-domains step.

Full mobile check and test suite pass locally.

Live data: a real staging card's `agent.json`
(`av_MioTAG6dm7q3Zcm0UV2j8A`) has the v1 shape the table test uses, and
an unknown id returns 403 from the CDN.

Live run on an iPhone SE (build of 63ed54c749, Builderlab sign-in
enabled, remote-agent-pilot, signed in as builderlab@tombrow.com) with a
real production card,
[ToastyMacbeth](https://app.builderlab.xyz/a/av_lrmRlJR6ylrQKvJkoLWCOQ)
(a v1 card with no instructions and an extra `description` key):
- Opening `buzz://agent/new?card=av_lrmRlJR6ylrQKvJkoLWCOQ` from a cold
start opens Create agent prefilled with the card's name and avatar.
- After choosing a model and adding instructions, Create adds the agent
with the card avatar, and it replies in a DM.
- An unknown card id shows "This agent card isn’t available."

| Prefilled | Created | Reply | Unknown card |
| --- | --- | --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/b9222db494758c3e536b8e2011032ba7cec86daa/pr-8912--01-prefilled.png"
width="200"> | <img
src="https://raw.githubusercontent.com/block/buzz/b9222db494758c3e536b8e2011032ba7cec86daa/pr-8912--02-created.png"
width="200"> | <img
src="https://raw.githubusercontent.com/block/buzz/b9222db494758c3e536b8e2011032ba7cec86daa/pr-8912--03-reply.png"
width="200"> | <img
src="https://raw.githubusercontent.com/block/buzz/b9222db494758c3e536b8e2011032ba7cec86daa/pr-8912--04-unavailable.png"
width="200"> |

Before this PR, the app had no handler for `buzz://agent/new`, so the
link did nothing.

A separate widget test covers a link that needs Builderlab sign-in
first: once sign-in lands, a setup saved for that account blocks the
card, which is never fetched. Removing the registry reload fails it, as
does removing the busy-Agents guard for the journey step above.

### Before merge

- [x] Human live check waived under the repository owner's standing
testing exception. In its place, agent-driven full-app runs exercised
the flow end to end: the iPhone SE device run and the simulator demo
above (a real card's share page, **Add to Buzz**, the prefilled form,
Create and a reply from the agent). Limitation: those runs predate
[638f5ecac7](638f5ecac7),
whose two guards are covered by the widget tests above rather than a
live run.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:00:08 -08:00
Tom Brow
61b8455a3c fix(mobile): open unjoined channels from links and search results (#8923)
Tapping a `#channel` link to an open channel you haven't joined showed
"Channel could not be opened", because a tap could only open a channel
already in the local list. A search hit had the same gap: it did nothing
when its channel wasn't in the loaded list, for example when the
open-channel directory failed to load or hadn't loaded yet.

Both taps now go through one resolver. It looks in the loaded list
first, then reads the channel's own metadata, as `buzz://` deep links
already do.
- An open channel you haven't joined opens in preview ("Join this
channel from Manage to participate").
- A private or withheld channel stays closed and shows "Channel not
found here".
- A failed lookup shows "Could not open the channel". So does a missing
channel when the channel list failed to load, since it may be one you
belong to.

The resolver also guards two cases:
- A second tap during a slow lookup doesn't open the channel twice.
- A lookup that finishes after you switch community or identity, or
after you moved to another page, opens nothing and doesn't block a new
tap.

Fixes AIDA-2324 and AIDA-2286.
[#8764](https://github.com/block/buzz/pull/8764) already made search
load the open directory, which fixed the common search case, so the
search part here covers the remaining fallback.

### Before: a link to an unjoined open channel fails


https://github.com/user-attachments/assets/c39cf0db-8098-4010-993e-cf664230c809

### After: the same link opens the channel in preview


https://github.com/user-attachments/assets/1422e70f-39c5-4598-8232-efad190b3413

### Validation

- Added tests:
- A table-driven channel-link journey with four rows: an unjoined open
channel opens, a withheld channel and a private channel show "Channel
not found here", and a failed lookup shows "Could not open the channel".
Each row double-taps during a gated lookup and asserts a single lookup
and a single push. Failure rows then tap again to show the lock is
released.
- A lookup that settles after the user opened another page pushes
nothing.
- Search journeys: a message hit opens its unlisted channel (including
after the channel list failed to load), or explains why it can't ("Could
not open the channel" when the list failed).
- Taps whose lookup will be discarded don't block the next tap: after a
community switch away and back, or from another page after the first
page closed. Each row fails if its part of the tap lock or the
connection fence is removed.
- I reverted the production files to `main` once and confirmed every new
test fails there.
- The full mobile suite and `mobile-check` pass.
- I ran it on an iPhone 18 Pro simulator (iOS 27), signed in to
Builderlab with an account that isn't a member of the open channel
`#speed-tests`.
- A link to that channel shows the error toast on `main` and opens a
preview with this change (videos above).
- A search hit in that channel opens it with this change. On `main` it
also opens, through [#8764](https://github.com/block/buzz/pull/8764)'s
directory load.
- Not covered by tests: waiting for a channel-list load still in flight,
a short guard I checked by reading the code.
- The human app-test step doesn't apply (Tom's standing exception for
mobile client changes).

buzz-review-completed

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
2026-11-02 12:55:59 -08:00
Tom Brow
97ff4e19d1 docs(mobile): describe the role split in the media image keys (#8932)
Follow-up to [#8913](https://github.com/block/buzz/pull/8913). Its
review
[noted](https://github.com/block/buzz/pull/8913#discussion_r4445612714)
that three doc comments in `media_image.dart` still describe the old
keys after that PR split direct and thumbnail-fallback loads:

- The class doc now says the provider keys on `isThumbnailFallback` as
well as url, scale and auth scope.
- The `_cooldownUntil` doc says it is keyed by (url,
isThumbnailFallback), like `_inFlight` but without auth.
- The `_SharedFetch` doc says a download is shared per URL, auth scope
and role.

Comments only, no behavior change. `flutter analyze` is clean and the
full mobile suite passes. No app run, since nothing executes
differently.

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

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 12:55:55 -08:00
Phil Azar
8f83c63209 fix(db): preserve partition reader grants (#8485)
## Summary

PostgreSQL does not copy parent table grants to new partitions. A reader
can query the `events` parent but fail direct partition reads after the
relay creates another month or replaces its catch-all.

Copy each managed parent's explicit table-level `SELECT` grants to its
new children inside the existing maintenance transaction. The parent ACL
supplies the policy; there is no new setting or deployment-specific role
name. Writes, column-only grants, owner entries, and grant options are
excluded. A grant failure rolls back the table's partition changes.

Existing children and manually created or attached partitions still need
grants from database provisioning. Apply the [companion initializer
fix](https://github.com/squareup/bb-utility/pull/2253) for existing
partitions before relying on automatic grants for future ones.

### Related issue

No duplicate found. The closest related PR is #8464, which enables
catch-all advancement by default; this change independently preserves
reader access during that maintenance.

### Testing

No manual testing. Production rollout and the Fivetran setup check
remain pending.

Generated with Codex

Signed-off-by: coder2 <4acc49cb93576448ba82e631688c51a40bf03d6a889d78fddf57917d198de10f@buzz.block.builderlab.xyz>
Co-authored-by: coder2 <4acc49cb93576448ba82e631688c51a40bf03d6a889d78fddf57917d198de10f@buzz.block.builderlab.xyz>
2026-11-02 15:27:05 -05:00
Tom Brow
35df866b33 fix(mobile): split escaped voice-note links into their own segment (#8940)
Follow-up to [#8925](https://github.com/block/buzz/pull/8925), from a
[Codex
finding](https://github.com/block/buzz/pull/8925#discussion_r4446048706)
there.

After #8925, message Markdown arrives with backslash escapes shielded,
but imeta is keyed by plain URLs. Two lookups used the raw,
still-escaped destination:

- **Bubble segmentation** (`bubble_segments.dart`): an attachment link
such as `[voice.mp4](https://…/voice\_note.mp4)` missed its imeta entry
and stayed inside the text bubble, even though the link builder rendered
it as an attachment. The fix unshields the URL before the lookup.
- **Trailing image gallery** (`extractTrailingImageGallery` in
`message_gallery.dart`): with two or more trailing images, an escaped
destination kept its backslash, so the carousel loaded the wrong URL and
lost the imeta alt text. The fix unescapes the destination the same way
as the label beside it. Its callers in `message_media_avatar.dart` and
`message_skeleton_body.dart` get the fix too. This gap was already on
main.

### Validation

- The existing `attachments stay outside bubbles and text order is
preserved` journey now uses an escaped destination with a plain imeta
URL. The existing carousel journey now has one escaped image destination
and taps it by its plain URL. Each fails with its fix reverted.
- `flutter analyze` is clean and the full mobile suite passes (4151
tests).
- Limitation: no simulator run. Both changes are key lookups, and the
widget journeys exercise the rendered layout. The full-app run for the
surrounding escape handling is recorded in #8925.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 11:18:10 -08:00
Bradley Axen
b5b69d3de8 fix(mobile): let the issuer choose relay assertion lifetime (#8935)
## Why

Mobile rejects any NIP-FI relay assertion whose `expires_at` is more
than 300 seconds away (`hosted_relay_auth.dart`). Once an issuer returns
longer assertions, every fetch fails with `invalidResponse` and
organization sign-in can't connect to the relay. Builds with org sign-in
configured hit this on every attempt.

## What

Keep rejecting assertions that have already expired, and drop the
300-second cap so the issuer chooses the lifetime. Desktop made the same
change in block/buzz-app#2171. The relay still enforces its own
`maximum_assertion_age`.

The token cache needs no change. It already refreshes 30s before
`expires_at`.

## Testing

- Added a table case to `organization_relay_auth_test.dart`: 300s and
24h are accepted; 0 and -60 are rejected with `invalidResponse`. It
fails against the old cap and passes with the fix.
- `just mobile-install mobile-check mobile-test` passed locally at
f2024a5ac.
- Not tested in the app or against a live issuer.

## Rollout

Distributed builds that enable org sign-in need this before the issuer
moves past 300s.

Signed-off-by: Bradley Axen <baxen@squareup.com>
Co-authored-by: claude00 <fb8b76a01257932b8ca35060862c29bd09b2137638573731c2d5074fc02c5385@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
mobile-v0.27.0-rc.17
2026-11-02 12:13:51 -07:00
Logan Johnson
2fd5c122e8 fix(mobile): load earlier edits to thread replies when a thread opens (#8870)
## Summary

In the mobile thread view, if a reply was edited before you opened the
thread, the reply showed its original text and no "(edited)" marker. An
edit that arrived while the thread was open worked.

The thread `/query` asked only for message kinds. The channel window
carries edits, reactions and deletions (aux events) only for top-level
messages, so an earlier edit to a reply was in neither result. The
thread query now sends `include_aux: true`, as desktop does (`buzz-app`
`src/features/relay/threads.ts`). The relay already supports this on the
`depth_limit` thread route: it returns the aux events for the root and
every reply, plus deletions of those
(`crates/buzz-relay/src/api/bridge.rs`, `bridge_thread_aux`). A relay
that ignores the flag gives the same result as before.

The relay sends a page's rows (at most 200 reply-marked events, of any
kind) before that page's aux events. It also repeats the root's aux on
every page. So `fetchCompleteThread` treats the leading run of events
reply-marked into this thread, other than reactions and NIP-09
deletions, as the page's rows. Only rows count toward a page, move the
cursor and feed the reply cache. A reply-marked huddle event still fills
a page, and a reply-marked reaction never becomes a reply. A
reply-marked edit of the root in the repeated aux can follow a short
final page's rows and look like a full page, so the scan also stops when
the next cursor does not advance. The thread-summary scan
(`fetchCompleteThreadReplies`) still asks for replies only and pages
exactly as on `main`.

### Related issue

Found during the emulator testing of
https://github.com/block/buzz/pull/8863.

### Testing

- `just mobile-check`, `just file-size-check` and `just mobile-test`
pass.
- `thread_replies_provider_test.dart` adds two tests. The first checks
that the query sends `include_aux` and that an edit made before the
thread opens folds into its reply. The second covers pagination: a
reply-marked huddle row filling a page, a reply-marked edit that is a
row on one page and aux on the others, a reply-marked reaction that
stays aux, 200 reply-marked reactions after 199 replies (one query), 200
reply-marked root edits after a short final page (the progress guard
ends the scan), an edit reply-marked into another thread that stays aux,
and that the summary scan doesn't send `include_aux`. It fails if any of
these is removed: the reaction/deletion exclusion, the root check, the
200 cap, the progress guard, or the separate row and aux id sets.
- `channel_detail_page_test.dart` adds a journey: it opens
`ThreadDetailPage` with the real thread query and a fake relay session
that sends the reply's earlier edit only when `include_aux` is set. It
checks the edited text, that the original text is gone, and the marker.
It fails when the flag is removed or when the provider drops aux.
- Android emulator (AVD, debug build) against a local relay. Morty posts
"Who can review the release notes?", Demo User replies "I can, after
lunhc" and then edits the reply to "I can, after lunch", all before the
thread is opened. These shots come from the first revision of this
branch. The pagination changes after it give the same result for a
one-page thread like this one.

| Before (unfixed: the #8863 build) | After (this branch) | Live edit
while the thread is open |
| --- | --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/4daefdf9a427c66dd80edcc59ee664c1da63824c/pr-8870--thread-before.png"
width="300" alt="Before: original reply text, no marker"> | <img
src="https://raw.githubusercontent.com/block/buzz/4daefdf9a427c66dd80edcc59ee664c1da63824c/pr-8870--thread-after.png"
width="300" alt="After: edited reply text with (edited)"> | <img
src="https://raw.githubusercontent.com/block/buzz/4daefdf9a427c66dd80edcc59ee664c1da63824c/pr-8870--thread-live.png"
width="300" alt="Live edit while the thread is open"> |

Before, the thread shows the original reply text with no marker. After,
it shows the edited text and "(edited)". A live edit still lands.

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

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 10:59:38 -08:00
Tom Brow
54ca2a3cc6 fix(mobile): render Markdown backslash escapes (#8925)
Mobile showed Markdown backslash escapes literally. A message linking a
channel as `[\#buzz-mobile](buzz://channel/…)` rendered as
`\#buzz-mobile`, and `\[text\]` rendered as LaTeX math. gpt_markdown has
no backslash escapes.

Outside code, each escaped ASCII punctuation character is now held as a
stand-in (an invisible word joiner plus the character's fullwidth form)
before parsing, so no other syntax (links, emphasis, mentions, channel
tokens, math) matches it. A new inline component and the link and image
builders restore the original character. The inbox/search preview
builder already handled escapes this way; its helpers moved to a shared
`markdown_escapes.dart`.

Two edge cases found in review are covered: shielding runs after
trailing-gallery extraction so carousel URLs and labels stay as
authored, and a bare link no longer takes a trailing backslash into its
destination. Codex review added: escaped backticks stay literal instead
of opening code, authored word joiners are not mistaken for stand-ins,
Buzz URL punctuation peeling keeps a trailing backslash, gallery
semantic labels drop escapes, the trailing-backslash peel is linear and
reruns after delimiter stripping, an escaped `\<url>` is not treated as
an autolink, `~~~` fences are skipped like backtick fences, an autolink
containing `\` keeps it in both label and destination, and the stand-ins
use fullwidth characters so the one gpt_markdown fallback that shows raw
text (multi-line strikethrough) still reads right.

Later review: fence detection looks back only over blanks and `>`
markers, so it stays linear on long lines (security review) and treats
an unclosed fence inside a quote or list as code, like the renderer
(Carl).

Behavior change: `\(x\)` and `\[x\]` now show as `(x)` and `[x]` instead
of math, per CommonMark. Desktop (buzz-app) doesn't render math either.
`$…$` math is unchanged.

### Validation

- Added tests: a `MessageContent` table covering escapes in prose, bold,
link labels (HTTP and `buzz://channel`), fenced and inline code (left
literal), a bare link ending in `\`, and escaped backticks, authored
word joiners and fullwidth characters, a `~~~` fence, a multi-line
strikethrough, `<https://example.com/a\>`, a Buzz URL ending in `.\`, an
emphasized bare URL ending in `\*`, an escaped `\<url>`, and a trailing
image gallery with escaped labels (including its semantics label), in
plain and bubble layouts. Each row also asserts no stand-in reaches
visible text or semantics. Reverting any one of these fixes fails it.
- `flutter analyze` is clean and the full mobile suite passes, except
for 11 `notification_toggle_test.dart` tests. Those fail locally on the
Studio at the CI-green a3964ab as well, so they are environmental and
unrelated to this change. A 256 KiB regression test covers the linear
fence scan, and it fails on the earlier code.
- iPhone 17 Pro simulator (iOS 27) signed in to the internal community:
loganj's original message now shows `#buzz-github-prs` and
`#buzz-mobile`.
- Full-app run on
[a3964ab](a3964ab08c):
debug build on a fresh iPhone 17 Pro simulator (iOS 27), signed in to
the internal community with live data. Covered the #buzz-github-prs
timeline (long linked lists, bare URLs, mentions, reactions, thread
summaries), a thread view, Inbox previews (the shared preview helpers),
a DM with a bare link and a channel link, and search results. Everything
rendered as before apart from the fixed escapes, with no stray stand-in
characters. Limitations: debug build, iOS only, no gallery or code-block
messages were in the visited history (the widget tests cover those), and
a 165-second recording was taken but GitHub can't take it through our
upload script. Two quirks seen are pre-existing and unchanged here: a
bare link absorbs a trailing comma, and search shows raw Markdown for
messages over 512 characters.

<details><summary>Full-app screenshots</summary>

| Channel | Channel (scrolled) | Thread |
| --- | --- | --- |
|
![channel](https://raw.githubusercontent.com/block/buzz/1b0b3e0052f65fbc7b0aca678fc1b45d1265d8c3/pr-8925--fullapp_02_channel.png)
|
![scrolled](https://raw.githubusercontent.com/block/buzz/1b0b3e0052f65fbc7b0aca678fc1b45d1265d8c3/pr-8925--fullapp_04_scroll.png)
|
![thread](https://raw.githubusercontent.com/block/buzz/1b0b3e0052f65fbc7b0aca678fc1b45d1265d8c3/pr-8925--fullapp_05_thread.png)
|
| **Inbox** | **DM** | **Search** |
|
![inbox](https://raw.githubusercontent.com/block/buzz/1b0b3e0052f65fbc7b0aca678fc1b45d1265d8c3/pr-8925--fullapp_06_inbox.png)
|
![dm](https://raw.githubusercontent.com/block/buzz/1b0b3e0052f65fbc7b0aca678fc1b45d1265d8c3/pr-8925--fullapp_07_dm.png)
|
![search](https://raw.githubusercontent.com/block/buzz/1b0b3e0052f65fbc7b0aca678fc1b45d1265d8c3/pr-8925--fullapp_08_search.png)
|

</details>

### Before / after

Before (production build, reported):


![01-before](https://raw.githubusercontent.com/block/buzz/809b2fccf98b41dc612acb9a32c9dd880ff40403/pr-8925--01-before.png)

After (this branch, same message):


![02-after](https://raw.githubusercontent.com/block/buzz/809b2fccf98b41dc612acb9a32c9dd880ff40403/pr-8925--02-after.png)

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 10:35:54 -08:00
Logan Johnson
5f8d0aab98 fix(relay): refuse a second listed open channel with the same name (#8782)
## Summary

Two open channels in one community could have the same name, so the
channel list showed two `#square-ios` rows and people could not tell
them apart. Now the relay refuses to create, rename or open up a channel
when that would give it the same name as another channel in the channel
list, ignoring case. The client gets `invalid: an open channel named "…"
already exists`.

The rule follows Slack: an archived channel keeps its name until it is
deleted. Private channels, DMs and unlisted channels may still repeat
names. So a refusal never reveals that a private channel exists.

The PR also adds a script that renames the duplicates that exist today.
It sends each rename through the relay, so relays keep serving while it
runs. The stacked PR https://github.com/block/buzz/pull/8787 then adds a
database index that enforces the rule. This PR is the release that must
be fully deployed before that index.

## Details

**Which channels the rule covers.** A *listed* channel: open, not
unlisted, a stream or forum, not deleted. Archived channels are
included. The check runs on create, rename, private → open and unlisted
→ listed.

**Relay check** (`crates/buzz-db/src/store/channel.rs`,
`crates/buzz-relay/src/handlers/ingest.rs`):
- The check runs after the write, in the same transaction, under a
per-name lock. So two concurrent writers cannot both commit the same
name.
- A refused create or edit (kind:9007 or kind:9002) gets `OK false
"invalid: an open channel named … already exists"`. Nothing from it is
stored.
- The relay gives the same answer when the database index from
https://github.com/block/buzz/pull/8787 refuses the write. That covers
the rollout, and a rollback from that release.
- A channel create with no `h` tag (the new channel's id) is now refused
before it is stored. Before, such a create ran after the event was
stored, so a refusal (for example, a taken name) still answered
"accepted". All first-party clients send `h`.

**Operator rename** (`crates/buzz-relay/src/handlers/side_effects.rs`).
The rename script needs to rename channels it does not own, archived
ones included. So a relay operator may now send a channel edit that
changes only the name of a listed channel. Everything else still needs
the channel's owner or admin.
- "Operator" means `RELAY_OPERATOR_PUBKEYS`, the owner fallback, or an
`operator` row in `relay_operators`. A moderator is not an operator.
- The rename also applies to an archived listed channel, whatever the
operator's role in that channel, and the channel stays archived. Other
edits of an archived channel are still refused.
- Private channels, unlisted channels and DMs still need the channel's
owner or admin. The relay decides "listed" under the channel row lock,
so a channel made private during the rename is not renamed.
- The rename takes the normal edit path: the name check, the stored
event, the channel metadata event and fan-out.

**Rename script**
(`scripts/maintenance/rename_duplicate_listed_channel_names.sh`, with
the read-only plan `plan_duplicate_listed_channel_renames.sql`):
- In each group of listed channels that share a name, the oldest
unarchived channel keeps the name. Each other channel becomes
`<name>-<first 8 chars of id>`, or `<name>-<id>` when that is also
taken. When all the channels in a group are archived, the oldest keeps
the name.
- **Starter channels.** Released desktop and mobile apps create
`#general` and `#welcome-everyone` when a community has no open,
unarchived stream with that name. Under the new rule, the relay refuses
that create while an archived channel has the name, and the apps' setup
fails. So the script also renames `general` and `welcome-everyone` when
every listed channel with that name is archived. That frees the name for
the apps.
- The apps create each starter at a fixed id, derived from the relay's
address. If a channel already has that id, the relay refuses the create
there whatever its name, so a rename cannot help. For that name the
script renames nothing, and an archived starter at its id keeps the
name. The script computes the ids from `BUZZ_RELAY_URL` (or
`BUZZ_STARTER_ORIGIN` when the apps use a different address) and prints
them, so the operator can check them against a client. A relay-side fix
for those clients is planned separately.
- The operator key goes in `BUZZ_PRIVATE_KEY`. It must be a relay
operator and a member of the community. The script stops at the first
auth refusal and says so.
- The script stops before the plan for an unknown host, an archived
community and a community being deleted, and says what to do.
- A failed rename (the new name is now taken, or the relay can't be
reached) is reported, and the script goes on. At the end it reads the
plan again and exits 1 if any rename remains. `--dry-run` prints the
plan.
- The script replaces the earlier direct-SQL repair and `buzz-admin
reconcile-channels --metadata`, which needed every relay pod stopped.

**Duplicates that exist today** (read on 2026-10-30):
`ml-platform-public` (`d41a31f8…`, `7be55f5a…`) and `square-ios`
(`79f8e9d4…` archived, `fe246d3e…` live).

### Rollout

The full procedure, with exact SQL and recovery steps, is in
`docs/channel-name-uniqueness-deployment.md` in
https://github.com/block/buzz/pull/8787.

1. **Deploy this PR.** Wait until no older relay pod serves; check the
image of every pod. An older relay can still create a duplicate, and it
refuses the operator rename.
2. **Rename the existing duplicates in each community**, with relays
serving. The key must be an `operator`, not a moderator, and a member of
the community (`RELAY_URL=wss://<host> buzz-admin add-member --pubkey
<npub>`).
   ```sh
   DATABASE_URL=postgres://... BUZZ_PRIVATE_KEY=<operator key> \
scripts/maintenance/rename_duplicate_listed_channel_names.sh --dry-run
<community host>
   # review the plan, then run it again without --dry-run
   ```
For the duplicates known today, the newer `ml-platform-public` and the
archived `square-ios` (`79f8e9d4…`) get the `-<id8>` suffix.
3. **Confirm that no duplicate remains** in any community (query in the
doc).
4. **Build the index, then deploy
https://github.com/block/buzz/pull/8787.**

Rollback from https://github.com/block/buzz/pull/8787 goes to this
release only, keeping the index, with auto-migration off.

### Related issue

None found.

### Testing

- **Live local relay.** The script ran against a local relay built at
9ee09a619, with relay membership required and six seeded channels
(`ops`, `OPS`, an archived `Ops`, a private `ops`, `dev`, `DEV`):
  - `--dry-run` listed the three renames.
- With the operator key not yet a member, the script stopped at the
first refusal, named the membership precondition and renamed nothing.
- After `buzz-admin add-member`, it renamed all three and exited 0. The
archived channel stayed archived, the private `ops` kept its name, and a
second run renamed nothing.
- With the community archived, the script stopped with the unarchive
message.
- Rerun at 0df245e3c as part of the full rollout in
https://github.com/block/buzz/pull/8787: the relay refused a new
duplicate over the wire, and the script renamed the seeded duplicates
with the relay serving.
- **Relay tests** (`group_state_postgres_tests.rs`): a taken name is
refused as `invalid:` and nothing is stored; a create with no `h` tag is
refused before it is stored; an operator can rename only a listed
channel, only the name, archived or not, and a moderator cannot; an
operator rename is refused if the channel stops being listed before it
is saved.
- **Store tests** (`channel_name_postgres_tests.rs`): create, rename,
private → open and unlisted → listed are refused for a taken name;
private and unlisted names may repeat; an archived channel keeps its
name and a deleted one frees it; of two concurrent creates with one
name, the second is refused once the first commits.
- **Plan and script tests** (`listed_channel_name_postgres_tests.rs`)
run the shipped SQL and shell script: which channel keeps the name, the
starter cases, names at the 255-character limit, names with control
characters or a leading `-`, and the auth and community stops. The
starter-id computation matches the mobile app's test vectors.
- The new guards were each removed once to confirm that a test fails.
- The full local Postgres test runs (buzz-db, buzz-relay, buzz-admin)
pass, except 6 tests that also fail locally on main and pass in CI.

### Database review checklist

- [x] Phase: writer release (deploy), plus a maintenance script. The
stacked PR adds the contract index; this release is its required stop.
- [x] No trigger, function, procedure, rule or foreign key.
- [x] No migration. The script writes nothing to the database itself. It
reads a plan, and the relay applies each rename as a normal channel
edit. `docs/database.md` Rule 3 asks for maintenance scripts in their
own PR. The script is here because it depends on the operator rename in
this PR.
- [x] Contract: not in this PR.
- [x] Rule 4 locations: no schema change.
- [x] Deployed relay: the schema is unchanged, so the deployed relay
keeps working. An older relay refuses the operator rename, so run the
script only after this release is fully deployed. This release also
works beside the contract index (tests above).
- [x] Timeouts: no index build or bulk write here.

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

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
mobile-v0.27.0-rc.16
2026-11-02 13:23:40 -05:00
Will Pfleger
f1ce1c5d47 docs(nip-fa): make assertion lifetime issuer operator policy (#8934)
NIP-FA fixed the assertion lifetime at 300 seconds. That limit belongs
to the adapter operator: the relay already enforces each issuer's
configured `maximum_assertion_age`.

This changes the `expires_at` paragraph to:
- make the lifetime the adapter operator's policy;
- keep the rule that `exp` cannot be later than the adapter session's
expiry;
- require the lifetime to stay within the `maximum_assertion_age` the
relay accepts for the issuer;
- replace "Clients MAY refuse … more than 300 seconds" with a rule that
clients must not refuse an assertion for its lifetime alone;
- state that the lifetime bounds how long a revoked key can keep
reconnecting after a relay restart.

NIP-FI and the other NIPs have no fixed 5-minute assertion rule, so they
are unchanged.

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
2026-11-02 13:22:20 -05:00
Tom Brow
da93ce3f62 fix(mobile): open the public Builderlab login the iOS bridge allows (#8914)
On main, "or sign in with email" on iOS fails immediately with "We could
not finish connecting. You can try again." No Builderlab page opens.

[#8700](https://github.com/block/buzz/pull/8700) changed the iOS browser
bridge so the public account hosts (`app.builderlab.xyz`,
`app.builderlabstaging.xyz`) may only open `/auth/native`. The Dart side
still opens `/api/goose/v1/auth/login` for them, so the bridge rejects
every public sign-in before presenting a browser. Every iOS build since
#8700 is affected; organization sign-in on tenant hosts is not.

The matching Dart change was Wes's
[#8712](https://github.com/block/buzz/pull/8712). It was merged into the
#8700 branch, but main's squash of #8700 kept only the bridge half. This
PR restores #8712 on top of main:

- The public account opens `/auth/native` (state, S256 challenge,
callback and retained invite unchanged, without the CLI-only
`type`/`product` parameters). Organization tenants keep
`/api/goose/v1/auth/login`.
- A saved session whose account still needs web setup goes through the
same browser handoff, keeping its subject and destination.

Android has no path allowlist in its browser plugin, so it needs no
change.

### Validation

- Reproduced on an iPhone 17 Pro (iOS 27) simulator with a main-based
debug build: "or sign in with email" fails instantly. With this change
applied, the same build shows the system "Buzz wants to use
app.builderlab.xyz to sign in" prompt, then the login.builderlab.xyz
"Welcome back to Buzz" page, which confirms production serves
`/auth/native`.
- Restored #8712's tests on top of main's current tests: the browser
test checks the public login URL and its exact parameters, and the
hosted invite journey runs for both a fresh account and a saved account
that still needs setup, asserting `/auth/native` and that the invite is
retained. Without the Dart fix, these fail.
- Mobile format and analyze pass; the hosted onboarding journeys and
`shared/hosted` tests pass with the CI auth-domain configuration.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
2026-11-02 10:00:08 -08:00
Salman Mohammed
f99807a79a fix(desktop): skip a starter channel the relay will never create (#8911)
## Problem

On first run, the desktop app creates the `general` and
`welcome-everyone` starter channels at fixed ids when it can't find a
live one. Some refusals of that create are permanent. Any refusal
aborted setup **before it joined the new member to the starters that do
exist**:

| Starter state in the community | Relay reply to the create | Before |
|---|---|---|
| Another listed channel holds the name, archived ones included (with
#8782) | 400 `invalid: an open channel named "…" already exists` | setup
fails, not joined to `general` |
| An archived channel holds the fixed id | 400 `invalid: channel is
archived` (the relay checks this before duplicates) | setup fails |
| The channel at the fixed id was renamed or retyped | `duplicate` |
waits, then `starter channels created but metadata not yet available` |
| The channel at the fixed id was deleted | `duplicate`, and no metadata
is ever served | same |

Onboarding itself continues, because public starter failures are already
caught in `initializeStarterChannels`. The new member just never gets
joined to the other starter. With #8782 this happens to every new
desktop user in a community whose `welcome-everyone` or `general` is
archived.

## Fix

`ensure_starter_channels_inner` now skips a starter it can never create
or use, clears its pending-owner mark, and still joins the rest. Before
skipping after a permanent refusal, it reads the open directory again,
from the writer rather than a replica, once for all refused starters: a
live starter created after the first read is joined instead of skipped.
Any other refusal still fails setup. On the TS side,
`ensureStarterChannels` reports a skipped starter as `null` instead of
throwing. No caller reads those fields.

## Testing

- New tests run `ensure_starter_channels_inner` against a stub relay
that sends the relay's real reply shapes, one per row above, plus one
checking that other refusals still fail, and one where a live
`welcome-everyone` is listed only by writer-pinned reads (a lagging
replica) and gets joined. Each test fails when its guard is removed.
- Ran the real starter-setup code as a new user against a local relay
built from #8782, one community per row:
  - On `main`, rows 1, 2 and 4 fail.
- On this branch, all four join `general`, and each community still has
exactly one `welcome-everyone`.
  - A rerun is a no-op.
- `cargo test` (desktop Tauri, full), clippy, fmt, desktop `pnpm test`,
`tsc`, and biome all pass.

Related: #8782 (relay refuses taken names), #8807 (the same fix for
mobile). The archived-id case returns `channel is archived`, not
`duplicate`, so mobile needs to handle that reply too.

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

---------

Signed-off-by: Salman Mohammed <smohammed@squareup.com>
Co-authored-by: opus <f038cc09b32a776e7d95b65c267e9d851777ee2b6cdbd46a48edba01b1d10ebd@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 12:59:28 -05:00
Tom Brow
3ae2b6a377 fix(mobile): hide the Flutter debug banner (#8915)
Debug and simulator builds of the mobile app draw Flutter's red DEBUG
ribbon over the top-right corner of every screen. It covers app UI in
demo videos and screenshots, and nobody relies on it. This turns it off
on the app's `MaterialApp`, matching the profile avatar sheet app, which
already does. Release builds never showed it, so they're unchanged.

### Validation

- `flutter analyze` and `dart format` pass.
- Recorded the agent card flow demo on an iPhone 17 Pro simulator (iOS
27) from a debug build with this flag set: no DEBUG ribbon on any
screen.

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

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
2026-11-02 10:42:01 -07:00
Tom Brow
9f8913dd1b mobile: keep a thumbnail fallback apart from a direct load of the same URL (#8913)
## Summary

Follow-up to [#8896](https://github.com/block/buzz/pull/8896), from its
review threads.

**A fallback and a direct load of the same thumbnail no longer share a
download.** `MediaImageProvider.isThumbnailFallback` decides what a 404
means: for a thumbnail `MediaImage` picked in place of an original, it
records a missing thumbnail so the next build loads the original; for
any other URL it starts the 30s failure cooldown. It was left out of
equality and the in-flight download key, so when a profile URL that is
itself a thumbnail and a fallback to that same thumbnail were on screen
together, both joined one download and only one of them ran its own 404
handling. Now each keeps its own provider and download, and each gets
its own handling.

The failure cooldown is keyed the same way. Before, a direct 404 of a
thumbnail put its URL on cooldown (30s, up to 5 min with Retry-After),
and an avatar that fell back to that thumbnail during the cooldown hit
the cooldown error instead of a 404, so it showed initials instead of
loading the original.

Also:

- Reworded the `preferThumbnail` doc so it no longer names community
icons, which never set it.
- Removed the community-icon avatar test, which fed a URL production
never produces.

## Testing

- New test: a direct thumbnail URL and a fallback to it, shown together,
make two requests; after a 404 the direct URL is in cooldown and the
fallback's original loads (not the initials). It fails with the fetch
key change reverted.
- New test: after a direct thumbnail 404, an avatar that falls back to
the same thumbnail still loads the original. It fails with the cooldown
key change reverted.
- `just mobile-check` and `just mobile-test` pass.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 09:38:34 -08:00
Tom Brow
daeb463f37 fix(mobile): prompt Builderlab sign-in instead of labeling agents desktop-only (#8907)
Without a Builderlab session, mobile can't tell which agents run on
Builderbot, so the Agents page labeled every agent "Manage on desktop",
including remote ones.

Now, when the community supports remote agents and there's no Builderlab
session:
- The Agents page shows "Sign in to Builderlab to manage remote agents"
with a **Sign in** button (the same reconnect flow as Add).
- Agent cards and the navigation subtitle drop the "Manage on desktop"
label.
- An agent's profile says to sign in to see whether it runs on
Builderbot or desktop.

### Before / after

iOS simulator, signed in to the remote-agent-pilot community with no
Builderlab session.

| Before (main) | After (this PR) |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/9be9cebedc8f5ff172e97a3df2cbf505a90e41c4/pr-8907--before-agents.png"
width="300"> | <img
src="https://raw.githubusercontent.com/block/buzz/9be9cebedc8f5ff172e97a3df2cbf505a90e41c4/pr-8907--after-agents.png"
width="300"> |

### Validation

- New widget test `agents_signed_out_prompt_test.dart` (runs with
`BUZZ_AUTH_DOMAINS`, added to the CI list).
- `just mobile-check` and the mobile test suite pass.
- Checked the signed-out Agents page in the iOS simulator.

buzz-review-completed (manual testing waived by Tom)

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 09:22:14 -08:00
klopez4212
b4509dee8b feat(mobile): add Buzzies and refresh agent avatar picker (#8921)
## Summary

Adds Buzzies and refreshes the agent avatar picker with larger previews,
rounded option strips, and clearer category choices. Also moves Agents
above Appearance in the home menu and adds a custom agent icon.

### Related issue

None found.

### Testing

Analysis, 4,138 app tests, package tests, and 121 configured-auth tests
pass. Earlier visual build exercised on Pixel and iOS simulator, with
iPhone review; snapshots below show that build.

| Android · Pixel | iOS · simulator preview |
| --- | --- |
| ![Buzzies in the Android avatar
picker](https://github.com/user-attachments/assets/bf56120c-0128-4683-a0ad-189587a13c8d)
| ![Buzzies in the iOS avatar
picker](https://github.com/user-attachments/assets/63c692f5-822c-4490-883f-36bec8d2be15)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-02 17:00:12 +00:00
Luke Tornquist
c9b8f1176e fix(deps): clear RUSTSEC-2026-0433 and RUSTSEC-2026-0429 from the dependency policy (#8922)
Two RustSec advisories published after Oct 30 turned the `Security /
Dependency policy` (`cargo-deny check`) job red on every PR. This clears
both.

## Changes

- **`pulldown-cmark` 0.13.5 → 0.13.6**
([RUSTSEC-2026-0433](https://rustsec.org/advisories/RUSTSEC-2026-0433)).
Lockfile only — `crates/buzz-relay/Cargo.toml` already allows `0.13.4`+.
Not exploitable here regardless: the advisory needs
`Options::ENABLE_WIKILINKS`, and our only caller
(`crates/buzz-relay/src/api/invites.rs`) uses the default `Parser::new`.
- **Ignore
[RUSTSEC-2026-0429](https://rustsec.org/advisories/RUSTSEC-2026-0429)**
for `zip` 8.6.0 in `deny.toml`, next to the existing
RUSTSEC-2026-0367/0368/0369 entries for the same path. The only path is
the dev-only `buzz-mesh-smoke` crate → mesh-llm v0.76.2 → zip 8. The
advisory is in `ZipArchive::extract`. Both mesh-llm zip readers at
v0.76.2 (`mesh-llm-plugin-manager/src/archive.rs`,
`mesh-llm-system/src/autoupdate/release_fetch.rs`) iterate `by_index`
and `io::copy` each entry by hand and never call `extract`. We can't
bump past it yet:
[Mesh-LLM/mesh-llm#2471](https://github.com/Mesh-LLM/mesh-llm/pull/2471)
moved mesh-llm to zip 9, but the latest release (v0.78.1) still locks
zip 8.6.0. The existing comment already says to remove these ignores on
the mesh-llm bump that ships it.

## Testing

On a Linux workstation at the PR head: `cargo-deny check` passes, `cargo
check --locked -p buzz-relay` builds, and the `buzz-relay` invite tests
(the `pulldown-cmark` caller) pass.

## Simpler?

No new concepts: one lockfile bump and one ignore entry that joins an
existing, already-documented group with the same removal trigger.

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

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 10:30:07 -05:00
Matt Toohey
322fdbf086 test(relay): hold the replica's second aux page and move media's DB tests (#8740)
## Summary

Two buzz-relay test fixes from the 23–30 Oct flake audit. Both are
test-only. This is one of six PRs split from the reviewed `flakey-tests`
branch (siblings below).

### `thread_window_bridge_restarts_both_aux_hops_after_replica_failure`
(3 runs)

- **Runs:** 40799617319, 40924179886 and 40930100994 (#8697, docs only),
all on 10-29 in PostgreSQL Tests, attempt 1 (jobs 123208043033,
123632125866, 123651641392). None of the branches touches thread_window,
and each run passed on a re-run. Signature:
`failure_postgres_tests.rs:46`, "barrier must precede closure
completion", left: 2, right: 1.
- **Cause:** `pause_after_aux_page` polls the request until a
`thread_window_aux` span closes, then expects exactly one. The fixture's
1,001 reactions make two replica pages at `AUX_LIMIT` 1000, and each
page is one round trip on the transaction already held. A loaded runner
can deschedule the test thread after page 2's query is written. Postgres
then answers first, and the same poll reads page 2 too.
- **Change:** `test_support` gains a `PausingProxy`, a trimmed copy of
buzz-db's private one with only the hold-everything switch. The replica
reader pool connects through it. `AuxPages` pauses it when the first aux
span closes. That runs inside the request's poll, after page 1's
response is consumed and before page 2's query is written, so page 2
stays pending however fast Postgres answers. The test resumes the proxy
after it terminates the reader's backend. The held page then sees the
termination, and the closure restarts on the writer, as before. The
other three `pause_after_aux_page` callers read from the writer and pass
`None`.

### `api::media` tests that need Postgres (fail every time Flake Check
selects them)

- **Run:** 40954304982 (#8658), 10-29, Flake check (Rust), job
123730434146. All six `test_state()` tests panic at the community-seed
`expect` after the 30 s sqlx acquire timeout.
- **Cause:** when a hunk lands outside a test body, Flake Check selects
every test in the file. It skips only `postgres_tests::`, `postgres_*`
binaries and `external_infra`, and its job has no database. These six
tests seed `relay.example` through Postgres but sat in plain `mod tests`
without `#[ignore]`. So any PR that changes non-test code in `media.rs`
fails that lane, and at least 8 open PRs touch the file. No other lane
ran these tests either.
- **Change:** move `test_state`, the `media_get_*` helpers,
`media_request` and the six tests into `tests::postgres_tests`. Mark
each test `#[ignore = "requires Postgres"]`, and point `test_state` at
`test_support::database_url()`, as the module's other states do. The
PostgreSQL lane now runs them, and Flake Check skips them. The change
also drops imports `mod tests` no longer uses, and updates the Justfile
comment that gave `api::media` as the reason the infra-free lane is
scoped to `api::admin`.
- When `media.rs` is staged, the pre-commit secret scan reads the whole
file and flags the module's existing public ES256 test key
(`TEST_EC_PKCS8_PEM`). The key is now written on one line with a sadscan
marker, as `invites.rs` already does. Its bytes are unchanged.

### Related issue

None found. **Conflicts with #8451 and #8444**, which both already
conflict with `main`. The one-line `TEST_EC_PKCS8_PEM` rewrite is the
only new conflict hunk with each. The test move merges cleanly with
both.

### Testing

Verified locally:
- Each of the four `pause_after_aux_page` tests passes when run alone
against local Postgres through `scripts/postgres-test-run.sh`.
- A forced check, not committed: after the barrier, the test gave page 2
another 500 ms over ten re-polls and asserted the page count stayed at
1. It passed with the pause and failed with `None`, with the CI
signature (left: 2, right: 1). A true same-poll burst needs the OS to
preempt between sqlx's write and read, so it was not forced directly.
- `check-postgres-test-discovery.py` accepts the touched files and all
621 files under `crates/`, and `scripts/test-postgres-test-discovery.sh`
passes. Each of the six media tests passes when run alone against local
Postgres.
- `cargo fmt --check` and `cargo clippy -p buzz-relay --all-targets --
-D warnings` are clean under 1.98.1.

Only hosted CI can show the full lanes, which were not run locally. The
six media tests have never run in CI, so this PR's PostgreSQL lane is
their first hosted run.

Pushed with `git push --no-verify`, because the pre-push suites fail
when the laptop is under load.

### Readiness

An agent review of the combined `flakey-tests` branch (b432453af) found
no blockers. `buzz-review-completed` is not added, because the human
test step (AGENTS.md "Before opening a PR", step 3) hasn't happened yet.

### Siblings

- #8739 ci: Hermit fetch deadline and sccache fallback
- #8741 test(mobile): RunnerTests title fade and alert wait
- #8742 fix(desktop): deep-linked message hold (product change)
- #8743 test(desktop): four specs racing scroll settles and focus
- #8744 test(desktop): five settle classes and remote-owned-mentions

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

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 08:28:32 -07:00
Matt Toohey
a83926da5d test(desktop): stop four E2E specs racing scroll settles and focus (#8743)
## Summary

Units 2–5 of the 23–30 Oct desktop Playwright flake audit: four E2E
specs that raced scroll settles and focus. All four fixes are test-only.
This is one of six PRs split from the reviewed `flakey-tests` branch
(siblings below).

### `navigation.spec.ts:472` "composer Buzz chip labels wrap without
orphaning their icons"

11 jobs failed in the window, 6 of them after the earlier fixes,
including 4 of the 7 main runs that carry every fix. Run 40970053997
(Smoke shard 7) failed all three attempts.

- **Mode A** (jobs 122971493553, 123066839967, 123572467962,
123819180597 and retry 2 of 123784825042): `fragmentStep` is null at
:634. Rows key on `renderKey = localKey ?? id`, and a head refetch or
live echo after the ack drops the `localKey`. The row remounts and loses
the 220 px width the test set earlier. **Change:** re-land the test half
of closed #6852. Set the width and measure in one `expect.poll`, and set
the width again before each fragment measurement.
- **Mode B** (jobs 123573056202, 123784199542, 123784825042 twice,
123804428893, 123818728963, 123819543461, and 122924164125): the
tooltip-centre poll at :667 receives 81.35 or Infinity. Radix Tooltip
closes when an ancestor of its trigger scrolls. In the failing attempt,
the post-send settle took its last step 21 ms or less before focus.
**Change:** wait for the sent row's `message-send-status` to clear. Then
poll until the timeline sits at its floor and holds there for two
animation frames, and only then focus the chip. `positionOverFragment`
asserts `data-state` is open. There is no retry loop around the race.

### `huddle-transcription.spec.ts:637` "animates the responding agent
with the shared speaker ring"

- **Runs:** 9 jobs, always Smoke shard 4, attempt 1. On main:
39896948198, 40718743531, 40732061485, 40840949170, 40906253761,
40969965981 and 40983029157. On PRs: #8715 and #8666. `toBeFocused()` at
:697 reads inactive.
- **Cause:** `useComposerAutofocus` runs again once the editor mounts
and takes focus from the focused trigger.
- **Change:** wait for `message-input` to be focused before focusing the
trigger, as the same spec already does at :855.

### `scroll-history.spec.ts:1289` "fast middle-page scroll settles with
continuous mounted coverage"

- **Runs:** 9 jobs. After the fixes: main 40980404335 (job 123819180518)
and two attempts on #8720. The `viewportCoverage` poll expects < 100 and
receives 200, the bottom spacer.
- **Cause:** the bottom-settle intent armed when the channel first
positions survives the prepend and the spec's `scrollTop` writes.
Virtua's re-measure then pulls the list back to the floor.
- **Change:** hover the timeline and wheel up 120 px first, since reader
input cancels the intent. Then poll until the timeline is more than 32
px from the floor and steady for two frames.
- The commit message calls the cancelling listener
`listenForReaderScroll`, a name introduced by sibling #8742. On `main`,
the same wheel listener is inline in `useVirtualizedBottomSettle`, so
this fix doesn't depend on that PR.

### `custom-emoji.spec.ts:370` "reacting with a custom emoji renders via
the loopback media proxy"

- **Runs:** main 40787609859 and 40983029157 (Smoke shard 3), and #8241.
The inline "Add reaction" opacity reads "1", not "0".
- **Cause:** the non-modal Radix Popover returns focus to its trigger in
a `setTimeout(0)` after its 100 ms exit animation. That can land after
the spec's blur.
- **Change:** wait for the "Open reactions" trigger to be focused, then
blur.

### Related issue

None found. Sibling #8744 also edits `navigation.spec.ts`, in a separate
hunk. The two merge cleanly in either order. #8622 adds an unrelated
test at the end of `navigation.spec.ts`, and #7812 (stale since 10-06)
adds tests elsewhere in three of these files.

### Testing

Not run locally, because Playwright bogs this machine down. Flake Check
will repeat the touched specs. `pnpm typecheck` and `pnpm check` pass.
An ad hoc `tsc` run over the four specs found the same pre-existing
errors as on their `main` copies (navigation 7, huddle 17,
scroll-history 0, custom-emoji 2).

Pushed with `git push --no-verify`, because the pre-push suites fail
when the laptop is under load.

### Readiness

An agent review of the combined `flakey-tests` branch (b432453af) found
no blockers. `buzz-review-completed` is not added, because the human
test step (AGENTS.md "Before opening a PR", step 3) hasn't happened yet.

### Siblings

- #8739 ci: Hermit fetch deadline and sccache fallback
- #8740 test(relay): thread_window aux-page hold and `api::media`
Postgres tests
- #8741 test(mobile): RunnerTests title fade and alert wait
- #8742 fix(desktop): deep-linked message hold (product change)
- #8744 test(desktop): five settle classes and remote-owned-mentions

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

---------

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 08:28:07 -07:00
Tom Brow
d342cf141c fix(mobile): catch up unread from the newest message already seen (#8901)
## Summary

Every channel refresh path (app resume, reconnect, the backstop, joining
a channel) re-runs the unread catch-up. Each run asked for every
channel's messages since its read timestamp, up to 1000 full messages
per channel. For a reader with long-unread channels that is megabytes,
every time.

Measured on an iPhone 12 mini against the internal relay (gzip wire
bytes, instrumented profile build):

| | main | this PR |
| --- | --- | --- |
| Cold start unread catch-up | 3.5MB | 3.5MB (unchanged, first catch-up)
|
| Unread catch-up on app resume | 3.5MB | 23KB |

The 60s foreground backstop is one of those paths, so on main an open
app downloads the full catch-up again every minute (about 3.5MB a minute
on this account). With this PR each tick costs about 23KB.

On an Edge-class link (Network Link Conditioner "Edge", about 30KB/s) a
3.5MB batch needs about two minutes but has an 8s history timeout, so it
never completes and the next refresh starts it again.

A later catch-up in the same session now starts
`relayCrossDeviceSkewSeconds` (1860s) before the previous one was sent.
The relay rejects events whose `created_at` is more than 900s from its
clock, so a sender 900s slow and this phone 900s fast are at most 1800s
apart. Every message stored since the previous catch-up therefore falls
inside the window, including one that arrives late with an older
timestamp (for example, composed offline or on a phone with a slow
clock). Messages before that point were already recorded or skipped, so
unread and attention state is unchanged. The record is cleared with the
rest of the unread state when the channel list is rebuilt (community or
identity switch), and ignored if a channel's read timestamp ever moves
back. A channel whose websocket fallback filter failed (without a
deadline, so the batch still completes) is not recorded, so the next
catch-up asks for its whole window again. A deletion that removes an
unread event the app already recorded also drops the channel's record.
That event may have been the oldest of a full window of quiet agent
messages, standing in for older unread ones the window hides, and only a
whole-window catch-up finds them again. A catch-up that was already out
when that happened doesn't restore the record, since its response only
covers the narrower window. A channel muted or unmuted since its record
also catches up over its whole window, because muting changes which
messages count.

## Testing

- New test: a repeated catch-up starts 1860s before the previous one was
sent, and still records a message that arrived late with a timestamp
1500s before it. Confirmed it fails without the drift window (`since`
starts at the previous send time) and with the earlier 960s window.
- New test: a rebuilt channel list catches up over the whole window
again.
- New test: a channel whose batch and websocket fallback both failed
catches up over its whole window on the next refresh. Confirmed it fails
without the fix.
- Extended test: deleting the quiet-window marker while a catch-up is
out makes the next catch-up ask for the whole window, and the next quiet
message stands in. Confirmed it fails without each fix.
- New test: a channel unmuted since its last catch-up asks for its whole
window and records its ordinary backlog. Confirmed it fails without the
fix.
- The test fake now honors `since` and `until` like the relay. The
deletion cases now use recent timestamps, because a relay never stores a
1970 event late.
- `just mobile-check` and `just mobile-test` pass.
- Device: resumed Buzz on the 12 mini on Wi-Fi with the fix;
backgrounded for a minute, the catch-up requests were 22KB and 0.5KB
instead of 3.2MB and 287KB (f71c6c7600).

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
mobile-v0.27.0-rc.15
2026-11-01 18:53:39 -08:00
Tom Brow
464448c7b2 fix(mobile): resync read state from the last fetch, not the whole week (#8903)
## Summary

Every read-state resync (each reconnect and each resume from background)
fetches every read-state blob (kind 30078, `#t: read-state`) from the
past week, then opens a live subscription with no `since`, which replays
the same blobs again. Merging keeps the newest mark per context, so
blobs from before the previous fetch add nothing. Each blob is a whole
device's read marks, about 100KB for a heavy reader.

A later fetch and the live subscription now start
`relayCrossDeviceSkewSeconds` (1860s) before the last successful fetch
was sent. The relay rejects a `created_at` more than 900s from its
clock, and blobs come from other devices, so a writer 900s slow and this
phone 900s fast are at most 1800s apart. Every blob stored since that
fetch falls inside the window. A failed fetch keeps the earlier start.
The first fetch of a manager still covers the whole horizon.

Companion to [#8901](https://github.com/block/buzz/pull/8901) and
[#8902](https://github.com/block/buzz/pull/8902), which do the same for
the unread catch-up and the latest-message query.

Measured on an iPhone 12 mini against the internal relay, resuming after
60s in the background (kind 30078 websocket bytes, instrumented profile
build):

| | main | this PR |
| --- | --- | --- |
| Read-state resync on resume | about 1.8MB (91 blobs, fetch plus
subscription replay) | 415KB (4 blobs changed since the last fetch) |

## Testing

- New test: the first fetch covers the horizon, the live subscription
and later fetches start 1860s before the last successful fetch was sent,
and a failed fetch keeps that start. Confirmed it fails without the fix
and with the earlier 960s window.
- New test: after the clock moves back, a resync whose fetch fails isn't
retried on every later read; the next reconnect fetches the whole
horizon. Confirmed it fails without the fix.
- `just mobile-check` and `just mobile-test` pass.
- Device: resumed the app on the 12 mini on Wi-Fi; the resync fetched
only the 4 blobs stored since the previous fetch, and the live
subscription replayed none.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 18:53:17 -08:00
Tom Brow
9d1b684455 fix(mobile): show remote agent instructions from the registry (#8908)
After creating a remote (Builderbot) agent with instructions on mobile,
its profile said "Instructions aren't available on mobile for this
agent."

Two causes:
- Mobile called `list-agents` without `include_instructions: true`, so
registry rows had no `agent_instructions`. Desktop already sends this
flag.
- The profile read instructions only from published desktop agent
config, which remote agents don't have.

Now mobile requests instructions in `list-agents` (both the listing and
the post-create readback). For an agent in the active community owner's
registry listing, the profile shows the row's `agent_instructions`. Like
desktop, a missing field means "No instructions set." Desktop agents
keep the published-config path.

Because listings now carry instructions (up to 20,000 characters each),
`list-agents` gets a 4 MB response bound instead of 64 KB. The
delete-retry journal, which lives in plain preferences, drops
instructions before it's written.

### Before / after

iPhone SE, remote-agent-pilot community: profile of an agent just
created on mobile with the instructions "Reply in one short sentence."

| Before (main) | After (this PR) |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/5d7333ea429e6c8c8aae93846d23c3a2bb9bda25/pr-8908--before-instructions.png"
width="300"> | <img
src="https://raw.githubusercontent.com/block/buzz/5d7333ea429e6c8c8aae93846d23c3a2bb9bda25/pr-8908--after-instructions.png"
width="300"> |

### Validation

- Extended `agents_sheet_test.dart`: the remote-agent case asserts the
`include_instructions` flag and that the profile shows the registry
text. It fails without the fix.
- New/extended tests for the larger `list-agents` bound and the retry
journal; each fails with its fix reverted.
- `just mobile-check` and the mobile test suite pass.
- iPhone SE (release build, remote-agent-pilot community): created an
agent with instructions on mobile, then opened its profile, which showed
them. Deleted the test agent afterward.

buzz-review-completed (manual testing waived by Tom)

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 18:53:02 -08:00
Tom Brow
985de41f14 feat(mobile): show NIP-FI verified names (#8858)
Stacked on #8700. Review only the last commit until #8700 merges.

NIP-FI relays can publish a relay-signed verified name for each member
who signs in with an organization assertion (kind 30382, specified in
#8745 and implemented in the relay in #8774). Desktop shows these names
in block/buzz-app#2005. Mobile showed only self-declared kind 0 names,
so a verified Block name looked no different from one anyone could set.

This adds verified names to mobile, following the spec and the desktop
client's validation rules.

**What people see**

- A verified member's name is the issuer-verified display name wherever
mobile resolves names through identity names (message headers, threads,
DM titles, member lists).
- Message headers and the profile sheet show a verification mark. The
profile sheet also shows the full name when present, "Verified by
{issuer}", "Last verified {time ago}", and marks the record as cached
when it hasn't been reconciled with the relay on this connection.
Verification is described as historical, never as current access.
- Nothing changes on relays that don't advertise
`federated_identity.public_profile`, and marks never come from kind 0 or
NIP-05.

**How it works**

- NIP-11 discovery decides whether marks may show: a successful response
without `public_profile` hides them (cached records are kept), and a
failed or malformed response keeps the last state. Discovery refreshes
on connect and every 5 minutes while names are tracked.
- Subjects are tracked as the profile cache resolves them, and fetched
in batches of up to 128 with `{kinds:[30382], authors:[self],
#d:[...]}`. Live updates and retractions apply on the same subscription.
A full batch that omits subjects is not treated as proof they have no
record.
- Each event's id and signature are verified (in an isolate) before use,
and it must be authored by the community's NIP-11 `self` key with
exactly the specified tags (plus the optional `full_name` the relay
emits). Names follow the spec's NFC and character rules; `unorm_dart`
(MIT, pure Dart) is added for the NFC check because Dart has no built-in
normalizer.
- The cache is per community and is cleared when the relay's `self` key
changes.

**Not yet covered**

- Some surfaces still read the kind 0 name directly: the members sheet
initial, and search and inbox rows. The mark isn't shown in search rows,
inbox rows, or member tiles. (The mention picker now matches, ranks and
inserts by the verified name and shows the mark, and it also offers
cached verified people outside the channel whose kind 0 name differs.)
- NIP-FI says clients MUST show `issuer_label` beside every verified
name. Here it appears only in the mark's tooltip and accessibility label
and in the profile sheet, and verified names reaching DM titles and
member lists show no mark or issuer. Follow-up: show the issuer inline
wherever a verified name is shown.
- Live updates are coalesced to the newest per subject before signature
proof, ordered by unverified `created_at`/`id`. Only a misbehaving relay
could exploit this (the subscription is `authors:[self]`), and it could
already withhold events. Follow-up: prove first, then coalesce among
valid events.
- The verified `username` isn't shown beside the name in message headers
(the NIP-05 handle still is).
- Collision qualification relies on the existing name-disambiguation
policy rather than the spec's exact self-declared-name rule.

### Validation

Added tests:

- Validation: discovery enabled, disabled and malformed; wrong author;
mismatched or duplicate `d`/`p`; extra or duplicate tags; invalid
usernames; non-NFC and whitespace-padded names; `verified_at` after
`created_at`; bad signatures; retractions.
- Directory: discovery gating, batching, truncation, live retraction,
reconnect reconciliation, and disable hiding cached records.
- Directory failures: a pre-EOSE flood, failed proof retried on refresh,
oversized events (by total size, id or signature) dropped before proof,
live events coalesced, a retry before or after the first EOSE never
proving absence, a retry during proof not promoting records to checked,
and a newer signed malformed projection clearing the older one.
- Mention picker: a verified name finds, ranks and inserts the verified
person; the self-declared name still finds them; a verified person
outside the channel is found by verified name even when an impostor
copies that name into kind 0.
- Identity names: a verified name outranks the kind 0 name.
- UI: the mark in message headers and the profile sheet details, no mark
for a cached record, and a single accessible label.

Full `mobile-check` and `mobile-test` pass.

**Live relay (checked 2026-11-01):** not yet run end to end.

- Staging now advertises the feature: the bb-block staging relay's
NIP-11 (`buzz.block.builderlabstaging.xyz`) has
`federated_identity.public_profile` with `kind: 30382` and
`multiple_issuers: false`, bound to `self` `f7f4df94…dcf1`. So #8774 is
deployed and enabled there. The other staging relays
(`communities.public.builderlabstaging.xyz` and its `team.` host)
advertise no `federated_identity`, and the client shows nothing on them.
- I couldn't confirm that real 30382 events are served or render them.
The relay requires NIP-42 auth plus a client-attached NIP-FI assertion,
which needs an interactive Builderlab/Okta sign-in on a physical device.
SSO doesn't work in the iOS simulator. The remaining step is a device
run signed in to the staging Block community, with screenshots of a
verified name, its mark and its details.

**Shipping behind discovery gating:** this is safe. The client reads and
shows verified names only when a successful NIP-11 discovery advertises
`public_profile` bound to `self`. It accepts only kind 30382 events
signed by that key that pass id and signature verification and strict
shape checks. On every relay without the feature, which is every relay
but bb-block staging today, it shows nothing new and changes no names. A
failed or malformed discovery never turns the feature on, and it marks
cached records as unchecked, which hides their mark. The subscription
and reconciliation paths are covered by the tests above, and Carl's
review reran retry probes through the real `RelaySessionNotifier`.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
mobile-v0.27.0-rc.14
2026-11-01 15:51:25 -08:00