Commit Graph

3448 Commits

Author SHA1 Message Date
Kalvin C
0bbcf3e7c2 fix(mobile): play short-delay animation frames at browser speed (#9432)
Some animated custom emoji play far faster on mobile than on desktop.

Browsers show any animation frame with a delay of 10 ms or less for 100
ms. Firefox started this ([bug
207059](https://bugzilla.mozilla.org/show_bug.cgi?id=207059)). Chromium
copied it (decoder comment: "use a duration of 100 ms for any frames
that specify a duration of <= 10 ms"), and so did WebKit ([changeset
73295](https://trac.webkit.org/changeset/73295/webkit)). Flutter uses
the delay as written
([flutter/flutter#130684](https://github.com/flutter/flutter/issues/130684)),
so a GIF with 0 or 1 centisecond delays runs at the display refresh rate
on mobile and at 10 fps on desktop.

`MediaImageProvider` now wraps multi-frame codecs so frames of 10 ms or
less get 100 ms. Every other frame keeps its own delay. Still images are
passed through unchanged. Custom emoji, avatars and attachments all
render through this provider.

**Tests:** `flutter test test/shared/relay/media_image_test.dart` passes
(24/24), and `flutter analyze` reports no issues on the changed files.
The new test decodes a GIF with delays of 0, 1, 2 and 5 cs and expects
100, 100, 20 and 50 ms. It fails when the wrapper is removed.

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

Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 17:43:04 -08:00
Luke Tornquist
279a31daac buzz-search: read profile names without the profile_name_fields function (#9397)
## What

This is phase 1 of 2 for retiring the plpgsql function
`profile_name_fields(text)`. Its only caller, profile name search
(`search_profile_names`), now gets `name` and `display_name` from an
inline SQL expression (`PROFILE_NAME_FIELDS` in
`crates/buzz-search/src/query.rs`) instead.

The function's exception handler made unreadable kind:0 content have no
names rather than fail the search. The inline version keeps that
guarantee in two steps:

- **`pg_input_is_valid(content, 'jsonb')`** rejects the soft parse
errors: a `\^@` escape, a lone surrogate, a huge number, and malformed
JSON.
- **A bracket bound for deep nesting.** PostgreSQL's JSON parser is
recursive. Past about 10k levels at the default `max_stack_depth`, it
raises `stack depth limit exceeded`, and `pg_input_is_valid` does not
catch that. I confirmed this on PG 17: depth 10k is fine and 20k raises.
So content with more than 4096 `[` and `{` is never parsed and has no
names. The count bounds the depth. Ingest already rejects kind:0 content
nested deeper than 128 levels (serde_json), so this only matters for
content stored another way.

`OFFSET 0` keeps the parse from being copied into each reference to
`doc`, so each candidate row is parsed once, plus one validity pass. The
plpgsql function also parsed each row once.

**Behavior change (edge only):** a kind:0 with more than 4096 brackets,
even inside strings, is no longer findable by name. Real profiles don't
come close, and a `picture` data URL is base64 with no brackets.

`pg_input_is_valid` needs PostgreSQL 16 or later. CI runs 16, and the
dev compose runs 17.

**Phase 2 (not here):** a migration and desired-state change drops the
function, its `schema/functions/` file and its `schema.sql` entry once
this release has shipped.

## Tests

- `profile_names_survive_unparsable_content` already stores a `\^@`
profile, a lone-surrogate profile, a `1e1000000` profile and a 60k-deep
profile next to a plain one, and requires the search to return only the
plain one. It now also covers the bound itself: a profile with exactly
4096 brackets, all but one inside a string, is still found.
- Must-fail mutants, each run on Blox: bracket bound removed (the search
fails with a stack overflow), validity check removed, bound tightened by
one, and the string-type check loosened.

## What got simpler

Profile search no longer depends on a database function. Name extraction
lives next to the rest of the search SQL it feeds.

## Verification

Blox, at `be0db303ea90033cc2c37ac8f8f07c27f4b08e31` (clean tree):

| Gate | Result |
|---|---|
| `cargo fmt --all --check` | pass |
| `cargo clippy -p buzz-search --all-targets -- -D warnings` | pass |
| `cargo nextest run -p buzz-search --lib` | 4/4 |
| `scripts/postgres-test-run.sh -p buzz-search --tests -E
'test(/profile_names/)'` | 4/4 |
| Must-fail mutants (no bound, no validity check, bound tightened by
one, non-string names allowed) | 4/4 killed |

Probe on PostgreSQL 17.11: `pg_input_is_valid` returns false for `\^@`,
a lone surrogate, `1e1000000` and truncated JSON; nesting depth 10k is
valid; depth 20k raises `stack depth limit exceeded`. CI runs the full
Postgres lanes.

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


buzz-review-completed

---------

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-12 19:34:56 -05:00
Luke Tornquist
745f7dac7c buzz-db: build the community deletion lock key in Rust (#9396)
## What

This is phase 1 of 2 for retiring the SQL function
`community_deletion_lock_key(uuid)`. Every relay caller now builds the
lock name in Rust, with
`buzz_db::deletion::community_deletion_lock_key(CommunityId) -> String`.
It then locks with the built-in `hashtextextended($1, 0)`, the same
pattern the other advisory locks here use (`channel_ttl_lock_key`,
`push_gate_lock_key`). The string is byte-for-byte the one the function
hashes (`'buzz-community-deletion:' || target::text`), so the lock is
identical. Old and new relays, and the fence functions still in the
database (`community_write_allowed`, `assert_community_write_allowed`),
keep excluding each other during a rolling deploy.

- **Production callers:** exclusive `lock_community_deletion`, shared
`lock_community_deletion_shared`, and the bulk shared lock in
`bind_nip_fi_issuers_to_active`. The bulk lock now binds a `text[]` of
keys, sorted by community id in Rust and locked `WITH ORDINALITY`. That
keeps the old `ORDER BY id` lock order, because `Uuid` and Postgres
`uuid` both compare bytewise.
- **Startup check:** `validate_serving_catalog_on` no longer requires
the function to exist, so the phase-2 drop can't make this release
refuse to serve or delete.
- **Tests and docs:** test lock holders use the helper.
`docs/database.md` gives repair scripts the inline expression, which
keeps working after the drop.
- The test-schema shims in `api/bridge.rs` and `handlers/ingest.rs`
stay. Test schemas still copy the fence functions that call the
function.

**Phase 2 (not here):** a migration and desired-state change drops the
function after this release is fully deployed and the fence functions
are gone (#8323). It also removes the two test shims.

## Tests

-
`deletion::postgres_tests::community_deletion_lock_key_matches_the_sql_function`
checks that `hashtextextended(rust_key, 0)` equals
`community_deletion_lock_key(uuid)` for the nil UUID, the max UUID and
64 random ones.
- `migration_schema_fence_objects_are_optional_for_serving_and_deletion`
already drops the universal fence objects by hand. It now also drops
`community_deletion_lock_key(UUID)`. With both present and absent, it
runs the startup and readiness checks, serving leases (shared lock),
`bind_nip_fi_issuers_to_active` (bulk shared lock) and
`lock_community_deletion` (exclusive lock).

Must-fail mutants, each run on Blox against its guarding test: a changed
key prefix, the shared, exclusive and bulk locks reverted to the SQL
function, and the startup check requiring the function again.

## What got simpler

No relay code path calls a database function to build this lock any
more. The key has one definition in Rust instead of one in SQL plus
about 20 SQL call sites. One fewer object stands between the schema and
declarative-only management (`docs/database.md` Rule 1).

## Verification

Blox, at `3020a44148d942f1502395640730cd0548a76400` (clean tree):

| Gate | Result |
|---|---|
| `cargo fmt --all --check` | pass |
| `cargo clippy -p buzz-db -p buzz-relay --all-targets -- -D warnings` |
pass |
| `cargo nextest run -p buzz-db --lib` | 214/214 |
| `scripts/postgres-test-run.sh -p buzz-db --lib` over
`deletion::postgres_tests`, `nip_fi_binding::postgres_tests` and the
reaction and channel-member lock tests | 101/101 |
| Must-fail mutants (prefix, shared, exclusive, bulk, startup check) |
5/5 killed |

CI runs the full Postgres lanes.

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


buzz-review-completed

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-12 19:34:23 -05:00
Will Pfleger
630922a9e1 fix(cli): pair for the active community, skip stopped desktops, accept a bare relay host (#9417)
## Summary

Fixes to `buzz auth desktop pair` and relay selection:

- **`pair` skips desktops that aren't running.** A desktop that exited
without removing its advertisement no longer counts toward "several Buzz
desktops are running". It uses the same running test as `probe`, where
only a transport failure means not running.
- **`--relay` is global**, so `buzz auth desktop pair --relay …` works
and the flag shows in subcommand help.
- **A bare host means `https://`** for `--relay` and `BUZZ_RELAY_URL`.
An explicit `http://`, `ws://` or `wss://` stays as typed.
- **`pair` with no relay sends `[client]`.** The desktop pairs for its
active community and returns the bound relays, which the CLI stores. An
older desktop answers `invalid_request`, which the CLI reports as "this
Buzz can't pick a community; pass --relay".
- **`docs/local-credential-provider.md`**: covers the one-parameter
`pair`, `unavailable` when no community is active, the older-provider
fallback, skipping stale advertisements, and a lapsed sign-in keeping
the pairing instead of revoking it.
- The existing `concurrent_pair_and_unpair…` test now fails after 60s
instead of hanging when the held pair request never reaches the mock
desktop.
- The CLI tests that run the real `lsof` same-user check now take turns.
A nextest test group, `desktop-same-user-check`, serializes the whole
`desktop_provider::` module for the pre-push hook. Most of its tests
reach the check, and the few that don't run serially too. A test-only
lock around the check does the same in `cargo test`. Waiting for a turn
doesn't count against the request timeout. The check and its 5s limit
are unchanged. `list_tells_a_stale_advertisement_from_a_rejection` keeps
the in-cap `retry_after` of 30 and times from the desktop's first
answer, so waiting for a turn isn't counted.

The desktop half is block/buzz-app#3386.

## Testing

- New tests: `pair_skips_a_desktop_that_is_not_running`,
`an_older_desktop_asks_for_a_relay`,
`relay_flag_is_global_and_a_bare_host_means_https`, plus updated
relay-selection cases.
- `cargo test -p buzz-cli --locked -- --test-threads=4` passes: 689
tests, both with default threads and with `--test-threads=4`, and under
the pre-push hook's nextest run.

---------

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-11-12 19:29:12 -05:00
Tom Brow
8f3f4e266a test(mobile): run push tests without a gateway dart-define (#9423)
A plain `cd mobile && flutter test` failed 11 push and notification
tests on a clean checkout of main, while CI passed. Agents running the
full suite kept reporting them as "pre-existing, also fails on main",
which hides real regressions in the same files.

### Root cause

Push is gated on the compile-time `BUZZ_PUSH_GATEWAY_URL` define
(`Env.pushGatewayConfigured`, since [#7261 configurable
gateway](https://github.com/block/buzz/pull/7261) made the gateway
optional). Without it the app is a push-less build:
`readBuzzPushEndpointGrants()` returns no grants and
`buzzPushLifecycleEnabled()` is false. CI's mobile shards, `just
mobile-test` and the lefthook pre-push lane (which runs `just
mobile-test`) all pass
`--dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example`; a bare
`flutter test` passes nothing. Each of the 11 was checked against that:

- `notification_toggle_test` (5 cases) and
`push_lease_revocation_outbox_test` "hosted relay assertion": no
endpoint grants without a gateway (`Bad state: No element` at the grant
lookup, `No endpoint grant exists for push lease tombstone`).
- `push_bridge_test` "reads and exposes persisted endpoint grants" and
"debug enrollment carries the configured relay and gateway URLs": the
bridge returns `[]` before calling native (`Expected: an object with
length of <1>, Actual: []`).
- `push_bootstrap_test` "default push requires relay capability" and
"new community starts registration": the lifecycle is disabled, so
registration never starts (`Expected <1>, Actual <0>`).
- `push_bridge_test` "development push gateway matches the compiled
configuration": a stale test. It expected a `https://push.buzz.xyz`
default that [#7261 configurable
gateway](https://github.com/block/buzz/pull/7261) removed from the app;
under CI's define both sides equal `push.example`, so it could never
fail there.

The same files pass on the same Mac with the define, so nothing is
environmental. The `Unexpected native call
takePendingNotificationResponse` log is unrelated noise: the toggle
test's native mock didn't answer that call, which logs (and is caught)
in CI too.

### Fix

Tests get the gateway from a test hook instead of the define.
`Env.pushGatewayUrl` becomes a getter over the compiled value with a
`@visibleForTesting` `debugPushGatewayUrlOverride` (the same pattern as
`debugUriStreamOverride`), and `test/flutter_test_config.dart` sets it
for every test file. `push_unconfigured_build_test` sets it to `''`, so
it runs in the main suite instead of being skipped there and run
separately. CI, the Justfile, `scripts/flake-check.mjs` and the
AGENTS/README check commands drop the define, so CI, `just mobile-test`,
pre-push and a bare `flutter test` run the same thing.

I chose this over documenting a canonical command because the define
can't be made automatic: `flutter test` has no config file for defines,
so any doc fix still leaves the obvious command red. A test hook makes
every invocation agree.

The stale test is replaced by one in the push-less file that binds the
production read: with the override cleared, `Env.pushGatewayUrl` must
equal the compiled `BUZZ_PUSH_GATEWAY_URL`. Now that CI compiles without
the define, it fails if the app regains a built-in gateway default,
which the old test never could. The toggle test's mock answers
`takePendingNotificationResponse` with no pending response.

What gets better: one test mode everywhere, and the push-less tests run
in every run. Nothing gets worse: production reads the same compiled
value (the override is never set outside tests, and an `aot-snapshot`
probe shows AOT still folds the getter to the compiled constant and
drops push-only code when the define is absent), and every test still
runs in the push-capable mode CI used before.

### Validation

- On a fresh origin/main worktree with the Hermit toolchain, a bare
`flutter test` failed exactly these 11; the same files passed on the
same machine with the define.
- After the fix, the pre-push `just mobile-check` and `just mobile-test`
(now a bare `flutter test` plus the recipe and secp256k1 checks) pass,
with the push-less tests running instead of skipped.
`scripts/flake-check.test.mjs` passes.
- Falsifiability: re-adding a `defaultValue` to the compiled gateway
fails the new no-default test, and removing `flutter_test_config.dart`
brings back the other 10 failures.
- Unrelated: an earlier full run also failed `community_provider_test`
"discard orphan snapshots and image caches", which waits 500 ms for a
background directory delete. The Mac was running other full suites at
the time; the file passed 8 of 8 runs alone, with and without the
define. It is not touched here.

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-12 16:19:10 -08:00
Tom Brow
5bfbda4c33 fix(mobile): never pop Home when archiving from channel settings (#9387)
Archive, Leave, Unarchive and Delete in channel settings wait for the
relay, then close the settings sheet by popping whatever route is on top
of the root navigator. If the user closes the sheet themselves during
that wait (for example: long-press a chat on Home, Archive, confirm,
then tap the close button), that pop lands on Home instead. The
navigator ends up empty: a blank white or black screen with no controls
and no way back short of killing the app.

### Change

- **Close only the route that offered the action.** (Main has since
gained the same guard; after merging main, this part of the diff is a
comment plus the tests below.) After the action completes, the settings
sheet or page closes itself only if it is still on top, with its "close
the conversation too" result, as before. If the user opened something
over it meanwhile (for example a member's profile), it stays open under
that route, which keeps working. If the user already dismissed it,
nothing is popped. The sheet still closes on its own after a successful
archive.
- **Home can no longer be popped.** The app's root route refuses pops. A
stray pop that reaches it is reported through `FlutterError.reportError`
(which fails widget tests) and ignored, so no other caller can leave an
empty navigator either. Local-history pops on the root route (a drawer
or persistent bottom sheet) still close normally. The root route still
rebuilds from the current auth state, so sign-in and sign-out
transitions are unchanged.

### What changes for the user

Only the case where a settings action finishes after the user moved on:

- Settings dismissed mid-flight: before, the completion popped whatever
was on top (Home, the conversation, a thread or a profile). Now nothing
is popped.
- Something opened over settings mid-flight: before, the completion
popped that new route and left settings open. Now that route stays, and
settings stays open under it, as on main. That settings page shows the
channel as it was before the action (for example still offering Archive
after an archive, or a channel that is gone after Delete or Leave), as
on main. Settings following lifecycle changes is part of the follow-up
below.
- Delete or Leave from the pushed settings page, then back out before
the relay answers: before, the stray pop happened to close the
conversation. Now the conversation stays open until the user leaves it
(Leave and Archive show the read-only notice; a deleted conversation
keeps showing its last state). Closing it reliably needs the
conversation to notice its channel is gone, which is a follow-up.

Pops that reach Home were always a bug and are now refused. Every other
pop (sheets, pushed pages, system back above Home) takes the same path
as before.

### Validation

Added tests:
- Channel actions sheet journey: confirm Archive with the relay call
still pending, close the sheet, then let the archive finish. Home stays.
A second pass leaves the sheet open and checks it closes by itself once
the archive completes. Fails on the old code with Home popped.
- Conversation journey: open settings from a conversation, confirm
Archive with the relay call pending, open a member's profile, then let
the archive finish. The profile stays open and current, and its Message
action still opens the DM. Fails if the completion pops the top route,
and fails if it removes the covered settings page (the profile loses the
context it navigates from).
- Root route: normal push and pop still work, a drawer on Home closes on
back, and popping Home is refused, reported, and Home stays on screen.

Full mobile test suite and `mobile-check` pass, including after merging
main.

Automated app run at
[c158c17](c158c1793d)
on an iOS 27 simulator (iPhone 17 Pro, debug build, public Builderlab
community): long-press a solo chat on Home, archive it from the settings
sheet, and confirm the sheet closes by itself with the channel list
intact. Then the race: confirm Archive and immediately drag the sheet
closed while the archive is in flight. Home stays intact with the chat
archived, and no refused-pop error is logged. Limitation: AXe taps don't
reach the native glass close button on the simulator, so the run
dismissed the sheet with the drag gesture, which takes the same route
pop.

buzz-review-completed

---------

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-v1.1.0-rc.3
2026-11-12 16:06:45 -08:00
Sanket Kanjalkar
a314975ce7 perf(acp): reduce observer CPU and lock overhead (#9381)
## Summary

The Agent Client Protocol (ACP) observer records agent activity for the
owner's viewer. Sizing currently allocates JSON strings and rescans
whole frames for each trimmed field.

Count JSON bytes directly, reuse fitted sizes, and count changed fields
when cheaper. Skip raw-message copies for disabled observers and copy
replay payloads outside the mutex. Keep sequence numbers, timestamps,
replay insertion, and publication under one lock to preserve replay/live
ordering.

This adds one `Arc` allocation per replay entry, and snapshots pin
entries until copying completes. Replay retains the existing 1,000-event
limit.

### Related issue

Related to #7987. This PR targets `main` independently of #9366, which
owns the byte-budget fix. The changes overlap in observer plumbing, so
the second PR to land will need a rebase.

### Testing

The new ignored probe exercises the production publish queue. Timing
includes template clones and queue ingestion, before encryption. Five
alternating fresh release processes per arm/case compared `75d38b6c2`
with this commit on a busy host. Every pair retained identical queue
bytes and event counts.

```sh
cargo test --release -p buzz-acp --lib observer::perf_tests::publish_queue_probe -- --exact --ignored --nocapture
```

Set `BUZZ_OBSERVER_PROBE_EVENTS`, `BUZZ_OBSERVER_PROBE_BYTES`, and
`BUZZ_OBSERVER_PROBE_FIELDS` for each row. Medians:

| Events / string bytes per event / fields | Before | After |
| --- | ---: | ---: |
| 200 / 1 MiB / 64 | 5605 ms | 228 ms |
| 1,000 / 1 MiB / 1 | 714 ms | 352 ms |
| 10,000 / 1 KiB / 1 | 13.01 ms | 6.90 ms |

A separate 20-trial integration run used the same production Chromium
app build (`block/buzz-app@128cd4c2`) and four real ACP subprocesses.
Three peers emitted 225 synthetic messages over five seconds, each with
1 MiB across 64 strings. A fourth emitted 50 markers, 100 ms apart. Each
arm/mode used five fresh processes and browser contexts, with
alternating arm order.

The real observer, publisher, encryption/signing, browser broker, and
Activity UI ran through a modeled relay transport. Marker timing used
one controller clock, from release through transcript DOM commit plus
two animation frames. Observer timestamps were not the timing origin.
Observation-enabled medians:

| Metric | Before | After |
| --- | ---: | ---: |
| Rust CPU time | 4.49 s | 0.45 s |
| Marker-to-render p95 | 4471 ms | 1018 ms |
| Sampled peak RSS | 342.3 MiB | 296.9 MiB |

All 500 enabled-mode markers rendered exactly once; all 2,250 large
events were delivered with the existing payload elision.
Observation-disabled CPU stayed roughly flat (0.13 s to 0.12 s). The
controlled publication-order regression also fails original `main` and
passes this commit.

These are stress-workload measurements, not a general app speedup.
Native hosting and deployed-relay performance remain unmeasured. The
publish cadence remains one frame per second. No CI timing threshold was
added.

Generated with Codex

Signed-off-by: Sanket Kanjalkar <sanketk@squareup.com>
Co-authored-by: Codex <codex@openai.com>
2026-11-12 14:37:33 -08:00
Luke Tornquist
ce42853982 ci: fix main — stale RunCause call sites and Playwright on ubuntu-26.04 (#9418)
## Summary

Two separate CI breaks are hitting every PR right now. Each one blocks a
required check, and neither fix can go green on its own, so this PR
fixes both.

**1. Rust test targets stopped compiling on main at fc8c8022.** #9227
added a required `RunCause` argument to `create_workflow_run` and a
`may_direct_agents` flag to `ActionSink::send_message`. Two tests that
merged around the same time still used the old signatures:

- `attach_rejects_run_from_another_workflow` in
`crates/buzz-db/src/store/workflow.rs` (from #8317)
- the agent-DM refusal test in `crates/buzz-relay/src/workflow_sink.rs`
(from #9371)

Each PR passed CI on its own base. Together they break Rust Lint, Unit
Tests, Windows Rust and the Desktop E2E Relay artifact build (main CI
run 43291564834). The fix passes `RunCause::Schedule` and `true`,
matching the neighbouring tests. Neither test is about run cause or
agent mentions, so neither assertion changes.

**2. Desktop E2E Integration fails on GitHub's new Ubuntu 26.04
runners.** `ubuntu-latest` is rolling to 26.04, and Playwright 1.60 (our
lockfile) fails with `Playwright does not support chromium on
ubuntu26.04-x64`. Playwright 1.61 is the first release that knows
ubuntu26.04. This PR bumps `@playwright/test` to `^1.65.0` in
`desktop/`, `web/` and `admin-web/` and regenerates `pnpm-lock.yaml`. It
also pins every `ubuntu-latest` job in `ci.yml` and `_ci-*.yml` to
`ubuntu-26.04`, so the next runner image change arrives in a reviewed
diff. macOS and Windows jobs are unchanged.

The newer Chromium bundled with Playwright 1.65 changed two smoke specs.
It reports Tailwind's `items-end` as `align-items: flow-end`
(`doctor-states` now accepts `flex-end` or `flow-end`), and it draws the
textarea resize grip differently, so the
`workflow-template-variable-autocomplete` baseline is refreshed from the
ubuntu-26.04 CI output.

It also exposed a timing race in `profile.spec.ts` "owned agent profile
stays in parity": when the Message click lands while the profile panel
is still settling, Playwright's scroll-into-view retry scrolls the
profile body, which hides the owned-agent action row (aria-hidden +
inert) for good. The test now pins the body to the top and waits for the
row before clicking (12/12 repeats plus the full profile spec pass on
Linux).

## Verification

- `cargo clippy -p buzz-db -p buzz-relay --tests -- -D warnings` is
clean on fc8c8022 with commit 1 applied.
- `pnpm install --frozen-lockfile` succeeds with the new lockfile, and
`playwright --version` reports 1.65.0.
- `tsc --noEmit` passes for `desktop`, `web` and `admin-web`.
- This PR's CI is the E2E check on 26.04.

## Notes

- Workflows that don't run on PRs (release, helm charts, sprig, image
builds, codex-security-review) still use `ubuntu-latest`. This PR can't
exercise them, so they're left for a follow-up.
- The hermit-pinned actionlint (1.7.12) doesn't know the `ubuntu-26.04`
label yet. CI only actionlints `codex-security-review.yml`, which this
PR doesn't touch.

## Simpler

No production code changes. The runner pin swaps an implicit moving
target for an explicit one.

🤖 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-12 22:25:00 +00:00
Tom Brow
60b7fb8178 fix(mobile): keep shortcut taps and the member picker from wedging (#9388)
Follow-up to review notes on [#9360 Home Screen
shortcuts](https://github.com/block/buzz/pull/9360). These are fixes for
rare races, with no visible change.

- **The member picker can't wedge.** UIKit ignores a dismiss that
arrives mid-transition (the sheet still animating in, or during an
interactive swipe-down) and never runs its completion. The picker
coordinator queues every later present behind that completion, so New DM
and Add members then opened nothing for the rest of the session. Dismiss
now waits for the transition to finish, then dismisses whatever is still
presented.
- **A shortcut tap reaches Dart once.** The native bridge both buffered
a tap and sent it, so a tap during cold launch could arrive twice and
run again after Home had already handled it. Now a tap is buffered only
until Dart first takes the pending one, and after that it is sent
directly.
- **Signing out drops a waiting tap.** Removing Home clears a tap that
is still waiting (for example on a Bestie that is still loading), so it
can't run in the next account's Home.

Not included: carrying the Bestie's pubkey in the shortcut item, so a
stale "Message Alice" falls back to Agents rather than opening the
current Bestie. It's optional, and it would change the bridge's payload
shared with the lock screen widgets in [#9373 lock screen
widgets](https://github.com/block/buzz/pull/9373).

### Validation

- The Home shortcut journey gains a step: a tap waiting while the app is
inactive is cleared when Home goes away. The step fails with the
teardown guard removed.
- Automated simulator run (iPhone 17 Pro, iOS 27, signed in to a
Builderlab test community), driving the app icon's quick actions on the
Home Screen:
- New DM from a conversation opens the picker. New DM again, with the
picker already open, replaces it. New chat over the picker closes it and
opens a draft, and New DM after that opens the picker again (it doesn't
get stuck).
  - Message (Bestie) opens the Bestie conversation.
- Cold launch through New chat opens exactly one New chat screen. Back
returns to Messages.

buzz-review-completed

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-12 14:09:31 -08:00
Tom Brow
defc2d5ea3 fix(mobile/ios): close notification code blocks only at line-ending fences (#9349)
Follow-up to [#9346 notification message
text](https://github.com/block/buzz/pull/9346), from [Kyle's
review](https://github.com/block/buzz/pull/9346#discussion_r4539634396).

A fenced code block that contains three backticks, such as a `"```"`
string literal, closed at those backticks. The rest of its code then got
the prose rules, so `"**literal**"` lost its asterisks in the
notification. A fenced block now closes only at a fence at least as long
as its opening fence that ends its line, as in CommonMark and the in-app
preview.

Along the way, fences the old regex handled loosely are now consistent
with the app: a block fenced with four or more backticks keeps its code
(and can hold a three-backtick fence), and a block's opening line is
dropped as its language even when it contains a backtick. A one-line
fenced span (`` ```just ci``` ``) and an unclosed fence running to the
end of the text, on one line or several, are unchanged.

**What gets better, what doesn't get worse:** code inside a fenced block
now shows exactly as written. The change is one regex in the
notification body function, and every existing shared case still passes.

### Validation

- Added tests: six shared cases in
`test-fixtures/notification-body.json`: backticks inside a block don't
close it, a closing fence at the end of the last code line closes the
block, a four-backtick block keeps its code, a four-backtick block can
hold a three-backtick fence, an unclosed one-line fence runs to the end,
and an opening line with a backtick is dropped. All 112 BuzzPushKit
XCTest cases and the Swift Testing suite pass.
- No visible change outside notification text for fenced code, so there
are no new screenshots. Human testing is waived; the shared-case run
covers the changed behavior.

🤖 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-12 14:02:32 -08:00
John Matthew Tennant
432df8aa52 fix(media): run thumbnail regeneration tests in CI and cap rebuild pixels (#9198)
## Summary

Two gaps in the thumbnail regeneration from
[#9174](https://github.com/block/buzz/pull/9174):

- **Its MinIO tests never ran in CI.**
`api::media::tests::postgres_tests::external_infra_minio_tests` is
excluded from the PostgreSQL lane, which skips `external_infra*`
modules, and the backend integration job's only media MinIO step selects
the admin tests. A sibling step now runs them against the same Postgres
and MinIO.
- **Rebuilding a thumbnail skipped the 25 MP pixel cap.** Upload
validation rejects images over 25 megapixels before they reach the
decoder. A thumbnail rebuilt from a stored original goes straight to
`generate_image_metadata_sync`, so that cap didn't apply. The check is
now a shared `check_image_pixels` that upload validation and thumbnail
generation both run before decoding. On a rebuild, an oversized original
gets the same 404 as any other regeneration failure.

### Related issue

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

### Testing

- `missing_thumbnail_is_not_regenerated_when_unsafe` now stores a valid
5001x5000 PNG as an image original without thumbnails and requests one
through the media route. On `main` the relay rebuilds and serves it;
with this change the request is a 404 and no thumbnail is written.
- The new CI step runs both `external_infra_minio_tests` cases one at a
time, since they share the fixture image's storage keys.

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

---------

Signed-off-by: John 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-12 14:01:24 -08:00
Tom Brow
4a101752fb fix(mobile): never show emoji shortcodes in reaction pills (#9411)
## Context

A reaction slot should never show an emoji shortcode as text. Today it
does in two cases:

- An agent's reaction is a kind 7 event with content `:shortcode:` and
no NIP-30 `emoji` tag, so the pill printed `:approve:` in tiny text
instead of the community emoji.
- A custom emoji whose image fails to load fell back to the shortcode
text.

## Behavior

- A tagless `:shortcode:` reaction resolves against the community
custom-emoji palette and renders that image. An `emoji` tag on the event
still wins.
- A shortcode with no palette entry, or whose image fails to load, shows
a neutral placeholder glyph (the muted sparkles icon) instead of text.
The shortcode survives only as the semantics label.
- This applies to every reaction slot: the timeline pills, the combined
reaction tray, the Flutter reaction detail sheet, and the native iOS
reactions sheet (which is told which shortcodes have no image, so
`NativeMessageGlyph` draws the placeholder). System-message rows reuse
the same reaction row.
- Unicode emoji are unchanged.

The icon is the existing `BuzzIcons.sparkles`. Pareto note: the palette
lookup is the shared `reactionEmojiUrl` helper, so there is no new
resolver, and the pill only watches the palette when the reaction has no
tag.

### Validation

- `just mobile-check mobile-test`: format and analyze clean; full suite
passes (4773 passed, 74 skipped).
- New widget tests (journeys, in
`reaction_row_test/shortcode_tests.dart`): a tagless known shortcode
renders its image; an unknown shortcode and a failed image render the
placeholder and never the text; unicode is unchanged; the native sheet
payload marks image-less shortcodes. Reverting the fix makes both new
journeys fail.
- iOS: added a Swift unit test for the native glyph placeholder
(`NativeMessageImageLoaderTests`); not run locally, the app run below
exercises the same code.
- Automated app run on a cloned iOS simulator with a fixture entry point
(mocked media, synthetic palette): confirmed the pills before and after,
and opened the reaction sheet from a long-press to confirm the
placeholder renders there too. The clone was deleted afterwards.

## Before / after

Captures use synthetic shortcodes and a synthetic palette.

<img width="300" alt="reaction-pills-before"
src="https://raw.githubusercontent.com/block/buzz/75b22a00d92df2db389ef90d35d476b285b60374/pr-9411--reaction-pills-before.png">

<img width="300" alt="reaction-pills-after"
src="https://raw.githubusercontent.com/block/buzz/75b22a00d92df2db389ef90d35d476b285b60374/pr-9411--reaction-pills-after.png">

Reaction sheet after:

<img width="300" alt="reaction-sheet-after"
src="https://raw.githubusercontent.com/block/buzz/75b22a00d92df2db389ef90d35d476b285b60374/pr-9411--reaction-sheet-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: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
2026-11-12 13:38:37 -08:00
Michael Fransen
2f792c2f6b fix(mobile): isolate channel header scroll rebuilds (#9400)
## Summary

Improve scrolling performance in the Messages channel list by avoiding
unnecessary inventory rebuilds as the header moves. Keep scroll-driven
header state in a notifier and retain the inventory while the header
animates. Provider and parent updates still rebuild the inventory with
current channel data.

This complements the lazy-row optimization in #9401. In manual iPhone
runs with 500 channels, adding lazy rows on top of header isolation
reduced maximum observed Flutter UI work from **65.5 ms to 4.2 ms**.
That measurement reflects the added lazy-row change; no standalone
performance gain was established for header isolation.

This is an independent production-only change. Test fixtures and
diagnostic instrumentation are excluded; regression checks were run
locally and are not included in this PR.

### Related issue

Related: #5911 addresses scrolling behavior inside conversations. This
change addresses header-triggered rebuilds in the Messages channel
inventory. No matching open PR or issue was identified in the focused
duplicate search.

### Testing

Exercised Messages on a physical iPhone with 500 channels against an
isolated local relay. Header collapse and channel updates worked. This
change alone did not remove the large scroll stalls; lazy section rows
are a separate change. The device run preceded the refresh onto current
main.

Local-only regression checks passed 10 tests on this independent branch.
They verify stable inventory identity during header movement and visible
channel updates. These checks are excluded from the commit.

Remaining visible jumps are under investigation; this PR does not claim
to resolve them.

Generated with Codex

Signed-off-by: Michael Fransen <mfransen@squareup.com>
Co-authored-by: Karl <9e8471f7b792e46c9c100840959dc71c660786c79c2a3165dbcaf58f52c4266e@buzz.block.builderlab.xyz>
2026-11-12 13:11:49 -08:00
Michael Fransen
2636e986a0 fix(mobile): build channel section rows lazily (#9401)
## Summary

Improve scrolling performance in the Messages channel list by building
Solo and Team rows only as needed. Sections currently mount all their
rows as one scroll item, which causes long UI stalls with large channel
lists.

In manual iPhone runs with 500 channels, maximum observed Flutter UI
work fell from **65.5 ms to 4.2 ms** after adding lazy rows to a build
with header isolation (#9400). The tester reported much smoother
scrolling. This was an exploratory comparison with different gestures,
not a controlled benchmark of this independent branch.

Use stable channel keys and retain section controls, sorting, and
collapse animations. Clip the sliver extent during collapse so the
animation does not mount the entire section. The shared transition
supports downward vertical lists.

This is an independent production-only change. Test fixtures and
diagnostic instrumentation are excluded; regression checks were run
locally and are not included in this PR.

### Related issue

Related: #5911 addresses scrolling behavior inside conversations. This
change addresses eager row mounting in the Messages channel inventory.
No matching open PR or issue was identified in the focused duplicate
search.

### Testing

On a physical iPhone with 500 channels against an isolated local relay,
maximum Flutter UI work fell from 65.5 ms to 4.2 ms after adding lazy
rows to a build that already isolated header rebuilds. The tester
reported much smoother scrolling. Gestures differed between runs, so
this is not a controlled benchmark or an isolated measurement of this
branch on its own. The device run preceded the refresh onto current
main.

Local-only regression journeys passed 12 tests on this independent
branch. They cover page laziness and updates, variable-height rows,
large text, the last row, collapse reversal, reduced motion, and
semantics. These checks are excluded from the commit.

Smaller visible jumps remain under investigation; this PR does not claim
to resolve them.

Generated with Codex

---------

Signed-off-by: Michael Fransen <mfransen@squareup.com>
Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Karl <9e8471f7b792e46c9c100840959dc71c660786c79c2a3165dbcaf58f52c4266e@buzz.block.builderlab.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
2026-11-12 12:56:43 -08:00
Blake McAnally
745d2a86e0 fix(cli): treat a retried sidebar write the relay already stored as success (#9202)
Follow-up to #9191. It addresses the review findings that came in as
that PR merged.

## Summary

**A retried write was reported as a conflict.** This was found by the
Codex security review on #9191 (LOW).

- `BuzzClient::submit_event` can resend the same signed event after an
ambiguous attempt, for example a timeout after the relay stored it.
- The relay answers that resend `accepted: true, message: "duplicate:"`.
`publish` turned any `duplicate:` into exit 5 with "a newer update won;
run the command again".
- If you ran `buzz sidebar sections create` again, you got a second
section.

On a `duplicate:` reply, `publish` now re-reads the head from the writer
(`consistency: strong`). It reports success only when the head is the
event it signed. The check is safe because `replace_parameterized_event`
returns `was_inserted = false` for both an exact-id duplicate and a
stale (dominated) write. Only a stored event can be the head with our
id, so a dominated write still exits 5.

**Unknown keys are dropped on decode.** This was raised by joahg on
#9191. The module docs now state the limitation and why it is safe:
- Buzz Desktop (`block/buzz-app`, `validate_sidebar_payload`) refuses to
sign a document with unknown keys, so a new field needs a new `meta.v`.
- `decode` refuses an unknown `meta.v`. The commands then neither read
nor overwrite that head.

## Not changed

joahg also noted the lost-update window between the head read and the
submit. That window is documented in `crates/buzz-cli/README.md` (from
#9191). A kind:30078 replaceable event has no compare-and-set on the
relay, so the CLI cannot detect the case. A test would only check the
fake relay's replace rules.

## Testing

- `cargo test -p buzz-cli`: 665 pass. `cargo clippy -p buzz-cli
--all-targets -D warnings` and `cargo fmt --check` are clean.
- New test: `a_retry_of_a_stored_write_is_success`. The fake relay
stores the event and answers `duplicate:`. The test fails without the
fix (`Conflict("a newer sidebar sections update won; run the command
again")`).
- `a_dominated_write_is_a_conflict` now seeds a newer head under the
caller's own key. Before, every conflict test had an empty own head, so
a looser check (`Some(_)`) still passed. The test now fails under that
change.
- An agent review ran. It found that gap in the dominated-write test,
which is now fixed. Its remaining notes are optional.

### Manual test steps

The retry path needs a lost relay response, so you cannot easily trigger
it by hand. These steps check that normal writes did not change:

1. Run `cargo build -p buzz-cli`. Then set `BUZZ_RELAY_URL` and either
pair with Buzz Desktop (`buzz auth desktop pair`) or set
`BUZZ_PRIVATE_KEY`.
2. Run `buzz sidebar sections list` and save the output.
3. Run `buzz sidebar sections create --name "Retry test"`. Expected:
`"accepted": true`, a non-null `event_id`, and a `section_id`.
4. Run `buzz sidebar sections delete --section "Retry test"`. Then run
`list` again. Expected: the same output as step 2.

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

Signed-off-by: Blake McAnally <bmcanally@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 12:31:49 -08:00
Will Pfleger
fc8c802253 feat(workflow): add owner-only agent mentions for workflow messages (#9227)
Adds owner-only agent mentions, a relay setting,
`BUZZ_WORKFLOW_AGENT_MENTIONS`. Its values are `any`, the default and
today's behaviour, and `owner`. Any other value fails startup. With
`owner`, a workflow message can direct an agent only if the run's cause
and definition allow it. The setting is relay-wide.

**Recording what caused each run.** `workflow_runs` gets `cause_source`
and `cause_pubkey` columns. Migration `0087` adds them with named checks
marked `NOT VALID`, so it does not scan existing rows under its lock;
`0088` validates the checks separately. `create_workflow_run` now takes
a required `RunCause`, so every place that creates a run has to set one:
- **Event trigger:** the cause is the signer of the message, reaction or
diff. For a reaction, that is the reactor, not the author of the message
being reacted to.
- **Cron and interval schedules:** the cause is the timer.
- **Webhook:** the caller is unknown.
- **Manual trigger:** the cause is the invoking pubkey.

Rows written before this change have no cause. A stored source without
the pubkey it requires, or with one it forbids, is read as no cause.

**Deciding eligibility.** A run's messages may direct agents only when
all of these hold:
- the cause is a schedule, or an event or manual trigger whose pubkey is
the workflow owner's;
- every step of the definition that is running is `send_message` or
`delay`. This is an allowlist, so any other action, including one added
later, makes the definition ineligible, even when an `if:` skips that
step;
- the run did not resume after an approval.

Webhook runs and runs with no recorded cause are never eligible. The
executor decides this from `WorkflowConfig::agent_mentions`, and passes
`ActionSink::send_message` the owner's stored step text with a
`may_direct_agents` flag. The sink adds `buzz:workflow-mention` tags
only when the flag is set, and returns `SentMessage { event_id,
agent_mentions_withheld }`, which is true only when it left out a tag
that a member named in both the stored and rendered text would otherwise
have received. Webhook and manual runs start through `execute_run`, so
`execute_from_step` is used only to resume after an approval.

**The gate.** With `owner`, the relay adds `buzz:workflow-mention` only
to messages from eligible runs. Messages from other runs still post with
their plain `p` tags and still notify people. With `any`, mention tags
are added exactly as before.

**Making it visible.** When a `send_message` step leaves the tags out:
- its output in the run trace gets `"agent_mentions": {"withheld": true,
"reasons": [...]}`, listing each reason that applies: `cause` (not
started by the owner or a timer), `steps` (a step other than
`send_message` or `delay`), `resumed` (resumed after an approval). The
key is absent when nothing was withheld. The trace is what the app's run
history shows;
- the relay logs it at info level under the
`buzz_relay::workflow_agent_mentions` target (visible under the default
filter) with the community, workflow, run, step and reasons;
- the counter `buzz_workflow_agent_mentions_withheld_total` increases
once per reason, labelled `reason`.

**Discovery.** The NIP-11 document always carries
`"workflow_agent_mentions": "any" | "owner"`, so a missing field means
an older relay.

`NOSTR.md` now specifies `buzz:workflow-mention` for clients: what the
tag means, the checks an agent makes before acting, that a relay may
leave it out under a policy, and the NIP-11 `workflow_agent_mentions`
field. The relay policy itself is in the new operator guide,
`docs/operator-workflow-agent-mentions.md`: the setting and its values,
what counts as a run's cause for each trigger, which steps are allowed,
the trace key, the log and metric, the NIP-11 field, when to use
`owner`, rollout across pods, and what it doesn't cover. The
`TESTING.md` and `.env.example` entries link to it.

---------

Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
mobile-v1.1.0-rc.2
2026-11-12 11:34:30 -08:00
klopez4212
cb69ad9e4f Adapt iPhone Duo layouts, rail navigation, and native sheets (#8568)
Duo keeps conversations and Settings details open across closed, book
and flat postures. Book mode splits at the fold; flattening restores the
sidebar. Native rail controls, full-width navigation backdrops and
sheet-local safe areas keep content aligned. Ordinary iPhones remain
single-pane in portrait and landscape; tablet and Duo split layouts
remain enabled.

Includes the recovered preview's matching pane widths, Buzz empty-pane
mark, stable Settings chevrons, Bestie pane routing and Duo-only upward
New menu attachment. Current main's Activity redesign, Search content
fade and unread-navigation button are retained. Menu selection checks
sit beside their labels.

Shared mobile changes remain in scope: the native iOS status editor,
Appearance preview layout, avatar crop preservation, onboarding footer
layout, profile/invite sheet geometry and nested conversation
navigation. This PR is not exclusively Duo-gated. Nested navigator
observers preserve recording cancellation and navigation callbacks; crop
resizing preserves the selected image region. iOS 27.1 APIs remain
compile-time and runtime gated.

Validation on the reconciled source: Flutter analysis, Activity/Home
journeys, the full mobile test suite, file-size checks and Swift package
tests. The iPhone single-pane fix was also exercised on an iPhone 18 Pro
simulator before reconciliation. Fresh hosted CI and physical-device
confirmation of the reconciled build are separate and remain pending.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Tom Brow <tomb@squareup.com>
2026-11-12 19:00:58 +00:00
klopez4212
5f81803cb1 End last-human agent Huddles and preserve mobile transcript access (#9375)
## Summary

Leaving an agent DM Huddle previously skipped client lifecycle cleanup,
allowing Join to remain visible while the relay waited for the agent.
Apply the existing last-human rule to DMs: release audio first, then
publish the ending and archive when the human count is at most one.
Preserve the existing additional-device and same-Huddle rejoin guards.
Retain DM membership when other humans remain so hanging up does not
revoke transcript access.

Offer **View** only when cached backing-room membership confirms
transcript access, opening the original Huddle transcript and reply
threads without joining audio. Non-members retain disabled Ended.
Preserve Open for an active local session even when an ended event
arrives.

For admitted or just-left Huddles, switching/removing a community
retires pending remote finalization and waits for local audio teardown.
Generation guards prevent retired count/end results from launching
follow-up writes or interfering with a later call. Existing in-flight
Huddle-start rollback behavior is unchanged.

Client only; remote relay finalization is separate in #9376. Human
counts use backing-channel membership; known duplicate owner admissions
prevent client cleanup. Concurrent device arrivals are not made atomic
by this client-only change, and human-to-human DMs with retained
memberships still rely on relay finalization.

### Related issue

Split from the combined Huddle repair at the author's request. Searched
open Huddle issues/PRs; no exact duplicate found for this mobile
behavior.

### Testing

Follow-up fixes are in `7f28dc486`; merged current main in `7a592f19d`.
Required push checks pass on the merged head.

- Mobile formatting, analysis, and full suite passed (4,705 tests, 71
skipped; supplementary suites: 4 and 27).
- DM hangup journey covers sole-human ending, preservation of another
owner device, and preservation of membership when another human remains.
Existing rejoin/lifecycle regressions remain in the full suite.
- View journey reads the original transcript and opens a server-only
reply-thread summary without joining audio.
- Mutation checks: restoring the DM cleanup exemption fails the
last-human journey; removing thread summaries fails View navigation.
Restored production code passes.
- Independent review completed; follow-up review of the three blocking
fixes found no remaining blockers.
- Follow-up regressions cover live-call Open after an ended event,
reactive history membership revocation, stalled count/end requests
during community transition, and background teardown of a later call
while retired cleanup is unresolved.

Phone/human validation remains pending. Start an agent DM Huddle and
leave from the last human device: its card should stop offering Join.
Tap View to read the transcript and replies. Repeat with another device
still in the Huddle and verify it stays open. Not deployed.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-12 10:45:10 -08:00
Luke Tornquist
aa9b44ef36 buzz-db: pin explicit FK-child cleanup and same-workflow fire links (#8317)
> Builds on #8219 (merged); rebased onto `main` at `93ffc37cb` (includes
the rustls bump, #8319).

## What

This closes the identified debt from #8212. Every remaining `ON DELETE
CASCADE` / `SET NULL` foreign key was audited against every supported
way its parent row gets removed, with FK actions retired in the per-test
schema (`test_support::retire_foreign_key_delete_actions`). One
production change was needed. The rest is already explicit and is now
covered by tests.

**Production change:** `attach_scheduled_workflow_run`
(`store/workflow.rs`) now links a scheduled fire only to a run of the
**same** workflow (`EXISTS` on `(community_id, workflow_id, id)` in the
existing admitted `UPDATE`). Before this, a fire for workflow B could
reference a run of A. That FK is already `NO ACTION`, so the link would
block A's explicit run delete. The only production caller
(`buzz-workflow` cron tick) attaches the run it just created for the
same workflow, so live behavior is unchanged. A mismatched attach now
returns `false` like any other non-match, and the caller already treats
that as best-effort.

## FK audit (all 12 delete actions)

| Child FK | Action | Parent removal path → handling |
|---|---|---|
| `scheduled_workflow_fires → workflows` | CASCADE | Workflow delete
removes fires explicitly (#8212) |
| `workflow_runs → workflows` | CASCADE | Explicit (#8212) |
| `workflow_approvals → workflows` | CASCADE | Explicit (#8212) |
| `workflow_approvals → workflow_runs` | CASCADE | Explicit, including
cross-workflow approvals (#8212) |
| `join_policy_acceptances → relay_members` | CASCADE | Explicit (#8212)
|
| `channel_members → channels` | CASCADE | Single-channel removal is a
soft delete. The community purge deletes members before channels.
**Pinned here.** |
| `personal_read_frontiers → personal_read_accounts` | CASCADE | No
single-account hard delete exists. The purge deletes frontiers before
accounts. **Pinned here.** |
| `personal_read_frontiers → channels` | CASCADE | Same as above.
**Pinned here.** |
| `users.agent_owner_pubkey → users` | SET NULL | The only production
hard delete of users is the purge deleting all of a community's users in
one statement. **Pinned here** (owner + owned agent). No single-user
hard delete exists. |
| `product_feedback → communities` | SET NULL | Communities are
tombstoned, never hard-deleted. The purge already nulls feedback
`community_id` explicitly. **Pinned here.** |
| `community_deletion_manifest_keys → community_deletion_requests` |
CASCADE | Requests are retained. `mark_logically_verified` deletes
chunks explicitly, and the chunk guard trigger is unchanged. **The
existing lifecycle test now runs with FK actions retired.** |
| `community_deletion_executor_heartbeats → community_deletion_requests`
| SET NULL | Requests are retained, so this action is never reached.
**Pinned here:** the heartbeat stays linked through the purge. |

Audit method: searched `crates/**/*.rs` for `DELETE FROM` against each
parent table. Every direct `DELETE FROM communities` / `users` hit is in
`#[cfg(test)]` fixtures. Production removes rows only through the scoped
purge loop in `store/deletion.rs` and the channel soft delete in
`store/channel.rs`.

**Not added:** per-parent delete APIs. They would duplicate the
community purge for parents that are never hard-deleted. I also left out
a source-text "no hard delete" policy test, because it would pass
without any production fix. If a single-user, channel, read-account,
request or community hard delete is ever added, it has to clear
`agent_owner_pubkey` (only that column) and delete these children
explicitly first.

## Tests

- `attach_rejects_run_from_another_workflow`: a claim for B can't be
linked to A's run, and the fire stays unlinked.
- `purge_removes_fk_children_explicitly_without_fk_actions`: on the
retired-FK schema, `channel_members` and `personal_read_frontiers`
children are seeded through an admitted transaction, and then the
production quiesce → fence → purge runs. Every child must be deleted
explicitly, both users must be removed, feedback must survive with
`community_id = NULL`, and the retained request's heartbeat (selected by
`executor_id`) must stay linked. The quiesce barrier against open
admitted writers is already pinned by
`begin_quiescing_waits_for_open_admitted_writer_note_update`; this test
does not re-claim it.
- `manifest_key_chunks_bind_freeze_execution_and_cleanup` now retires FK
actions first.

Each test was checked to fail when its production behavior is removed:
dropping the `EXISTS` guard, dropping `personal_read_frontiers` from the
purge order (`23503`), dropping feedback provenance clearing, and
dropping the explicit manifest chunk delete. All four failed as
expected.

## What got simpler

The production change is one predicate. No new concepts or APIs. The FK
audit is now settled, so the follow-up migration can drop all 12 actions
at once. No new delete code was needed.

**Identified debt (pre-existing, outside this diff):** the cron tick in
`buzz-workflow/src/lib.rs` logs only `Err` from
`attach_scheduled_workflow_run`; an `Ok(false)` (no matching unlinked
claim) leaves the fire unlinked without a trace.

**Identified debt (unrelated, pre-existing):**
`writer_pool_rejects_non_read_committed_database_default` hangs with no
active query on clean `177551dda` as well (bounded after ~270 s on both
checkouts).

## Verification (Blox, at the exact tree of this commit)

| Gate | Result |
|---|---|
| `cargo fmt --all -- --check` | pass |
| `cargo clippy --workspace --all-targets -- -D warnings` | pass |
| `cargo nextest run -p buzz-db --lib` | 166/166 |
| `scripts/postgres-test-run.sh -p` buzz-admin / audit / deletion /
push-gateway / relay / search / workflow | all rc 0 (4, 7, 11, 11, 402,
20, 2) |
| `scripts/postgres-test-run.sh -p buzz-db` | 510 pass, 2 fail: the
pre-existing hang above, plus one 150 ms `PoolTimedOut` in
`routed_fallback_spends_one_acquire_budget_when_aurora_cache_is_cold`
(pool setup under full-lane load; it passed on the clean-baseline lane
and passes in isolation at this tree, 1/1 in 1.7 s) |

The new tests are selected by the `postgres-ci` nextest filter
`(test(/postgres_tests::/) or binary(/^postgres_/)) and not
test(/(^|::)external_infra[^:]*::/)`.

---------

Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
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-12 13:28:13 -05:00
Luke Tornquist
0c58b33e64 chore(deps): bump hickory to 0.26.3 for RUSTSEC-2026-0506..0520 (#9394) 2026-11-12 13:14:55 -05:00
Luke Tornquist
193fda22f5 fix(db): seek replaceable heads by channel (#9147)
## Summary

`replace_addressable_event_under_lock` read and superseded a replaceable
coordinate's head with `channel_id IS NOT DISTINCT FROM $4`. A btree
can't use that as an index condition, so the read filtered every live
row of the `(kind, pubkey)` across all channels. The relay signs every
channel's state events (39000–39002) with one key, so each channel-state
write walked the whole community's relay state for that kind. Both
`Db::replace_addressable_event` and the relay's locked group-state
snapshots go through this function.

The predicate is now spelled per case, as `channel_id = $4` or
`channel_id IS NULL`, and `$4` is bound only for the first. The head
read becomes an index seek to one channel instead of a filtered walk. It
reads the same rows as before.

The `WHERE` scope and the head read are shared as macros
(`replacement_scope!`, `replacement_head_sql!`), so the plan test checks
the same predicate production runs. The supersede statement is spelled
inside the function itself, which takes `&mut AdmittedTx`, as the
guarded-write source scan requires. `superseded_with_mentions_sql!` now
takes an `expr`, so it accepts a `concat!`.

## Tests

- `replacement_channel_predicate_is_an_index_condition` seeds one relay
head per channel plus one channel-less head and refreshes the
statistics. Then, for both cases, it checks that the head read and the
supersede each have `channel_id` in an `Index Cond`. With `IS NOT
DISTINCT FROM`, the planner walks the newest-first `(pubkey, kind)`
index and applies the channel as a `Filter`. On an empty table it does
that even with the per-case spelling, which is why the test needs the
fixture.
- `addressable_replacement_supersedes_only_its_own_channel_scope`: a
channel write supersedes only that channel's head, and a channel-less
write is ordered against the global head only.
- **Mutants**, each killed by its test:
  - the scope restored to `IS NOT DISTINCT FROM`;
  - the global scope ignores the channel;
  - the in-channel supersede ignores the channel.

## What got simpler

- One predicate per case instead of a NULL-safe comparison the index
can't use.
- The head read and supersede share one scope definition.

🤖 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>
mobile-v1.1.0-rc.1
2026-11-12 10:13:35 -05:00
Luke Tornquist
6878520604 buzz-db: check events created_at floor in app writers (phase 1) (#8325)
## What

This is phase 1 of 2 for retiring the `events_created_at_floor` deferred
constraint trigger and its `events_created_at_floor_guard()` function
(migration 0021). The invariant now holds in the app writers. **The
trigger, function and `buzz.created_at_floor` GUC arming all stay** as
the backstop while pods run mixed versions. A later PR drops them.

One helper, `replica_fence::guard_event_created_at_floor(&mut
AdmittedTx, created_at, channel_id)`, runs on the admitted write
connection. If `created_at < clock_timestamp() - CREATED_AT_FLOOR_SECS`,
it rejects the write with the typed, non-retryable
`DbError::CreatedAtFloorRejected`. It uses the same 960 s constant as
the GUC and the fence wall. Channel-less rows skip it and cost no query.

**Why checking at insert time is enough:** the fence wall is
`min(oldest_xact_start, S) - floor - 5s`. A writer transaction that is
open during the probe's activity scan is bounded by its own
`xact_start`. A transaction that starts after the scan samples a clock
later than `S`. So any `clock_timestamp()` sample taken inside the
writing transaction is as strong as the trigger's commit-time sample.
This needs no commit hook, no candidate-row tracking and no ordering
against the TTL refresh, and `AdmittedTx::commit` is unchanged.

| Site | Check placement |
|---|---|
| `store/event.rs` `insert_event_on` | after the `ON CONFLICT DO
NOTHING` INSERT, only when a row was inserted, before follow-ups |
| `store/event.rs` `insert_event_with_thread_metadata_tx` | after the
INSERT, only when a row was inserted, before follow-ups and thread
metadata |
| `store/replaceable.rs` parameterized replacement | after the `ON
CONFLICT DO NOTHING` INSERT, only when a row was inserted, inside the
supersede savepoint (a rejection rolls back to it and restores the old
head) |
| `store/replaceable.rs` addressable replacement | after the `ON
CONFLICT DO NOTHING` INSERT, only when a row was inserted, before
follow-ups (the caller's rollback restores the old head) |
| `store/channel_members.rs`
`LockedMemberSnapshot::replace_member_event` | before the old head is
superseded |

**Writer inventory:** there are 7 production `INSERT INTO events`
functions. The other two are structurally `channel_id = NULL`: `push.rs`
`accept_lease_event` (kind 30350) and `relay_members.rs`
`publish_nip43_membership_locked` (kind 13534). Both write a literal
`NULL` for `channel_id` in their INSERT SQL, and the policy exempts them
only while that NULL sits at the `channel_id` position. No production
UPDATE writes `events.created_at` or `events.channel_id`, and the events
`ON CONFLICT` clauses are `DO NOTHING`.

**Client-visible outcome is unchanged.** The trigger's SQLSTATE 23514
surfaced as a generic DB error, which the relay maps to
`IngestError::Internal` (WS `OK false` / HTTP 500). The new variant
follows the same path. Relay mappings are untouched.

**`scripts/seed-admin-dashboard.sh`** writes a channel event with
`created_at = 1790000000` through raw psql, and it accepted any
`PGHOST`. It now refuses non-local hosts (`localhost`, `127.0.0.1`,
`::1` and Unix socket paths are still allowed), a non-empty `PGHOSTADDR`
or `PGSERVICE`, a multi-host `PGHOST`, and any `PGDATABASE` that is not
a plain name (psql treats URI and `key=value` `-d` values as connection
strings that override `-h`). Both psql paths pass `-X`, so a psqlrc
can't `\connect` elsewhere after the guard.

## Tests

There are eleven new PG tests in `store::event_floor_postgres_tests`.
Each runs in its own per-test database with `ALTER TABLE events DISABLE
TRIGGER events_created_at_floor`, and a `pg_partition_tree` check
asserts that every partition clone is disabled, so the trigger can't
hide a missing app check.
- `insert_event_floor_rejects_old_and_accepts_fresh`,
`thread_metadata_event_floor_rejects_old_and_accepts_fresh`,
`parameterized_event_floor_rejects_old_and_accepts_fresh`,
`addressable_event_floor_rejects_old_and_accepts_fresh`,
`member_snapshot_event_floor_rejects_old_and_accepts_fresh`: each
rejects a too-old event with the typed error, confirms no row was
written, then accepts a fresh event.
- `insert_event_floor_accepts_expired_duplicate`,
`thread_metadata_event_floor_accepts_expired_duplicate`: replaying an
already-stored event whose `created_at` is now below the floor returns
`Duplicate` (inserted = false), leaves one row and runs no thread
follow-ups.
- `parameterized_replay_of_soft_deleted_aged_event_is_a_duplicate`,
`addressable_replay_of_soft_deleted_aged_event_is_a_duplicate`:
replaying a replacement whose stored row is soft-deleted and has aged
below the floor returns a duplicate, not a floor rejection, as the
trigger did (it never fired on `ON CONFLICT`).
-
`cluster_global_event_floor_held_transaction_cannot_write_below_probe_wall`:
runs a real heartbeat probe while a writer transaction is open. It
asserts that the wall is bounded by that writer's `xact_start`, that a
below-wall event is rejected, and that a near-floor event sampled after
the probe lands above the wall.
- `event_floor_samples_statement_clock_after_transaction_start`: waits
on the database until a boundary timestamp that was valid at BEGIN
expires (normally ≤1.1 s), so `now()` would pass where
`clock_timestamp()` fails.

Source policy (`observability_source.rs`): every channel-bearing
production events INSERT in any crate's `src` must call the helper, no
production UPDATE may assign `created_at` or `channel_id` (including
`ONLY`, schema-qualified, aliased and row-constructor forms; quoted
identifiers are normalized), the helper must use `clock_timestamp()` and
the shared constant, and the seed script must refuse a remote host.
Negative fixtures cover each rule.

Each check was confirmed to fail when its fix is removed. All of these
mutants failed on assertions:
- removing the helper from all five writers, which failed all seven PG
tests
- switching `clock_timestamp()` to `now()`, which failed both the clock
PG test and the clock policy
- removing one call
- adding a `created_at` UPDATE
- disabling the policy self-test
- removing the seed guard
- (review round 2) restoring the pre-INSERT check placement, which
failed both duplicate-replay tests; the round-1 UPDATE matcher,
quoted-identifier normalization, buzz-db-only scan, NIP-43 bind
exemption, process-wide canvas base and each seed override were each
restored and failed their tests
- (review round 3) restoring the round-2 seed script, which failed the
extended seed test on its URI and key/value `PGDATABASE` cases;
restoring the script without `-X`, which failed the control's `-X`
assertion

**Fixture changes:** about 40 existing buzz-db PG tests seeded
historical rows (fixed old timestamps) through the serving writers. The
trigger never fired on them because the test pool is unarmed. They now
seed through a `#[cfg(test)]` raw-SQL helper,
`test_support::insert_historical_event` (and
`insert_historical_event_with_thread_metadata` for thread fixtures),
which keeps admission, follow-ups and mentions. The canvas CAS fixtures
keep their logical seconds and ties but each test passes its own fresh
base. The existing trigger backstop tests still exercise raw armed
INSERT/UPDATE plus COMMIT returning 23514.

The new PG tests are selected by the `postgres-ci` filter
`(test(/postgres_tests::/) or binary(/^postgres_/)) and not
test(/(^|::)external_infra[^:]*::/)`. The `cluster_global_` test also
joins the existing serial group.

## Behavior notes

- Prepared transactions (2PC) are covered only by the retained trigger,
which runs at PREPARE. The insert-time app guard runs earlier, and no
app writer issues `PREPARE TRANSACTION`. The comment in
`replica_fence.rs` now says so, so the trigger drop has to keep this
case covered.

- One extra round trip for each newly inserted channel event. Duplicate
replays add none.
- Ordinary and thread event writes check the floor only for newly
inserted rows, before follow-ups, mentions or thread metadata. Replaying
an already-stored id stays `Duplicate` even when its timestamp has aged
below the floor, as with the trigger. (Internal writers such as the
admin outbox worker do replay relay-signed events with their original
`created_at`.) A too-old new row returns the non-retryable
`CreatedAtFloorRejected`, and the owning transaction rolls back; every
current caller propagates the error and drops the transaction. The
transaction-composition seam documents that contract.
- Replacement writers check after their INSERT, only when it inserted a
row, so a replay of any stored id (live or soft-deleted) stays a
duplicate. The locked roster writer checks before the old head is
superseded.
- The trigger is stricter for a row that ages below the floor between
the app check and COMMIT. The proof above doesn't need that strictness,
and phase 2 gives it up.

## What got simpler

The NIP-43 insert drops a bind for a literal `NULL`, the floor and
follow-up policies share one workspace walk, and the canvas fixtures
lose their process-wide `OnceLock`. No commit-time machinery was added:
no candidate tracking, commit hook, cleanup protocol or new lock.
Test-only historical writes now go through one helper instead of being
scattered through the serving APIs. Nothing could be deleted in this
phase because mixed-version pods still rely on the trigger. **Phase 2**
drops the trigger and function, the GUC arming,
`verify_floor_guard_catalog` / `verify_floor_guard_behavior` and the
schema reconciliation entries. Its deploy gate: the whole fleet on this
binary, no rollback expected, and the replica breaker held closed
through the drop.

**Identified debt (pre-existing, outside this diff):** three
`buzz-relay` `handlers::group_state_postgres_tests::*` tests fail with
`ensure community: Sqlx(PoolTimedOut)` on the Blox host. They fail the
same way on a clean checkout of the base (`state.rs` test pool, 100 ms
acquire timeout).
`writer_pool_rejects_non_read_committed_database_default` and
`writer_pool_metrics_stop_after_session_timeout_setup_failure` hang on
that host at base too.

## Verification

Head `b046df76d` (after the placement fix and main's newer test
fixtures): on Blox, fmt, clippy and buzz-db unit (241/241) pass, and a
filtered buzz-db PG lane over the floor tests and every converted
fixture passes 88/88. CI is running at this head.

Earlier full gate, at tree `67c4fe5e1` + this diff, before the placement
fix:

| Gate | Result |
|---|---|
| `cargo fmt --all -- --check` | pass |
| `cargo clippy --workspace --all-targets -- -D warnings` | pass |
| `cargo nextest run -p buzz-db --lib` | 167/167 |
| `cargo nextest run -p buzz-db --test observability_source` | 22/22 |
| `scripts/test-postgres-test-discovery.sh` | pass |
| `scripts/postgres-test-run.sh -p buzz-db` (excluding the two host
hangs) | 517/517 |
| `scripts/postgres-test-run.sh -p` buzz-admin / audit / deletion /
push-gateway / search / workflow | all rc 0 (4, 7, 11, 11, 20, 2) |
| `scripts/postgres-test-run.sh -p buzz-relay` | 402 pass, 3 fail (the
pre-existing `PoolTimedOut` failures above) |

Local gates used a reverted `rustls` lock bump, because the build mirror
lacks 0.23.46. The committed `Cargo.lock` is unchanged, and CI uses the
real lock.

---------

Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
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-12 09:36:59 -05:00
Tom Brow
473d592602 fix(mobile): keep a thread tail behind the keyboard unread (#9295)
Follow-up to [#9243 read tall
mentions](https://github.com/block/buzz/pull/9243).

**Problem.** Since #9243, the newest reply in a thread is read once its
end shows at the live bottom, even when it is taller than the screen.
The thread view decides "at the tail" from scroll geometry that ignores
the keyboard. On Android, with the keyboard open and tail-follow
detached, the last reply can sit at the scroll end but entirely behind
the keyboard, and it gets marked read. The mark stays after the keyboard
closes, so a mention there can be cleared without ever being seen.

**Fix.** The thread's live bottom now also requires the tail row's end
to be inside the reading area, above the keyboard and composer. This
matches how the channel timeline already checks its bottom row.

### Validation

- Added test: a thread whose last reply sits behind the Android keyboard
leaves it unread, including after the keyboard closes. It fails without
the guard.
- Full mobile suite and `flutter analyze` 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-12 06:28:36 -08:00
Tom Brow
c0e30110dd fix(mobile): restore header cues and per-channel identity check for #9369 (#9370)
Follow-up to #9369 (conversation header avatars). Fixes two regressions
found in its review. The intended behavior of #9369 stays the same.

1. **Message rows no longer each scan the roster.**
`showMessageAuthorName` ran at the top of every message and thread row,
in every channel. Each run watched a whole `Channel` object (which has
no value equality) and built sets from the full roster, plus two more
sets for the stale-DM check in DMs. A new
`confirmedConversationPairProvider` works out the two-person pair once
per channel, including the rule that a DM keeps attribution while its
metadata disagrees with the roster. It returns a record that compares by
value, so rows rebuild only when the pair itself changes. It reads
`watchLatestChannelMembers`, so a revisit uses the cached roster and
rows no longer re-wrap 48 dp wider once the roster fetch returns. The
visible rule is the same as before.
2. **Android private channels show the lock again.** Every Android
conversation header now uses the compact title pill, which had no lock.
The pill now shows the existing lock icon (and its "Private channel"
label) before the name.

### Validation

- Added tests, all extending existing journeys:
- The author-visibility journey checks that replacing the channel object
(as live activity does) doesn't notify rows when the pair is unchanged.
Its existing `stale-dm` case covers the metadata rule now inside the
provider.
  - The private-lock header test expects the lock again.
- Each new or restored Dart assertion fails with its fix reverted, and
so does the `stale-dm` case with the provider's metadata rule removed.
- Full mobile suite (`just mobile-test`) and `flutter analyze` pass.
- Not run: an automated full-app session on a simulator or 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-12 06:21:39 -08:00
Tom Brow
e715fbb362 fix(relay): treat empty filter sets as match-nothing in SQL (#9342)
Fix for a COUNT regression introduced by #9339 (now on main), found in
its review.

With #9339, a filter with a nonempty `#a` takes the exact SQL COUNT
path. But `filter_to_query_params` dropped an empty `authors`, `ids` or
`#e` list and never represented an empty `#p`, `#d` or unpushed tag set.
So `{"kinds":[7], "#a":[A], "authors":[]}` counted every reaction to
`A`, while REQ (which post-filters) correctly returned nothing. Before
#9339 that COUNT returned 0. The same gap already existed for filters
without `#a`.

Any empty `authors`, `ids` or tag set now maps to the existing `kinds:
[]` match-nothing sentinel, which every read (REQ, COUNT, `/query`,
`/count`) answers without a query. An empty string value such as `"#d":
[""]` is still a real constraint.

Why nothing gets worse: only filters that provably match nothing change,
and they now skip the database instead of reading or counting.

### Validation

Added tests:
- Unit table over empty `authors`, `ids`, `#e`, `#p`, `#d`, `#h`, `#k`
and `#t`, each with and without `#a`, plus `"#d": [""]`. It fails with
the sentinel disabled.
- Postgres table: WebSocket and HTTP COUNT are 0, and REQ and `/query`
are empty, for each empty set beside a matching `#a` and for
`{kinds:[7], authors:[]}`. The first commit contains only the tests.

Existing workspace unit tests pass (pre-push). The Postgres lanes run
separately on an isolated cluster.

🤖 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-12 06:21:21 -08:00
Tim Mellor
0e6037c74d buzz-cli: add --before and --before-id to messages search (#9382)
## Summary

`buzz messages search` returns at most the newest 100 matches after
`--since` and had no end time. So an author-only search could never
reach older messages. This adds two flags to page back in time:

- `--before <unix ts>` — sent to the relay as the filter's `until`
(inclusive).
- `--before-id <event id>` — requires `--before`. Sends the relay's
existing `before_id` cursor, so events from the same second are neither
repeated nor skipped. This is the same `(until, before_id)` cursor that
`social notes --before/--before-id` already uses.

Without the new flags, the filter and output are unchanged.

### How to page

1. Run `buzz messages search --author <key> --since <ts> --before <now>
--limit 100`.
2. For the next page, pass the last result's `created_at` as `--before`
and its `id` as `--before-id`.
3. Stop when a page comes back empty.

Results are newest first, and events from the same second are ordered by
ascending id. That matches the relay's cursor order.

### `--query` rule

`--before-id` with `--query` is a usage error (exit 1). Full-text
results are ranked by relevance, not time, and the relay's search path
does not apply `before_id`. `--before` on its own still works with
`--query` as a time bound.

No relay change was needed: the relay's non-search query path already
honors `until` and `before_id`.

## Tests

- New unit test for the filter the command builds. It covers: no flags
(unchanged filter), `--before` mapping to `until`, `--before-id`
lowercased, `--before` with `--query`, and the usage errors
(`--before-id` with `--query`, without `--before`, malformed id).
Removing the `before_id` line makes it fail.
- New clap test: `--before`/`--before-id` parse, and `--before-id` alone
is rejected.

## Checks run

- `cargo fmt --all -- --check`
- `cargo clippy -p buzz-cli --all-targets -- -D warnings`
- `cargo test -p buzz-cli` (686 passed)
- Ran the built CLI against a live relay. Paged an author-only search
back across several full pages: no overlaps at page boundaries, and the
union matched an independent time-sliced query exactly (no gaps). A
manual check confirmed the relay applies `before_id` at a shared
timestamp.

buzz-review-completed

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

Signed-off-by: Tim Mellor <117199716+tmellor-block@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:58:58 -08:00
Joah Gerstenberg
a09b84d23d feat(relay): agents cannot write in DMs except with their owner (#9371)
## Summary

Agents stop writing in direct messages, except a DM with only their
owner. This is the relay-side backstop for block/buzz-app#3261, which
takes away the app agents' DM tools. An agent holding its own key could
still sign DM events directly, and only the relay can stop that.

A key counts as an agent when it has a stored owner
(`users.agent_owner_pubkey`, written when the agent authenticates with a
NIP-OA owner attestation). For those keys:

- **Writes into a DM channel** are refused with `restricted: agents
cannot post in direct messages; start a Solo chat that includes your
owner instead`. The check sits in `check_channel_membership` for
`ChannelWrite::Post`, so it covers stream messages, reactions,
deletions, typing and other channel ephemerals, artifact moves and edit
validation. Non-DM channels skip it at no extra cost.
- **Workflow `send_message`** (which posts as the workflow owner and
checks `may_post` directly) applies the same rule before persisting, so
an agent can't route a DM post through a workflow.
- **Put-user (9000) and edit-metadata (9002)** skip the generic gate, so
they get their own check in a DM: an agent never adds DM members with
9000 (an addition to an owner-only DM always widens it), and edits topic
or purpose only in its owner-only DM (the edit posts a system message
with the text).
- **Huddle admission** checks `may_post` directly, so it applies the
same rule to the huddle channel and its lifecycle parent.
- **DM open (41010) and DM add-member (41011)** are refused unless the
resulting participant set is exactly the agent and its owner.
- **Exemption:** a DM whose live members are exactly the agent and its
owner stays open (for example, a personal assistant's home chat).
- Lookup failures fail closed. Confirmed agent owners are cached per
community (`agent_owner_cache`, 5 min TTL). The link is first-write-wins
and never changes once set. A missing link is never cached, because
another relay process may write it later. `link_agent_owner` primes the
cache.

Humans are unaffected: they can still open DMs that include an agent and
post in them. The agent answers elsewhere.

Not covered:
- A key that never presented an owner attestation has no stored owner
and looks human.
- The huddle join commit re-check (4e) doesn't repeat the DM rule; the
pre-join admission check enforces it.
- Agents also can't delete their own earlier messages in a non-owner DM
via kind 5, since deletions pass the same `Post` gate.

### Related issue

Companion to block/buzz-app#3261. AGNTOPS-780.

### Testing

New Postgres ingest tests (`handlers::ingest::postgres_tests::agent_*`),
run locally:
- An agent is refused in a 1:1 DM with someone else and in a group DM
with its owner; the humans in both still post.
- An agent posts in a DM with only its owner.
- An agent can open a DM only with its owner; a human can still open a
DM with an agent.
- An agent can't add members to its owner DM, through 41011 or 9000.
- An agent can't set the topic in a DM with a stranger.
- An agent is refused admission to a huddle in a DM with a stranger and
admitted in its owner-only DM; a human still joins.
- An agent's workflow `send_message` is refused in a DM with a stranger
and allowed in its owner-only DM.
- An owner linked after a lookup miss is seen on the next lookup (no
negative caching).

`cargo clippy -p buzz-relay --all-targets -D warnings` is clean. The
other ingest and command-executor tests pass locally (214), except two
that fail only because of my local database: one needs the desired-state
FTS schema, and the other fails creating its restricted role in setup.
Neither reaches DM code. I pushed without the local pre-push `just
test-unit` lane because a `buzz-cli` unit test hung there for about 30
minutes; CI runs that lane.

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

---------

Signed-off-by: Joah Gerstenberg <joah@squareup.com>
Co-authored-by: Hertz <c6741012416ef5c49a865db79682113385c5fb998e8c890234fb442b59e22748@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 23:18:49 -06:00
Tom Brow
1b190d21fc fix(relay): bound each cross-node delivery attempt (#9305)
**Context:** #9302 (merged) stopped the relay from closing connections
when cross-node fan-out falls behind. It also removed the 30 s delivery
deadline. This PR is the fix from the review of #9302.

**Problem:** Since #9302, a fan-out worker waits for the access-gate
database read with no time limit. If a database connection stops
answering in the middle of that read, the worker never continues. Its
queue fills, and one sixteenth of the node's channels get no live events
from other nodes until the relay restarts. Before #9302 the deadline cut
that delivery off after 30 s.

**Fix:** Each delivery attempt has a 30 s limit
(`MAX_DELIVERY_ATTEMPT`), measured from when the worker starts it. When
the limit passes, the relay counts the event in
`buzz_multinode_fanout_dropped_total{reason="stale"}`, resyncs the
artifact syncs the event matched, and continues with the next event. It
closes no connection. An event that only waits in the queue is still
delivered late, as #9302 intends. The queue-full drop and the new stale
drop share one helper.

### Validation

- Added test:
`hung_delivery_is_dropped_at_the_bound_and_the_worker_moves_on` drives
the production worker with a database that accepts the connection and
never answers. It checks that the delivery stops at the limit, is
counted, resyncs the matched sync, closes nothing, and that the next
event on the same worker is delivered. With the limit removed it fails
(the worker waits out the pool's one-hour acquire timeout instead).
- The other `handlers::multinode_fanout` tests pass. The full local
`buzz-relay` suite was not run; CI on this head covers it.

🤖 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-11 21:10:40 -08:00
Tom Brow
27f0fe7527 feat(mobile): add iOS Home Screen shortcuts for New chat, New DM and the Bestie (#9360)
Long-pressing the Buzz icon on iOS now offers three quick actions:

- **New chat** opens a new solo chat (the same draft as New › New chat).
- **New DM** opens the New DM picker.
- **Message <Bestie>** opens the DM with the current community's Bestie,
using the agent's display name (for example "Message astra"). It's
hidden when the community has no Bestie, and it updates when the Bestie,
its name or the community changes.

| Before | After |
| --- | --- |
| <img alt="before"
src="https://github.com/user-attachments/assets/393c3fb6-fe64-4505-a7a4-12714d8bf1df"
width="300"> | <img alt="after"
src="https://github.com/user-attachments/assets/1c57df92-6fed-4fee-9ce8-e9ea0ec70294"
width="300"> |

### How it works

Home publishes the items through a small native bridge
(`buzz/home_screen_shortcuts`), which sets
`UIApplication.shortcutItems`. A tap goes through the scene delegate.
The bridge keeps the latest one until Dart has handled it, the same
pattern push-notification taps use, so cold launches work too. Home runs
it only once the app is active and the destination is ready. When a tap
arrives, Home pops back to the first route once, so a shortcut never
opens beneath a Flutter sheet or a conversation that was already open. A
screen that refuses to close (for example a New chat whose first message
is still sending) stays, and the tap is dropped. The one exception is
the native iOS New channel form, which isn't a Flutter route: a shortcut
tapped with it open runs underneath it. If a screen is opened while a
tap is still waiting for its destination, the tap is dropped. A New DM
tap while the New DM picker is open replaces the picker. Opening the
Bestie DM now goes through one shared function, so the Home launcher
button and the shortcut run identical code. A second open while the DM
is still being looked up does nothing. Signing out clears the items. The
shortcut wiring lives in its own leaf widget, so Home doesn't rebuild
for it.

Behavior change for the existing Home Bestie launcher: if you open
another screen while it's still looking up the DM, the DM no longer
opens over that screen.

Behavior change for the native member picker (New DM, and Add members in
channels): a picker reopened while the previous one is still closing now
opens once the dismissal finishes, instead of failing with "Cannot open
the member picker".

### Validation

- Added tests: a Home journey covers the published titles (including the
Bestie's name), a cold tap that waits for the app to resume and for New
chat to be available, a Bestie tap made with another route open, a New
chat tap while a draft is open, a New DM tap replacing an open picker, a
waiting tap dropped once another screen opens, no Home rebuilds on
lifecycle changes, and clearing the items when Home goes away. A
launcher test covers a slow Bestie lookup, and a New chat test covers a
shortcut tapped while the first message is still sending. I also broke
the code that runs the shortcut and confirmed the journey fails.
- Full mobile suite and `flutter analyze` pass. CI doesn't compile Swift
on drafts, so `NativeAddMembersSheet.swift` was compiled with a local
`flutter build ios --simulator` build.
- iPhone 17 Pro simulator (iOS 27), signed in to a Builderlab community:
from a cold launch, New chat opened the new-chat draft. Then, with the
app warm, New DM opened the picker and Message <Bestie> replaced it with
the Bestie DM. The recording stays in Buzz because it shows real account
data.

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: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 04:20:36 +00:00
Tom Brow
85c7156085 perf(mobile): stop downloading whole videos for posterless stills (#9367)
Follow-up to [#9356 gallery video
selection](https://github.com/block/buzz/pull/9356), merged as
[ef57fec4d](ef57fec4d2).
It makes two changes that came out of review.

### 1. Remove automatic stills for videos without poster metadata

#9356 generated a still for every rendered video bubble or stack card
that had no NIP-71 poster. To do that it downloaded the **whole video**
(up to the 100 MB limit), grabbed a frame and threw the file away.
Desktop (buzz-app) never writes `image`/`thumb` on video attachments, so
this covered every desktop-sent video, not only old mobile messages.
Before #9356 those bubbles were a static placeholder that used no
network.

The cost on a real timeline:
- A four-card stack could pull up to about 400 MB just for stills,
before anyone pressed play.
- When the user did open a video, playback started its own separate
download of the same file, and the poster download kept competing for
bandwidth.
- A failed, cancelled or stalled download saved nothing, so every
remount (scroll back, swipe back in a stack) started the full GET again.
Cached stills from non-relay URLs were also dropped and fetched again
after the cache's seven-day expiry.

This PR deletes that path: `video_poster.dart` and its test, plus the
watchers and `generatedPoster` branches in `video_preview.dart` and
`media_stack.dart`. Posterless videos are back to base behavior: "Video
attachment" in a bubble, the plain surface on a stack card, and the
source snapshot in the native viewer. The rest of #9356 stays: queued
local stills (`prepareVideoPoster` on the local file, no network), the
remote-poster preview in the native viewer, the picker changes and swipe
dismissal.

`NativeVideoFiles._cancelTimeouts` was added for poster disposal. No
remaining test depends on it, so it goes back to base too.

If stills for desktop-sent videos are still wanted, a bounded follow-up
is to generate and cache the still from the file that playback already
downloaded, after the user opens it. That adds no network.


### 2. Route `.3gpp` and `.3g2` picks as videos

The Video row is gone from the attachment menu, so `_isPickedVideo` is
now the only thing that classifies path-only picker results. Its
extension list missed `3gpp` and `3g2`. The recent-media grid returns
the original library file, and PHPicker exports 3GPP2 as `.3g2`, so
those videos were queued as images and then rejected by `uploadImage`.
Before #9356, the explicit Video action uploaded them through
`uploadVideo`, which transcodes to MP4.

### Pareto

The change only removes work: no extra network or decoding for
posterless videos, and no change for videos that carry a poster. The one
thing we give up is the still on posterless videos that #9356 added and
base never had.

### Validation

- Added test rows: `clip.3gpp` and `clip.3g2` in the existing
mixed-gallery composer journey. Both now queue as video.
- Full mobile `flutter test` passes on
[518af3a0d](518af3a0d2):
4,688 passed, 71 skipped. `flutter analyze` is clean.
- I didn't run an app journey for this fix. It brings the posterless
paths back to base code, and #9356's own validation covers the rest.

Review report:
`buzz://message?channel=e71f9a2a-ac46-473d-a24e-5e16fd8cb7b1&id=bcae52d59c255add1a927e0135441a1c58115b6aa6cda8d9ff7e58d47a627a87`

🤖 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-12 03:18:29 +00:00
Brad Seiler
4c60be6a1e Enforce single-community relay mode (#9149)
## Summary

Add `BUZZ_MULTI_COMMUNITY_RELAY` (default `true`, strict boolean). With
`false`, ordinary HTTP/WebSocket traffic serves only the community at
the normalized `RELAY_URL` authority, including a nondefault port. Other
hosts receive a generic 404 before tenant lookup or handler execution.

Authenticated admin/operator inventory and cleanup remain available
through their configured authorities. Existing extra rows do not prevent
startup and are not deleted automatically. Canonical bootstrap and
legacy convergence on the existing canonical community remain available;
operator creation is disabled. Invite minting and git callbacks also
check their target community. Availability retains its free-name meaning
and adds `can_create` to report policy permission.

Changes require restarting every serving revision. An older image can
ignore this setting. Removing it, setting it true, or rolling back to an
older image can reopen serving and creation. Keep operator/admin
authentication configured throughout rollout and rollback.

### Current draft gates

Published candidate `8319e9a4b299deb2bb2cb72400a2c6aff396b362` includes
the independently reviewed deletion-fixture correction. Normal push
hooks and all local validation passed at this head: 2,028 package tests,
full `just ci`, all 1,393 isolated UTF-8/UTC PostgreSQL tests and the
binary build. A running local relay passed 16 HTTP/WebSocket and signed
admin/operator assertions, including extra-host denial and cleanup
access. Hosted PostgreSQL and full CI must still pass on this head.
Human acceptance and the background-work decision remain open; keep this
PR draft.

Background workers currently retain their prior behavior:
cleanup/retention/recovery/revocation continue, as do scheduled
workflows, reminders, configured push/listener delivery, storage-usage
reporting and artifact sync under existing lifecycle rules. Whether to
pause ordinary background work for extras is an open product decision
and an acceptance gate. No worker changes are included yet.

### Rollout and acceptance

1. Resolve the background-work decision, finish validation/review, and
complete the repository-required human test.
2. Obtain separate staging rollout authority, use an immutable
supporting image and explicit false on every serving revision, and
review generated configuration.
3. Verify an extra host returns 404 for HTTP and WS; canonical HTTP/WS
and NIP-FI still work. Verify authenticated admin reads/actions and
operator inventory/archive/delete still reach an extra community.
4. Only after staging acceptance, obtain separate production rollout
authority. This draft grants no merge, deployment or cleanup authority.
5. Rollback by returning the setting to true or the previous image
explicitly reopens serving/creation. Preserve operator/admin access.

### Related issue

No duplicate creation-only policy PR found in the open-PR search.
Related creation surface work: #2398.

Creation-only validation is superseded; see its [historical CI
run](https://github.com/block/buzz/actions/runs/42123475841). It does
not qualify the current candidate.

Generated with Codex

---------

Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Sarnow <1894dcbafaa4df1ac607a9bf879ac0b7d77fbccb0e0f4619ecc252c8adbad8a2@seiler-test.communities.public.builderlabstaging.xyz>
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
2026-11-12 00:10:38 +00:00
Jemiah Westerman
695c1c3aae fix(relay): match #a tag filters in SQL before LIMIT (#9339)
## Summary

**Intent:** let clients find and count reactions to an **addressable
event** at the SQL layer. Addressable events are NIP-01 kinds
30000–39999, which NIP-33 called "parameterized replaceable events".
Clients reference one by its address, not its event id.

An addressable event is identified by its address `<kind>:<pubkey>:<d>`.
Each edit publishes a new event with a new id at the same address. A
reaction (kind:7) that tags the address with `a` therefore still applies
after the target is edited. A reaction that tags only the current
revision's id with `e` is left behind on the old revision.

Use cases:
- **Agent personas (kind:30175).** A ⭐ on a shared persona is a kind:7
tagged `["a", "30175:<owner>:<slug>"]`. A client that lists shared
personas by popularity needs `{kinds:[7], "#a":[...]}` and the matching
COUNT to return every star across persona edits, not only stars on the
current revision.
- **Other addressable kinds**, such as teams and channel metadata, work
the same way with no extra code.
- **Replaceable events, such as user profiles.** Kind:0 profiles and
kind:10100 agent profiles are *replaceable* (kinds 0, 3 and
10000–19999), not addressable. NIP-01 still gives them an `a` address
with an empty `d`, `0:<pubkey>:`, so reactions that tag a profile that
way are matched by the same filter.

Before this change, the relay applied `#a` only after the SQL `LIMIT`. A
selective read fetched the newest kind:7 rows across the whole community
and then dropped the ones that didn't match. It could return fewer than
`limit` matches, or none, while older matches existed. COUNT also could
not take its exact fast path.

What changed:

- **`EventQuery.a_tags`** (buzz-db) works like `e_tags`. It is an exact
positional tag match (`tag->>0 = 'a' AND tag->>1 = ANY(..)`) with JSONB
`@>` as the prefilter on the existing GIN index `idx_events_tags_gin`.
  - **No schema change, migration or backfill.**
- It gets the same MATERIALIZED CTE fence as `#e`, so the planner cannot
walk `created_at` backward looking for a rare match.
  - It reads the table whole rather than recent partitions first.
  - It is counted in `count_events_sql`.
  - An empty set matches nothing, as the NIP-01 post-filter does.
- **`filter_to_query_params`** fills `a_tags` for every filter, and
**`filter_fully_pushable`** treats `#a` as pushed. REQ, COUNT and the
HTTP bridge `/query` and `/count` all go through these two functions.
- **`per_channel_scope`** now skips per-channel reads for every fenced
query, through a shared `reference_tag_fence` helper. Before this, it
excluded `#e` reads explicitly, and `#a` with a multi-value `#h` would
have wrapped the fence around the per-channel lateral, which is invalid
SQL.

Not included: `#k` and other low-cardinality tags. Postgres's estimate
for JSONB `@>` assumes any match is rare, so pushing a common value
could switch to a slower plan. They remain post-filters.

### Related issue

None found.

### Testing

- `req.rs` unit test: `#a` maps to `a_tags` (including empty and absent)
and is fully pushable; `#a` + `#k` is not.
- buzz-db shape tests: an `#a` read is fenced, COUNT applies the match,
an `#a` read is never split into recent and older passes, and `#a` makes
a read ineligible for per-channel scope. An empty `#a` is answered
without a database read.
- buzz-db Postgres: an `#a` case added to
`pushed_down_tag_filters_reject_positional_decoys`, covering query and
COUNT.
- New `handlers/tag_pushdown_postgres_tests.rs`:
- REQ returns an older `#a` match ahead of a full page of newer
reactions to another address. COUNT is exact, and an empty `#a` counts
0. This test fails when `a_tags` is removed from
`filter_to_query_params`.
- REQ and COUNT with `#a` + a multi-value `#h`. This test fails, with
`CLOSED database error`, without the `per_channel_scope` fix.
- Full buzz-db and buzz-relay suites, unit and Postgres lanes, run
locally.
- Live check against a local relay built from this branch, over
WebSocket with NIP-42 auth. Two reactions to one address (each on a
different head event, as after an edit) and six newer reactions to
another address were published. `REQ {kinds:[7], #a:[needle], limit:2}`
returned exactly the two needle reactions, newest first. COUNT returned
2 for the needle, 6 for the hay and 0 for an empty `#a`. `#a` + `#e`
with `limit:1` returned the newest needle reaction.

buzz-review-completed

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

Signed-off-by: Jemiah Westerman <jemiah@squareup.com>
Co-authored-by: Son of Anton <8e0ef2ab02d69a7ac089ef5eca90d1a3fbbdc546f6322dc34d0c4f3deecea7f4@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 15:41:05 -08:00
klopez4212
96e56c5b33 Add participant avatars to mobile conversation headers (#9369)
## Summary

Add participant portraits to mobile conversation headers, with up to
four portraits in a compact cluster. DM and Solo headers show
counterparts; channel headers use the member roster. Names stay on one
line, and tapping either the portrait or title opens the existing
settings.

- iOS: float portraits above the glass title capsule; lift standalone
animated artwork and tuck the title underneath.
- Android: place the cluster inside a left-aligned title pill, 16 dp
after Back, with smaller agent portraits and a 48 dp tap target.
- Remove header presence/member-count subtitles. In confirmed two-member
conversations (DM, Solo, or channel), hide redundant incoming author
avatars and give text the freed 48 dp while retaining the previous total
width. Apply this to thread replies and align their footers; messages
with avatars and outgoing messages retain their production width caps.

Keep sender attribution visible while DM metadata and current membership
disagree. Forum loading, error, and post-list states reserve the full
conversation header height. The floating title follows the app-selected
appearance, including when it differs from iOS. Conversation headers
remain name-only, including temporary conversations, as requested;
retention accessibility text is preserved.

## Validation

- Formatting/static analysis and full mobile suite: 4,695 passed, 71
skipped; auxiliary push and crypto suites passed.
- Full native iOS suite passed, including title positioning/reset and
settings activation.
- Installed previews on physical iPhone Air and Pixel; Android
avatar/title settings taps exercised.
- Final diff reviewed for correctness, accessibility, scope, and
public-source boundaries; no blocking findings.

The width follow-up is covered by rendered conversation/thread tests.
The width follow-up is installed and launched on iPhone Air; the Pixel
preview remains on the preceding header build. The review fixes have not
been installed on physical devices.

## Snapshots

Current Pixel preview, cropped to the header to omit conversation
history. These captures show a single agent and a four-portrait cluster.
The current iOS preview is installed, but a fresh physical-device
screenshot was blocked by device authentication; no outdated iOS capture
is included.

### Android — single agent

![Android single-agent
header](https://github.com/user-attachments/assets/9ecb45b7-a2e0-4699-8889-ab7934fd1759)

### Android — grouped agents

![Android grouped-agent
header](https://github.com/user-attachments/assets/f4435720-0d10-4b4e-98c2-5494dc9a83c4)

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-11 15:28:39 -08:00
Logan Johnson
8f80b172ea fix(mobile): stop asking for an API token when the channel list is refused (#9327)
## Summary
- When the relay refused the channel-list request (HTTP 401), the mobile
app said "Not authorized. Check your API token." The app has no API
token, so the advice could not be followed.
- The message now reads "This community couldn't authenticate the
request. Try again." Retry stays: for org and hosted communities the
refused token is dropped and Retry gets a fresh one, and a self-hosted
relay that refuses the key has no other recovery on this screen.
- One exception: when the organization sign-in has ended, the app sends
reads unsigned and Retry can never succeed, while the Home banner
already offers a working Sign in. In that state the channel list says
"Use the sign-in prompt below to reconnect." and hides Retry.

## Details
- `_ErrorView`
(`mobile/lib/features/channels/channels_page/badges.dart`) only appears
on a cold load with no saved channel list. It replaces the list, so
Retry is the only control on that screen.
- It reuses the shared `organizationSignInPromptVisible` predicate, the
same one the Home banner and the community switcher use, so the error
view and the banner agree on who owns the action.
- The 403 "Access denied." message is unchanged.
- Widget tests cover the new 401 copy with Retry, and the signed-out
organization state with no Retry.

## Screenshots
Android emulator, debug build, against a local test relay that refuses
the channel-list read with HTTP 401 (no saved channel list, so the error
view shows):

<img
src="https://github.com/user-attachments/assets/feebc324-a4cf-49ca-876a-1ae026bda753"
alt="Channel list error: This community couldn't authenticate the
request. Try again. With a Retry button" width="360">


🤖 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: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 14:58:14 -08:00
Brad Seiler
75d38b6c2d chore(release): release Buzz Relay version 0.3.0 (#9362)
## Summary

Prepare Buzz Relay 0.3.0 from freshly fetched `main` at
`e5dee91c90fdc5f81f04fd609b7c99e31fa7570f`. Bump only the relay crate
version and its root lockfile entry from 0.2.1 to 0.3.0, and prepend the
relay changelog. Desktop, CLI, mobile, Helm, and dependency versions are
unchanged.

The changelog contains all 297 non-merge commits selected by the relay
release recipe from `relay-v0.2.1` through the pinned base. Internal
ticket identifiers were removed from historical subjects to follow the
repository's public-source policy; entries and PR/full-SHA source links
are preserved. [Full source
comparison](https://github.com/block/buzz/compare/relay-v0.2.1...e5dee91c90fdc5f81f04fd609b7c99e31fa7570f).

### Related issue

Release preparation request; no duplicate release 0.3.0 PR found.

### Testing

- `just bump-relay-version 0.3.0`: completed; the lockfile changes only
the `buzz-relay` version, without transitive dependency updates.
- `cargo metadata --locked --no-deps --format-version 1`: confirms relay
version 0.3.0.
- `git diff --check` and mandatory pre-commit formatting: passed.
- `bash scripts/test-release-ref-contract.sh` passed in a clean local
clone of `3def95c9435b2884c01446fe1a1f92f4fe9960fc`.
- Mandatory pre-push checks passed at
`3def95c9435b2884c01446fe1a1f92f4fe9960fc`. Independent review of the
commit and published head/body/draft state found no defects.
Repository-wide `just ci` passed at
`3def95c9435b2884c01446fe1a1f92f4fe9960fc` after reproducible dependency
setup; the worktree remains clean. [GitHub CI attempt 2
passed](https://github.com/block/buzz/actions/runs/43092559585/attempts/2)
at that exact head. The prior attempt failed before tests in three
artifact downloads; retrying the failed jobs resolved the service errors
without code changes. Local checks and independent review passed; human
acceptance remains separate. The existing ready state is preserved.
Local integration validation is blocked: Docker Desktop starts and its
daemon answers, but managed sign-in policy denies container access until
the operator signs in.

### Release hazards

Preparation only: **merging this PR automatically creates `relay-v0.3.0`
and starts image publication**, including stable version aliases and
`latest`/`debug-latest`. No merge, tag, or deployment is part of this
preparation.

The [relay
tagger](e5dee91c90/.github/workflows/auto-tag-on-release-pr-merge.yml (L64-L105))
tags the merge commit rather than the reviewed PR head. Changes arriving
on main before merge would also ship; the changelog is accurate only
while main remains at the pinned base. Regenerate and revalidate before
merging if main advances. The branch must stay `relay-release/0.3.0`:
the tagger derives the version from its name and does not compare it
with the crate manifest.

The recipe's changelog path selection omits some shipped components,
including `buzz-feature-flags`, `buzz-datastore-tracing`,
`buzz-deletion`, `buzz-relay-mesh`, `Dockerfile`, `web/`, and root
dependency lockfile changes. Use the full comparison above for the
complete source change set. The Helm chart's existing `appVersion:
"0.1.0"` remains a separate follow-up.

The release recipe's bump and changelog generation were executed in an
isolated worktree. Publication uses an ordinary push and explicit draft
PR instead of the recipe's reset/force-push/non-draft publication steps.
No release automation changes are included.

Generated with Codex

Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Honor <e0bf479eeebe47dde1e35705212a4df70616a7bc834d907ddfb18cf0405f8b1b@seiler-test.communities.public.builderlabstaging.xyz>
relay-v0.3.0
2026-11-11 17:43:44 -05:00
klopez4212
ef57fec4d2 Fix gallery video selection, thumbnails, and swipe dismissal (#9356)
## Summary

Photos currently filters out videos, video messages without poster
metadata stay grey until playback, and the native gallery rejects
downward dismissal on video pages.

- Include photos and videos in the native inline/popover pickers, system
picker, and recent-media grid. Mixed selections retain their order and
videos use the video upload path. Remove the redundant Video menu row on
iOS and Android, whose Photos picker accepts both media types; reduce
their menu height to four rows. Native pickers request the current asset
representation to avoid compatibility transcoding and export up to three
files concurrently, preserving order and cleaning up cancelled/failed
batches.
- Show local stills for queued videos. For older messages without
posters, serialize thumbnail generation for the four rendered stack
cards, bound/cancel downloads, remove temporary videos, and save stills
in the existing account/community cache. Existing poster metadata is
used directly. The native viewer uses the poster while loading instead
of a play-button snapshot.
- Allow downward dismissal on native video pages while preserving
horizontal paging/scrubbing, zoomed-photo panning, and the accessible
Close button. Fire selection-click haptics as a swipe begins rather than
after the page settles, with no duplicate on completion or cancellation.
Page-indicator changes retain immediate feedback. Replace visible
video-loading text with the existing white arc-spinner treatment,
implemented natively with reduced-motion and lifecycle handling.
Completed swipes release the previous video player even after UIKit
removes it from the active children.

Legacy videos need one initial download (up to the existing 100 MB
limit) to generate their cached preview. This does not start playback.

### Related issue

Follow-up to merged #9259 and #9314. Searched open and closed
gallery/video issues and PRs; no duplicate fix found.

### Testing

- Full `just mobile-check` and `just mobile-test` passed at
`12e6ed6ae3`: 4,688 Flutter tests (71 skipped), 4 unconfigured-push
tests, and 27 crypto-package tests. File-size checks passed. Regressions
cover mixed image/video upload routing, queued still previews, the
rendered-card poster window, authenticated/cancelled downloads,
caching/cleanup, and native poster loading.
- Native gallery simulator regression fixture passed: photo/video
dismissal guards, real MP4 decoding, paging, selected-only playback
loading, and teardown.
- Swift type-check of the native picker and its dependencies passed,
with existing deprecation/capture warnings.
- `just ci` passed its repository-wide lint/type checks. The unrelated
Rust test rebuild was stopped after consuming about 13 GB to preserve
remaining disk space; only that run's generated caches were removed.

Release build at `fb09f9cb8` installed in place and launched on the
physical iPhone Air as **Buzz Photo Stack**, with
`BUZZ_NATIVE_PHOTO_ZOOM=true`. Existing app updated in place with the
same bundle and Keychain identity; no uninstall or reset. Owner tested
the physical-device preview and approved readiness for review. No
before/after capture was produced. The simulator evidence above is an
automated native fixture, not a full Buzz device journey.

Native preparation follow-up:
`scripts/test-mobile-picker-preparation.sh` passed on an iPhone Air
simulator, covering bounded overlap, out-of-order completion,
cancellation, and failure cleanup. The updated physical Air preview
launched successfully with unchanged preferences metadata. Cloud-only
assets may still require preparation.

Loader/haptics follow-up native fixture passed: real video readiness
hides the loader, error/retry and detach stop/restart it correctly,
swipe start triggers one haptic before selection changes, and
completion/cancellation/duplicate-start callbacks add none. Updated
release installed and launched on the physical Air; owner approved the
final preview for review.

Review: focused code review and native regression checks completed;
physical Air preview tested by the owner, who approved readiness.


P2 follow-up: video presentation uses an already decoded poster or the
source snapshot immediately, then forwards a pending poster
asynchronously. Late poster updates are limited to the selected native
page and do not restart playback. Added pending-provider presentation
coverage and native late/stale-poster checks. This follow-up has not
been installed on the physical preview yet.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-11 13:22:25 -08:00
Logan Johnson
e5dee91c90 chore(deps): bump hickory-proto to 0.26.2 for RUSTSEC-2026-0487..0491 (#9351)
## Summary
- The dependency-policy check (cargo-deny) on `main` fails because
`hickory-proto` 0.26.1 now has five published security advisories:
RUSTSEC-2026-0487, -0488, -0489, -0490 and -0491 (panics and memory
amplification when parsing malformed DNS records or zone text).
- This updates `hickory-proto` to 0.26.2, which carries all five fixes.
With it, `cargo-deny check` passes again (advisories, bans, licenses and
sources all ok).
- The desktop app's separate Rust workspace (`desktop/src-tauri`) gets
the same update. CI does not run cargo-deny there, but release builds
include `hickory-proto` through the `mesh-llm` feature, so they would
otherwise still ship 0.26.1.

## Details
- `hickory-proto` is a transitive dependency: `iroh` →
`hickory-resolver` → `hickory-net` → `hickory-proto`. No crate names it
directly. `hickory-net` and `hickory-resolver` stay at 0.26.1; they
accept `hickory-proto ^0.26.0` and get the fixes through it.
- Lockfile-only change: the `hickory-proto` version and checksum in each
lockfile. 0.26.2 has the same dependency list as 0.26.1, and the locked
versions already meet its raised minimums. A plain `cargo update -p
hickory-proto` also moved ten unrelated Windows-only crates from
`windows-sys` 0.59 to 0.61; those are left out to keep this change to
the advisory fix.
- 0.26.2 is not behavior-identical: it rejects malformed DNS input that
0.26.1 panicked on or over-allocated for, and it treats IPv4-mapped IPv6
addresses as IPv4 in resolver answer filtering.

🤖 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-11 11:01:50 -08:00
Tom Brow
95dd9d67dd feat(mobile/ios): show the message's own text in notifications (#9346)
Expanding a Buzz notification today shows the same stub as the banner:
one line of at most 180 characters, with code replaced by `[code]` and
links by `[link]`. A long message can only be read by opening the app.

The notification body is now the message's own text. It keeps line
breaks, the text of code and bare URLs, and drops the Markdown syntax
around them (`[text](url)` shows `text`). It is capped at 256 UTF-16
units, which is the most iOS shows even when a notification is expanded.
iOS cuts longer bodies mid-word with no ellipsis, so the cap stops at
255 units and adds "…" without splitting a character. A link label,
image alt text or bold run may contain code, and a code fence with no
closing fence runs to the end of the message. The body is built from at
most the first 4096 UTF-16 units of the message, so very long or hostile
Markdown stays fast (under 300 ms for a 256 KB message in an optimized
build); when that cut drops text, the body also ends with "…".

| Before (expanded) | After (expanded) |
| --- | --- |
| <img width="300" alt="Before"
src="https://github.com/user-attachments/assets/7201791f-9556-446a-86d2-a50f024cf9dc">
| <img width="300" alt="After"
src="https://github.com/user-attachments/assets/3fa52db8-ebcf-470f-9175-47fe5ef4ed12">
|

[ADR 0002](docs/adr/0002-notification-message-text.md) records the rules
so other mobile platforms follow them, and the cases in
`test-fixtures/notification-body.json` are the contract. The iOS tests
run every case, and Android's notification code can run the same file
when it lands.

**What gets better, what doesn't get worse:** only the body text of
message notifications changes. The title, subtitle, grouping, avatars,
navigation and dedupe are untouched, and the change lives in one pure
function. A notification can now show code and URLs from the message, as
the message itself does, and the user's lock-screen preview setting
still applies.

### Validation

- Added tests: `testPreviewBodyMatchesSharedNotificationBodyCases` runs
the 30 shared cases (links, mentions, images, fenced and inline code,
code inside link labels and bold runs, Markdown inside code, unclosed
and cut fences, whitespace, the cap at 256 and 257 units, emoji and ZWJ
sequences at the cap, the 4096-unit input cut). The full BuzzPushKit
suite passes: 112 XCTest and 70 Swift Testing tests.
- iOS limit measured on an iPhone 17 Pro simulator (iOS 27) with a local
notification: the expanded body and its accessibility label both stopped
at exactly 256 UTF-16 units (256 ASCII characters, 128 emoji), cut
mid-word with no ellipsis. A 40-line body showed about 31 lines.
- The screenshots show this function's before and after output for the
same message, posted as a local notification. The simulator doesn't run
the Notification Service Extension for `simctl push`, so the full push
path (relay fetch through to display) was not exercised. An iPad was not
measured.
- Human testing is waived; the automated run above covered display of
the changed body only, since nothing else in the app changes.

🤖 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-11 07:17:32 -08:00
Tom Brow
1adde0e6bf feat(mobile): prefill the agents you last tagged in a conversation (#9248)
Desktop prefills an empty composer with the agents your latest message
in that conversation notified. This brings the same behavior to mobile
for channels and threads.

### Behavior

- An empty channel or thread composer starts with `@Agent ` for each
agent that the viewer's latest loaded message in that conversation
notified (its `p` tags). It reads what the view displays: the main
timeline in a channel (top-level messages and broadcast replies), and
the head plus its direct replies in a thread. Deleted messages don't
count, and same-second messages follow the display order.
- People are never carried over, and neither are agents that are no
longer channel members (agent here means a member with the bot role or
an owned agent, the same classification the mention picker uses).
- The prefill comes from messages already loaded, with no extra relay
reads, so it follows sends from other devices.
- It isn't a draft: nothing is saved (no Drafts entry) until the viewer
edits it, and it sends no typing indicator.
- Removing it lasts until the composer is shown again (leave and reopen)
or the viewer sends another message there. Sending without the agent
ends the chain.
- An untouched prefill is taken back once it's out of date: when the
viewer's latest message (even one sent from another device) tags no
agent, or its agents have all left the channel. Text the viewer wrote is
never cleared.
- DMs and Solo chats are unchanged. There's no settings toggle.

Also moves the composer's keyboard-metrics observer effect into a
`helpers.dart` hook, unchanged, to keep `compose_bar_widget.dart` under
the 1200-line limit.

### Validation

Added tests: a compose bar widget journey covering prefill from the
latest own message (ignoring people and others' messages), no saved
draft until edited, send then re-prefill, removal sticking through a
member/name refresh and returning on remount, and no prefill for an
agent that left. A second test covers taking back an untouched prefill
after a newer agent-less message or the agent leaving, while keeping
text the viewer wrote. A unit test checks same-second display order.
Breaking the draft skip, the removal guard or the take-back fails them.

Full mobile suite: all pass except 4 `swipe_reply_tests` "right drag …
replies" cases, which fail identically on the base commit (stale after
the swipe-left-to-reply change).

Simulator (iPhone 17 Pro, debug build, test community, run on the first
commit; the take-back follow-up is covered by the widget tests): after
mentioning an agent in a channel, the composer refilled with it; sending
again refilled it; deleting it kept the composer empty until leaving and
reopening the channel, which brought it back; a thread with no
agent-tagged reply had no prefill, and after replying with an agent
mention the thread composer prefilled it. Demo video is shared
internally rather than here, since it shows a real account.

🤖 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-v1.0.0-rc.3
2026-11-11 04:12:28 -08:00
Tom Brow
0ac71eaf95 feat(mobile): start a Solo chat from a message or inbox row (#9251)
Adds **Start Solo chat** to the message long-press sheet and the inbox
row menu, analogous to buzz-app desktop's action.

## Behavior

- Opens an unsent New chat draft (no channel is created until the first
send), with the composer expanded and focused.
- **Open channel messages** (public stream or forum) are seeded with the
message's `buzz://` link, as desktop does, so the agent can open it.
- **DMs, private channels, Solo chats and unknown channels** are seeded
with the message excerpted as a Markdown block quote, because the agent
usually can't read them. A long quote is cut at 6 KB so the first
message stays under the 8 KB limit.
- Starting again from the same message returns to the same draft (keyed
`new-solo:<messageId>`).
- Unlike desktop, it's available on your own messages too.
- In the sheet it's a list row under **Copy text**; in the inbox menu
it's under **Open conversation**, for message items with content.
- The draft title skips a leading block quote and uses the prompt after
it (or the quote text when there's no prompt).

## Notes

- A seeded draft left unsent stays in the inbox Drafts list, as an
unsent New chat draft already does.
- Conflicts with #9142 (inbox row menu follow and remind actions), which
reindents `_showRowActions`. Whichever lands second needs a small
rebase.

## Demo

Public channel message (linked), private channel message (quoted), then
a public channel inbox item (linked) and a DM inbox item (quoted). Test
data only.


https://github.com/user-attachments/assets/91d2de7f-d35d-4907-bcf6-ec904327f06c

## Before / after

| | Before | After |
|---|---|---|
| Message long-press sheet | <img
src="https://github.com/user-attachments/assets/da79ae08-68a0-4396-9a63-f01af9ceb3a3"
width="260"> | <img
src="https://github.com/user-attachments/assets/88ee6ff1-66d6-42fe-8b78-ab7f4e6cbcfa"
width="260"> |
| Inbox row menu | <img
src="https://github.com/user-attachments/assets/5c6f551c-63f5-40ab-b0a3-08778472d964"
width="260"> | <img
src="https://github.com/user-attachments/assets/25f9fa30-ac52-4b78-85fb-5679ca336495"
width="260"> |

Where it lands (new, unsent New chat draft):

| Public message | Private message | Public inbox item | DM inbox item |
|---|---|---|---|
| <img
src="https://github.com/user-attachments/assets/245204d3-0a56-4029-a2a6-e9d054ee6e0e"
width="190"> | <img
src="https://github.com/user-attachments/assets/3d5a100b-5ae5-470e-b945-8c9b24ac1db0"
width="190"> | <img
src="https://github.com/user-attachments/assets/8deea67e-07f4-4fc8-93d8-981fbe848c1d"
width="190"> | <img
src="https://github.com/user-attachments/assets/0f4b62c9-81ba-4d08-a77c-8298f3b46718"
width="190"> |

## Validation

- Automated simulator run (iOS 27 sim, Remote Agents Pilot): sheet on
own message in a public channel seeds the link chip; sheet on a private
channel message seeds a quote; inbox public channel item seeds the link;
inbox DM item seeds a quote. Recorded as the demo above.
- New `solo_chat_seed_test.dart` table test (link, thread link, quote
trimming, multi-line quote) and two new title cases in
`me_chat_creation_test.dart`.
- `flutter test`: all pass except the 4 `right drag starting on/beside a
link replies` cases in `channel_detail_page_test.dart`, which fail
identically on clean `main`.

🤖 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-11 04:12:00 -08:00
Tom Brow
045b637b0d feat(mobile): link to Builderlab web from Settings (#9300)
Neither mobile app offers a way to reach Builderlab's web-only account
features: GitHub linking, billing and creating a community. This adds a
**Builderlab account** row to Settings that opens the build's Builderlab
origin (app.builderlab.xyz on public builds) in the system browser.

The row shows only on public builds. An internal build's Builderlab is
its organization's tenant, where billing and the single community are
managed by the organization, so the link wouldn't lead anywhere useful.
Nostr-only builds have no Builderlab.

Sign-in uses a non-ephemeral `ASWebAuthenticationSession`, which shares
Safari's cookies, so Safari can reuse the Builderlab web session. A
browser other than Safari, or Android, may still ask the user to sign
in.

| Before | After |
| --- | --- |
| <img alt="before"
src="https://github.com/user-attachments/assets/a8f2157b-5a54-41fc-8937-013638b3347e"
width="300"> | <img alt="after"
src="https://github.com/user-attachments/assets/f1161f3b-c926-4a8c-b6a1-7532ef72749c"
width="300"> |

Nothing else gets worse. The change adds one row and one browser launch
on tap, and does no work at startup or in the background. A failure
stays inside this row: if the browser doesn't open, a toast says so.

### Validation

- Added tests: `builderlab_account_row_test.dart` checks that the row
opens `HostedEnvironment.origin` and shows a toast when the browser
fails to open (public build), and that the row is absent otherwise. It
is added to the CI public-build lane.
- `settings_page_test.dart`: the version-footer scroll check now finds
the footer after it scrolls just offscreen. The extra row pushed it
there under public-build defines.
- Settings tests pass under both public and Nostr-only defines, and
`flutter analyze` is clean.
- iOS simulator (public build, signed in to the public Builderlab): the
row appears below Connection, and tapping it opens Safari at Builderlab.
This simulator's session came from an identity import, not the in-app
browser sign-in, so Safari was signed out. I haven't yet verified that
Safari arrives signed in after a real in-app sign-in.

🤖 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>
2026-11-11 04:11:38 -08:00
Tom Brow
2c5c8343ef fix(relay): bound invite preview host lookup (#9308)
**Context:** #9306 (merged) added link-preview tags to the invite page.
To build the media URLs, the relay looks up the community for the
request host. This fixes a problem found in our review of #9306.

**Problem:** The invite page waits for that lookup with no time limit.
When the database is slow, the lookup can take as long as the pool
acquire timeout (3 s) plus the statement timeout (120 s by default).
During that time, a person who opens an invite link gets no page. Before
#9306, this page did not use the database.

**Fix:** The lookup gets 250 ms. If it does not finish, the relay sends
the page without the preview tags. A healthy indexed host lookup
finishes well inside that, so crawlers still get the tags, and nothing
else changes: the media route, the router and the build are untouched.

### Validation

- Added test: a host whose lookup never finishes gets the plain page.
Without the timeout, this test hangs (confirmed locally; it was killed
after 30 s).
- The existing `invite_preview` and router invite tests 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: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
2026-11-10 21:15:13 -08:00
Dave Grochowski
47674dc322 fix(relay): saturate filter limits instead of casting (#9230)
## Problem
Four request paths converted a client-supplied `usize` filter limit with
an inline `as` cast before clamping: `(l as i64).min(max)` (REQ query
params, bridge feed) and `(l as u32).min(max)` (WS NIP-50 search, bridge
channel window).
- A limit above `i64::MAX` turns negative (`usize::MAX as i64 == -1`).
On REQ and the bridge `/query` general path it reaches Postgres as
`LIMIT -1`, which fails the query. On the bridge feed it trips
`feed_count >= limit`, so the feed comes back empty.
- A limit that is a multiple of 2^32 wraps to `0` through `as u32`. That
returns no rows (one row in the window, via `.max(1)`) instead of the
page max.

Bridge thread and HTTP search already clamped before casting. COUNT
drops the row limit, so it was not affected.

## Change
- Add `buzz_core::filter::clamp_limit(limit, default, max) -> u32`. It
returns `default` when the limit is absent; otherwise it saturates the
request to `u32` and then caps it at `max`.
- Use it at every filter-limit site: `filter_to_query_params` and WS
search in `handlers/req.rs`; channel window, feed, thread and HTTP
search in `api/bridge.rs`. The inline casts are deleted, and
`BRIDGE_FEED_MAX_LIMIT` is now `u32`.
- Each site keeps its default, max and `limit: 0` behaviour. In-range
limits are unchanged.

## Testing
Failing before the fix (`cargo test -p buzz-relay --lib
huge_filter_limit`):
```
assertion `left == right` failed
  left: Some(-1)
 right: Some(1000)
test result: FAILED. 0 passed; 1 failed
```
After:
- `cargo test -p buzz-core --lib clamp_limit` → 1 passed (table test
covering absent, 0, in-range, at/over max, the u32 boundary and
`usize::MAX`)
- `cargo test -p buzz-relay --lib -- huge_filter_limit req:: bridge` →
152 passed, 0 failed (135 Postgres tests ignored)
- `cargo clippy -p buzz-core -p buzz-relay --all-targets -- -D warnings`
→ clean; `cargo fmt --all` → clean
- pre-push hooks (incl. rust-tests) passed

## Not covered
No Postgres-backed tests were run locally. The bridge call sites rely on
the shared helper's table test; they have no per-handler tests.

## Review
Every server-side read of `Filter.limit` now goes through `clamp_limit`.
The per-site default, max and `limit: 0` behaviour is unchanged. No
inline casts are left. Scores: minimalism 9, elegance 9, correctness 9.

Blockers: none.

Fixed in review:
- `PAGE_LIMIT_MAX` had been inserted between `filter_to_query_params`'s
doc comment and the function, so the doc comment ended up on the const.
Moved the const above it.
- Corrected the problem statement. The bridge feed returned an empty
feed rather than a Postgres error, COUNT was never affected, and thread
and HTTP search were already safe.

Optional:
- WS search, bridge window and bridge feed have no per-handler
regression test. Reverting one of them to an inline cast would not fail
any test.
- `bridge.rs` now has two functions called `clamp_limit`: the local
moderation `clamp_limit(Option<i64>)` and
`buzz_core::filter::clamp_limit`. They don't collide, but they are easy
to confuse.

## Exercised on a local relay

**Commit tested:** `88d76f7f1`. Dev profile, isolated harness relay on
:3030, authenticated channel member. Human rate limits were raised so
seeding wasn't throttled. I seeded 1,100 kind-9 events in one channel.
The filters were sent as raw JSON so the large integers arrived exactly.

| Path | `limit` 18446744073709551615 | `limit` 4294967296 | `limit` 50
(control) |
|---|---|---|---|
| WS `REQ` | EOSE after 1000 | EOSE after 1000 | EOSE after 50 |
| WS `REQ` with `search` | EOSE after 1000 | EOSE after 1000 | EOSE
after 50 |
| HTTP `/query` | 200, 1000 events | 200, 1000 | 200, 50 |
| HTTP `/query` with `search` | 200, 500 | 200, 500 | 200, 50 |
| HTTP `/query` `top_level` (channel window) | 200, 201 (200 + bounds
event) | 200, 201 | 200, 51 |
| HTTP `/query` `feed_types` | 200, 100 | 200, 100 | 200, 50 |

Every huge limit came back as that path's maximum. None of them produced
a database error, and none came back empty. The relay log has no error
lines, and the access log records the raw `limit` as
18446744073709551615 with `outcome:"served"`.

**Postgres-gated tests:** the PR adds or modifies none. As a related
check I ran `cargo test -p buzz-relay --lib -- --ignored limit`. Run in
parallel, it gave 10 passed and 1 failed:
`rate_limited_query_logs_one_denied_line`. That test passes on its own,
and the whole set gives 11 passed with `--test-threads=1`. It looks like
interference between tests that share the access-log capture, not a
problem with this change.

**Not exercised:**
- Bridge thread path.
- A before/after comparison against main.

Signed-off-by: David Grochowski <dgrochowski@squareup.com>
2026-11-10 22:46:43 -05:00
Logan Johnson
b483bbe765 fix(mobile): tell same-name agents apart in Solo (#9321)
## Summary
In Solo, two agents with the same name looked identical: the agent
picker, the new-chat draft ("Message Atlas"), the chat header and its
agent switcher, and the "working" label all showed the raw name. Now
they use the same disambiguated labels as the rest of the app, so
same-name agents read differently everywhere in Solo, and one agent
reads the same in every place.

### Related issue
None found.

### Testing
Widget tests open the picker and header with two same-name agents,
switch between them, and archive one (the remaining agent goes back to
its plain name). Further tests cover a saved agent switch and a resumed
new chat that still point to an archived agent with a live namesake, and
Solo rows with no working agent building no names. A focused test covers
which name Solo shows. Each fails without its change.

Android emulator, local relay, fictional names. Two live agents are both
named Helper; Atlas and Scout each have an archived namesake, which Solo
already left out.

**New Solo chat › Choose an agent: two agents named Helper**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/ff43e3cd-d8f3-4555-bc6b-6109b57993c0"
width="320"> | <img
src="https://github.com/user-attachments/assets/b1a73f63-fe00-49f8-b6ff-0c6a6cfc2d82"
width="320"> |

**New-chat draft with one Helper picked**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/4f375e0c-ce82-4787-b187-137ca25449ce"
width="320"> | <img
src="https://github.com/user-attachments/assets/d8626e98-e1db-437d-9824-cf83af26d752"
width="320"> |

**Chat settings › Agent row of an existing Solo chat**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/e7b0d3a0-10d2-4140-b5c0-b4a3857085ae"
width="320"> | <img
src="https://github.com/user-attachments/assets/e6a736ec-24af-4142-ba3f-1c68f64d80f7"
width="320"> |

**Agent switcher opened from that row**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/8eb1a66d-ff6d-4dc2-8df7-6c71732f186d"
width="320"> | <img
src="https://github.com/user-attachments/assets/9669231f-d842-4d32-bb13-ecd6a2b01e8f"
width="320"> |

## Details
- Names are compared only across the agents Solo offers. Archived agents
are already left out of that list, so an archived namesake adds no
qualifier.
- A saved choice can still point to an archived agent: a pending agent
switch (its notice and the Resume/Discard dialog) or a new chat waiting
to be resumed. That agent is labeled against the live agents, so it does
not read exactly like a live namesake.
- A Solo row reads names only while an agent is working in it, and
working rows share one name context.
- The chat header reads Solo names only in Solo chats, so channel and DM
pages no longer rebuild on every profile load (from
https://github.com/block/buzz/pull/9324 by Tom Brow).
- Solo keeps showing the owner's own name for an agent (from their agent
list) ahead of the agent's self-published profile name. A verified name
still wins over both.
- An agent with no name now shows the short npub, like every other
picker, instead of a truncated hex key.

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

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: corey <2dafccf60ea6d431a568f6d67fc600160d92a51e415e09dc8b213b8e360663d5@localhost>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost>
2026-11-10 16:20:32 -08:00
Tom Brow
0aca7588e3 fix(mobile): join an open channel from the chat footer (#9253) mobile-v1.0.0-rc.2 2026-11-10 15:32:45 -08:00
Logan Johnson
30e1fa47fb fix(relay): fence pending subscriptions on close or replacement (#2382)
## Summary

A REQ handler runs as a spawned task and can pause in its access checks.
Before this change it took its subscription ID only when it registered.
So:

- `REQ x`, then `CLOSE x` while the REQ was still in its access checks:
the relay sent `CLOSED x`, and then the REQ registered `x` anyway and
sent events and `EOSE` under an ID the client had closed.
- `REQ x` (A), then a newer `REQ x` (B): if A finished its checks after
B registered, A replaced B, or A's refusal retired B and sent a `CLOSED`
the client could not tell from B failing.

Now the read loop admits each REQ and sync REQ, in arrival order, before
it spawns the handler: the newest request owns the ID from the moment it
arrives. A `CLOSE` or a newer same-ID request fences an older one that
is still pending. A fenced request does not register, and does not send
`NOTICE`, `CLOSED`, `EVENT` or `EOSE` for the ID.

This builds on the owner token and lifecycle lock that #8522 added. That
PR fenced output after registration; this one moves the token to
admission, so the same fence also covers the time before registration.

The pubsub retain no longer waits on a full subscriber command queue.
Callers hold a connection's lifecycle lock while they retain. A retain
past a full queue now leaves a coalesced wake-up, and the subscriber
subscribes every retained topic it is missing. A retain that fits in the
queue leaves no wake-up, so first retains do not each make the
subscriber rescan the map.

## Behavior

- `close::admit` runs in the read loop before spawn. It records the
request's token in the connection's subscription map, with a flag that
says whether the request has claimed yet. It also applies the
subscription limit, which moves there from the handlers.
- Claims (`claim_admitted_live`, `claim_admitted_search`, the sync
claim) register only while the request still owns its token.
- Refusals after admission go through `close_if_owner`. The two REQ
refusals that also send a `NOTICE` send it in the same critical section.
- Refusals before admission (rate limit, admission quota) still retire
the old holder, as before.
- The old holder stays registered until the newer request claims or is
refused, as before.
- A channel-access eviction that removes an old holder while a newer
same-ID request is still pending retires only the old holder. It sends
no `CLOSED` for the ID, because the client already replaced that holder,
and the newer request still claims.

Client-visible changes to the subscription limit:

- The limit is checked on arrival, before scope, auth and filter checks.
On a full connection, a REQ for a new ID that would also fail those
checks now gets `CLOSED "error: too many subscriptions"` instead of the
other refusal.
- Requests still in their access checks count toward the limit.

## Scope

Live fan-out does not check the token. A same-ID request that has
arrived but not yet claimed does not stop live events to the old holder.
That path is unchanged here.

If a handler panics before it claims, its token stays in the map until a
`CLOSE`, a same-ID REQ, or the disconnect removes it. Until then it
counts toward the subscription limit.

## Testing

- `close_fences_a_req_whose_handler_has_not_run` (REQ and sync REQ):
`REQ x` then `CLOSE x` before the handler runs gives only `CLOSED x` and
no registration.
- `only_the_newest_same_id_req_answers`: of two same-ID REQs, only the
newer one answers.
- `superseded_or_closed_requests_cannot_claim`: an older request cannot
claim after a newer one, and a closed request cannot claim or close.
- `revoke_of_a_replaced_holder_spares_the_pending_replacement`: a revoke
of the old holder sends no `CLOSED`, keeps the pending request's token,
and the pending request then claims its own channel.
- `queue_full_retain_does_not_wait_and_schedules_reconciliation`
(pubsub).
- `live_subscriber_recovers_a_dropped_subscribe_hint` (pubsub, requires
Redis): it runs the real subscriber on a live link and drops a topic's
subscribe hint with a full queue. The topic is still delivered without a
reconnect and without `Confirm`. The same holds after the topic is
released, unsubscribed and retained again.
- Mutations: with no owner check in `close_owned`, both connection tests
fail. With no owner check in `claim_admitted_live`, the claim test
fails. With no `replaced` check in the eviction, the revoke test fails.
With no reconcile arm in the subscriber, or no `notify_one` in
`retain_topic`, the Redis subscriber test fails.
- CI: the four new relay tests and the full-queue pubsub test are added
to `just test-unit` and `scripts/run-tests.sh`. The Redis subscriber
test runs in a new Redis-backed backend-integration step.
- `cargo test -p buzz-relay --lib` (with `--skip config::tests`, which
hangs locally when run in one process), `cargo test -p buzz-pubsub --
--include-ignored` against a local Redis, `cargo clippy -p buzz-relay -p
buzz-pubsub --all-targets -- -D warnings`, `cargo fmt --all -- --check`.

🤖 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-10 14:16:48 -05:00
Logan Johnson
dd2d3cf4ea fix(mobile): hide archived people and agents from choices; give them their own members section (#9320)
## Summary
Mobile kept offering archived people and agents as choices: Add members
(including adding an agent to a Solo chat), New DM, the `@` mention
chooser, Search › People and the Agents page. An archived agent that
shares a live agent's name also forced a qualifier onto the live one,
because names were told apart across both. The members sheet listed
archived members among live ones with nothing to mark them.

Now, matching desktop:
- Archived people and agents are left out of those choices. History
(messages, activity, profiles) still shows them.
- The members sheet moves archived members out of People and Agents into
their own "Archived · N" section after both: archived people, then
archived agents. Each row reads "Archived · Member" (or Agent) in place
of the role, status or "Working…" line. A screen reader reads it with
the name. The members preview on the channel info page (the first five
members above "See all") follows the same order and label.
- Nothing is hidden or marked while the community's archive list is
unknown or cannot be read, and you are never hidden from yourself.

### Related issue
None found. The desktop members dialog gets the same Archived section in
https://github.com/block/buzz-app/pull/3203.

### Testing
Widget and provider tests cover each surface, the unknown-archive case,
the viewer rule and a community switch. Each test fails without its
change.

Android emulator, local relay, fictional names. Each archived person or
agent has a live namesake. History rows still show archived identities,
as intended.

**`@` mention chooser, typing "@Sc" (Scout `qsfn` is archived)**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/6693729c-66c1-4433-8a7b-4f0e948057ee"
width="320"> | <img
src="https://github.com/user-attachments/assets/eb4f80be-780e-4dbe-8b2b-e6a5b6c3ca97"
width="320"> |

**`@` mention chooser, typing "@Ca" (Casey Lee `9mhy` is archived)**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/b6d409cd-7af1-4da7-b5b9-deece5e21de5"
width="320"> | <img
src="https://github.com/user-attachments/assets/60e21387-791b-4233-b8e6-1a29f94e6810"
width="320"> |

**Add members, searching "Morgan" (Morgan Reyes `ssc2` is archived)**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/1222a923-5e43-4263-843d-57564bdc7af3"
width="320"> | <img
src="https://github.com/user-attachments/assets/dbe58278-788d-4a89-83bf-f0b4bc119c5e"
width="320"> |

**Add members, searching "Atlas" (Atlas `85gl` is archived)**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/55c67a21-4cf5-47c5-b6ac-1ba80d68a3ec"
width="320"> | <img
src="https://github.com/user-attachments/assets/54b19ab3-fa2f-4933-8b67-1056d6e3b3a4"
width="320"> |

**New DM, no query yet**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/6a26fd49-4a06-47bd-ba37-37e5898ab344"
width="320"> | <img
src="https://github.com/user-attachments/assets/3f33eb2b-f27a-4528-91c0-ac76d3fdf44b"
width="320"> |

**New DM, searching "Morgan"**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/22d2d617-3074-4910-8c39-e0778e47a879"
width="320"> | <img
src="https://github.com/user-attachments/assets/fa00b160-e350-49af-94d0-beb15c357bef"
width="320"> |

**Search › People, "Morgan"**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/83a28f2b-9b22-4d0c-8728-8a2dfb10e634"
width="320"> | <img
src="https://github.com/user-attachments/assets/573aed3f-75ab-478f-8c11-bedab5c6ce4f"
width="320"> |

**Agents page**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/d7980f95-825c-4d1a-ad67-95f906a52e9a"
width="320"> | <img
src="https://github.com/user-attachments/assets/6252c38a-b2ec-48dc-ab4e-824ea29bbd85"
width="320"> |

**Channel info, members preview**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/8744cdfd-c073-4ade-bd53-9d5e137cee60"
width="320"> | <img
src="https://github.com/user-attachments/assets/5240b1be-13d5-4f22-85a9-8375f1d8ac18"
width="320"> |

**Members sheet, top**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/c9241478-99b1-452e-ad41-8c38fe080182"
width="320"> | <img
src="https://github.com/user-attachments/assets/3c6f9247-60c6-47df-94b0-9175f25805bb"
width="320"> |

**Members sheet, scrolled to the end**

| Before | After |
| --- | --- |
| <img
src="https://github.com/user-attachments/assets/7da7fcc8-28ea-4c7a-904e-91ca5a068d23"
width="320"> | <img
src="https://github.com/user-attachments/assets/024f676e-8150-457d-b7d0-67fa1a680f8d"
width="320"> |

## Details
- The archive reader (the community's NIP-IA archive list, signed by the
relay's NIP-11 `self` key) moves from `shared/me` to
`shared/identity_archives`, since it is no longer Solo-only.
`hiddenArchivedIdentitiesProvider` is the single rule the surfaces read.
- Pulling to refresh on the Agents page also rereads the archive list.
Other views reuse the last list, as on desktop; it is also reread on
reconnect and when the agent inventory changes.
- A refresh keeps the last known list. After a community or account
switch, the previous community's list does not apply: nobody is hidden
until the new list arrives.
- The shared chooser drops archived identities before names are
compared, which covers Add members, New DM and mentions.
- Search › People asks the relay for 24 people and shows the first 8
that are not archived, so archived matches do not push live people out.
- The channel info preview shows the label on one line, so with a long
name the role after "Archived" can be cut off. A screen reader still
reads it in full.
- Not changed: typing a full `@Name` by hand (without the chooser) still
resolves against archived channel members too, so an archived namesake
can still make a typed name ambiguous. Picking from the chooser is
unaffected. Possible follow-up.

🤖 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-10 11:10:49 -08:00
klopez4212
b40f22114b Add video stacks and native iOS zoom playback (#9316)
## Summary

Videos previously rendered outside photo stacks and opened only the
Flutter video viewer. Message bubbles now group trailing videos and
photos in their original order, with poster thumbnails or a visible play
placeholder. On iOS 18+, the native zoom gallery (enabled by default)
opens both standalone videos and mixed stacks with AVKit playback
controls, and closing restores the selected stack item.

Flutter downloads only the selected video using fresh media
authentication, streams it to a bounded temporary file, and
cancels/deletes it when selection changes or the viewer closes. UIKit
releases the old player before that file is removed. Failed downloads
and playback expose Retry. Video URLs never enter the image decoder, and
photo-only actions are hidden on video pages.

The existing Flutter viewers remain the fallback. Native zoom defaults
on; `--dart-define=BUZZ_NATIVE_PHOTO_ZOOM=false` remains an explicit
build-time opt-out. Unsupported platforms/formats, unavailable native
presentation, and bridge failures use the Flutter fallback. Native video
pages use Close or accessibility escape to return with zoom; the
photo-specific downward drag recognizer is disabled there to avoid
competing with AVKit controls.

### Related issue

Stacked on #9314 (PNG/HEIC photo formats); merge that first. No matching
open video-stack/zoom PR found.

### Testing

- Default-on follow-up: presentation tests also cover unavailable, null,
and throwing bridge responses.

- `just mobile-check mobile-test`: analysis clean, 4,601 mobile tests
passed, plus push-configuration and secp256k1 package tests.
- Full native-enabled mobile suite: 4,626 passed, 68 skipped; rerun
passed at `dc752dccaa664cb56a19cbe517a266d1407a1100`.
- Mixed-stack widget journey covers order, video opening, returning to
selection, and the photo-only fallback gallery.
- Native bridge tests cover video snapshot presentation, selected-only
dispatch, stale sessions and keeping video URLs out of image decoding.
- Real HTTP download journey covers authenticated requests, Retry, byte
limits, cancellation and temporary-file cleanup.
- Native simulator fixture decodes a real MP4 and verifies selected-only
loading, player release on paging and teardown. Removing playback
cleanup makes the regression fail; restoring it passes.
- No physical-device installation or end-to-end zoom recording captured.

### Widget previews

Actual Flutter renders with sample video URLs and no poster metadata;
these show the stack layout, not device playback.

| Received, light | Sent, dark |
| --- | --- |
| ![Received video stack without poster
images](https://github.com/user-attachments/assets/af6d06a7-10e3-43c4-87de-52324be5b19c)
| ![Sent video stack without poster
images](https://github.com/user-attachments/assets/5b79b5b1-e3d4-4482-bbf6-574849d5dfdc)
|

### Human verification pending

On a normal build on iOS 18+ (no extra flag required), open a message
containing photos and videos, page between them, play a video, and
close. Verify playback controls work, only the selected video plays, and
closing returns to the selected stack item.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-10 10:03:56 -08:00
klopez4212
e2dc54e22f Support PNG and HEIC photos in the native iOS zoom gallery (#9314)
## Summary

The opt-in iOS zoom gallery rejected PNG and HEIC/HEIF photos, so photo
stacks containing those files opened the Flutter fallback. Accept these
formats by MIME type or file extension, including uppercase extensions
and URLs with query strings.

Preserve animated PNG playback by forwarding continuing decoded frames
to the selected native page. Keep the existing five-photo load window,
coalesce frames while the bridge is busy, skip offscreen frame encoding,
and release listeners on eviction or dismissal. Native updates reject
stale sessions, offscreen pages, malformed frames, and closed viewers.

`BUZZ_NATIVE_PHOTO_ZOOM` remains opt-in. iOS below 18 and unsupported
formats retain the existing gallery.

### Related issue

Follow-up to #9259. No matching open issue or PR found; #7666 concerns
desktop HEIC upload tiling, a separate path.

### Testing

- `just mobile-install mobile-check mobile-test` passed.
- Full mobile suite with `BUZZ_NATIVE_PHOTO_ZOOM=true`: 4,623 passed, 68
skipped. Standard configuration: 4,599 passed, 92 skipped; push
configuration and secp256k1 package tests also passed.
- Real PNG and HEIC decoding through the production presentation bridge;
real two-frame APNG decoding with continuing-frame and listener-cleanup
assertions.
- `scripts/test-mobile-photo-zoom.sh` on an iOS 27 simulator exercises
the production native controller, including frame updates, stale-page
rejection, malformed input and teardown.

No physical-device or end-to-end zoom recording captured. This extends
accepted image formats and playback without changing the gallery layout.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
mobile-v1.0.0-rc.1
2026-11-10 07:34:40 -08:00
Tom Brow
13a6aa19f4 fix(mobile): list known people in recipient pickers before a name is typed (#9311)
## Problem

Add members (and New DM, which shares the picker) showed an empty list
until the first character was typed. An empty query runs no directory
search, so the only candidates were the viewer's own agents and agents
shared with them. Once those are members, or while the owned-agent list
is still loading, nothing is left.

`@` completion shows the channel's members before a name is typed. A
recipient picker has no channel to draw from, so this lists the people
the viewer already knows instead.

## Change

- With an empty query, `recipientCandidatesProvider` adds people from
the viewer's open channels and DMs (from the member lists the channel
list already loaded). Add members skips the target channel's own members
before choosing. The 50 people (`mentionSuggestionLimit`) are gathered
from DMs first, then channels, and shown in the usual label order.
- Empty-query rows list agents ahead of known people, so people never
push an agent off the 50-row list. When an agent the opening hasn't
offered yet arrives after rows were shown (the viewer's own agents
loading late, a picker opened mid-reload, or a reconnect that brings a
new agent), the people rows start over so it takes its place ahead of
them; agent rows already shown keep their places, including one whose
list is still reloading. A reload that brings no new agent moves
nothing, and typed rows never restart. Known people don't wait for the
agent lists, which never load offline.
- Member lists carry no profiles, so the unnamed people among the 50 are
loaded through the user cache's batched preload, each person once per
picker opening regardless of rebuilds.
- The roster scan stops at 50 people or 2,000 roster entries, whichever
comes first, and doesn't copy rosters.
- Typed queries and `@` completion are unchanged: same ranking,
directory search only, no known-people rows mixed in.

## Why nothing gets worse

- Network: at most 50 profile reads per opening, batched, only for
uncached people, and never repeated on rebuilds (new messages, presence,
profile updates).
- CPU: at most 2,000 roster entries per rebuild, only while a picker
shows an empty query.
- Agents: every agent the picker listed before still lists, ahead of the
new rows.
- Ranking, selection, typed search, the viewer and unshared-agent
filters, and "Add members hides existing members" are unchanged.

## Validation

- Added tests: `recipient_known_people_test.dart` (empty query lists
people from open channels and DMs, excludes archived and non-member
channels, loads a missing name; agents stay ahead of people and people
don't wait for an unloaded agent list; the real user cache reads each
unnamed person once across rebuilds; Add members skips the channel's own
members; at most 50 people are gathered; typed query adds nothing) and
`recipient_late_agents_test.dart` (Add members sheet with a controllable
own-agent list and 60 known people: agents arriving on first load, in a
picker opened mid-reload, and on a reconnect that adds a new agent all
take rows ahead of the people; reload, land, reload, land moves nothing;
typed rows keep their places; 55 agents fill the list and stay put
across reloads and a rename that changes their order; both agent lists
reloading after a reconnect and landing in either order, with a new
agent in the first, keep every earlier agent's row). Confirmed the
late-agent tests fail without the restart. Confirmed the ordering and
read-once tests fail without their fixes. Confirmed the first test fails
without the fix.
- Focused existing validation: `flutter test test/features/channels`
(2,308 passed), `flutter analyze` on the changed directory.
- Automated app run (on an earlier head; iOS 27 simulator, iPhone 17
Pro, a Builderlab test account): opened a private channel, Channel
details, Add members. Before: only the account's own agents. After:
agents, then people from its channels and DMs with names and avatars;
typing `ar` still narrows to directory matches. Captures show real
community members, so they were shared internally rather than embedded
here. Native iOS sheet only; Android and New DM share the provider but
were not run in the app.

🤖 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: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 15:18:36 +00:00