Commit Graph

196 Commits

Author SHA1 Message Date
Tom Brow
02ce1d4cb8 fix(mobile): bound queued media authentication work
Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-29 13:32:20 -07:00
Tom Brow
ebc316b91b perf(mobile): sign navigation media requests off the UI isolate
Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-29 13:32:20 -07:00
Tom Brow
ccfc1490e4 perf(mobile): verify signatures with native libsecp256k1 (#8657)
The app already verifies signatures in a few places: NIP-OA agent
profiles and owner attestations, Solo `me_*` records (agent inventory,
archived agents, assistants, sync), and pairing events. Each used
`nostr.Event.fromJson`'s pure-Dart BIP-340 verify, about 11.2 ms per
event on an iPhone SE (2nd gen, Profile). This routes them through
`verifiedNostrEventFromJson` and `verifySchnorrHex`, which keep the same
checks (timestamp range, canonical id, Schnorr signature) and the same
`nostr` exception types, with the signature check in libsecp256k1 (40
µs). NIP-OA owner authorizations are now signed with `signSchnorrHex`,
which removes the last pure-Dart Schnorr call in `lib`.

Incoming relay events are still not verified; that stays a separate
decision.

A native trace showed pure-Dart Schnorr verify in `me_sync` and `nip_oa`
costs 100-125 ms per foreground. This PR moves exactly those sites.

### Validation

- Added tests: the native verifier accepts a valid event and rejects
tampered content, a wrong signature, a different pubkey, malformed and
short signatures, and a zero timestamp, each also rejected by the
pure-Dart verifier. Hex Schnorr helpers are checked in both directions
against `nostr.Schnorr`.
- Focused existing validation: crypto, NIP-OA, Solo/me, pairing and
agent tests pass. `just mobile-install mobile-check mobile-test` passes
at f90823cd1.
- Device: a Profile build of the full native stack ran on an iPhone 12
mini against the internal relay.

buzz-review-completed

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

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: george <973be878edd6d4f79692a3827f20a6f7fe0ed0362f269ff6b6ba8867033d7d8e@sprout-oss.stage.blox.sqprod.co>
2026-10-29 12:50:19 -07:00
klopez4212
0cde1fe2f4 Make mobile Agents a full-page owned-agent directory (#8693)
Replace the mobile Agents sheet with a full-page directory of verified
owned agents. Add dedicated creation and avatar pages with
catalog-backed model selection, reuse the profile emoji/photo/camera
controls, and expose Builderbot model settings while labeling desktop
agents “Manage on desktop.” Share one account-scoped model catalog
across editable cards and retries. Choose Bestie from the agent profile
using the shared encrypted main-agent record (text-only action, filled
star once selected), and move confirmed deletion to a top-left trash
control with native iOS glass. Include uploaded avatars in owner
registrations so desktop can display them.

Validation: mobile analysis and all 3,958 hosted-enabled app tests
passed; default-build tests run by the push hook. Earlier iPhone build
installed and iterated with the user; the current iOS build succeeds,
but final changes still need a device pass. Independent review found no
new blockers. Actual camera capture and live Bestie sync remain
unconfirmed; the existing shared `main-agent` wire format and Bestie
terminology are retained.

### Snapshots

Widget-harness captures with test data (not native iOS chrome).

| Agents directory | Image avatar editor |
| --- | --- |
| ![Agents
directory](https://github.com/user-attachments/assets/c68a0505-90a5-41db-980f-e3197cac63ac)
| ![Image avatar
editor](https://github.com/user-attachments/assets/9b126186-1e7c-4169-9a74-8f7f252cfa93)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-29 18:30:17 +00:00
Tom Brow
3dbc95c3f6 perf(mobile): compute NIP-44 ECDH with native libsecp256k1 (#8656)
`ecdhSharedSecret`, the input to every NIP-44 conversation key
(preferences, read state, reminders, themes, mutes, stars, sections,
sort, pairing, agent observer), used pointycastle's BigInt point
multiplication. That costs about 5.9 ms per call on an iPhone SE (2nd
gen, Profile) and is variable time in the secret scalar. It now calls
libsecp256k1's constant-time `secp256k1_ecdh` through `ecdhX` from #8654
(39.8 µs on the same device), returning the same raw x-coordinate NIP-44
requires. The decoded secret key bytes are zeroed after the call.

Invalid keys now throw `ArgumentError` instead of pointycastle's errors;
callers already treat any throw as a failed derivation.

### Validation

- Added tests: `ecdh_test.dart` compares the native result with the
previous pointycastle algorithm for 32 random key pairs, checks the
NIP-44 v2 `get_conversation_key` vector (secret 1, peer pubkey of secret
2), and checks rejection of a zero secret, an off-curve public key and
wrong lengths.
- Full mobile analyze and test suite pass after rebasing onto main at
da1190e2c, including the desktop nostr-rs NIP-44 interop vector.
- On a device: an iPhone 12 mini Profile build of the full native stack,
connected to the internal relay.

buzz-review-completed

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

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: george <973be878edd6d4f79692a3827f20a6f7fe0ed0362f269ff6b6ba8867033d7d8e@sprout-oss.stage.blox.sqprod.co>
2026-10-29 10:58:25 -07:00
klopez4212
e922dc08ff Unify mobile onboarding and community entry screens (#8687)
## Summary

Use consistent fullscreen screens for identity setup, community
selection, joining, and Add Community. Restore all memberships and open
the selected community; share the community grid with the switcher.

Add QR-first choices, animated link entry with 12px keyboard clearance,
a two-finger double-tap pairing toggle, and the centered reversing bee
loader. Preserve cancellation, Back, and reduced-motion behavior.

Add intentionally omits community creation and Builderlab sign-in: this
route follows sign-in and the existing-community picker.

### Related issue

Related: #8628 (native profile setup). No duplicate fullscreen
onboarding PR found.

### Testing

- Full standard and hosted-authentication mobile suites, static
analysis, and required push checks passed.
- iPhone preview iterated with Kenny; final simulator walkthrough below
uses synthetic data and does not authenticate or save account changes.
- Independent review completed; regressions cover cancellation during
loading, Add recovery after failed/conflicting memberships, retrying
skipped communities, and explicit picker stages.

Walkthrough recorded before the copy and recovery followups:


https://github.com/user-attachments/assets/bfea3ad3-1dde-40c4-8748-44c84226c292

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-29 16:12:31 +00:00
Tom Brow
8abcf2ea2a fix(mobile): keep read state across reconnects and resume (#8672)
### Context
Every app resume and relay reconnect tore down `ReadStateManager` and
built a new one. `ReadStateNotifier.build()` watched the whole
`relaySessionProvider` state, so each status change (disconnected,
connecting, connected) rebuilt the notifier. On an iPhone 12 mini each
resume re-ran the full read-state fetch and decrypt (about 19 to 24
decrypts, 60 to 95 ms).

The rebuild also cleared `_forcedUnreadContexts`, so a message or
channel the user marked unread lost that flag on any connection change.

The watch dates from #422, when the manager was built with
`remoteEnabled` tied to the connection status. #802 made `remoteEnabled`
constant and added the `ref.listen` that calls `reinitializeRemote()` on
connect, but left the watch behind.

### Change
- Watch `relaySessionProvider.notifier` instead of its state. Config,
nsec, community, pubkey, crypto and a replaced session still rebuild.
Status changes don't.
- Reconnects re-sync through the existing listener
(`reinitializeRemote()`: re-fetch, then re-subscribe, preserving the
history and live overlap).
- Re-syncs are serialized. One runs at a time after any pending
`initialize()`, and reconnects that arrive while one waits share it.
Overlapping passes could otherwise each install a live subscription and
leak one.
- A connect that lands while the first `initialize()` is running (the
cold-start connect) skips the re-sync when that initialize fetched and
subscribed successfully. Without this, cold start did the read-state
work twice.

### Validation
- Provider journey: disconnected, connecting and reconnecting cause no
refetch, no re-subscribe and no readiness drop, and the forced-unread
flag and read mark survive. Connected runs exactly one refetch and one
live-subscription swap. A second reconnect behaves the same. Account and
community switches still rebuild and clear forced flags.
- Manager tests: a connect during a successful `initialize()` does not
sync again; one during a failed `initialize()` does, and leaves one live
subscription; three reconnects during a running re-sync produce one more
shared pass and one live subscription.
- Falsified: restoring the old watch fails the reconnect journey
(`isReady` false, flag lost). The previous version without serialized
re-syncs fails the overlap test (5 passes instead of 3).
- `just mobile-check` and `just mobile-test` pass.

iPhone 12 mini (Profile builds of base and this branch, real community
over the production relay, instrumented manager lifecycle and
live-subscription counts):

| | base | this branch |
| --- | --- | --- |
| Resume after 3 s, 10 s (x3), 60 s background | 3 new managers and 3
initializes per resume | no new manager, 1 re-sync |
| Live read-state subscriptions after resume | 1 | 1 |
| Channel marked unread, then 10 s / 60 s background | flag lost after
the first resume | still unread after both |
| Read-state work per resume (median) | ~24 decrypts, ~176 ms main
isolate | ~18 decrypts, ~125 ms |
| Unread badges | same bold set | same bold set |

The first device run also found the doubled cold-start sync, fixed
above. A re-check on this head:

| Cold launch | base | first version | this head |
| --- | --- | --- | --- |
| Managers built | 3 | 1 | 1 |
| Read-state syncs | 2 initializes | 1 initialize + 1 re-sync | 1
initialize |
| Decrypts | 19 | 30 | 15 |
| Main-isolate ms in read-state code | 124 | 185 to 197 | 94 to 98 |

Four resumes on this head: no new manager, 1 re-sync and 1 live
subscription each, 17 to 20 decrypts. A foreground socket drop was not
exercised on the device; every resume there was a full disconnect and
reconnect.

buzz-review-completed

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

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:12:17 -07:00
Tom Brow
8161a7cad0 perf(mobile): derive public keys with native libsecp256k1 (#8655)
Follows #8654.

Deriving an x-only public key from a secret key with
`nostr.Keys(secret).public` costs about 5.6 ms in pure Dart on an iPhone
SE (2nd gen, Profile). The app does it in 10 places, including relay
config, the signed-event relay's `pubkey` getter, push lease checks,
status updates, and the settings identity row's `build` method. This
routes all of them through `derivePublicKeyHex` from #8654, which takes
16.5 µs on the same device.

Behavior is unchanged for valid keys: output is the same lowercase hex.
For a malformed or out-of-range key the thrown type changes from nostr's
`InvalidKeyException` to `ArgumentError`; every affected site either
catches all errors or rethrows its own `FormatException`/`StateError`,
and nothing in `lib` or `test` matches on the old type.

Sites that need a full `nostr.Keys` object (key generation,
`AccountIdentityStore`) are unchanged.

### Validation

- `derivePublicKeyHex` is checked against `nostr.Keys` for 64 random
keys in #8654's `event_signing_test.dart`.
- Full mobile analyze and test suite pass on this branch after rebasing
onto main, including the package's own tests.
- iPhone 12 mini, Profile build of this stack (with #8656 and #8657 on
top): paired to the internal Buzz community through Desktop; relay auth,
identity row, avatar, and channel/DM/inbox loading all worked under the
derived key.

buzz-review-completed (agent review, Codex and security review clean;
device validation above; human app test not applicable under the mobile
testing exception)

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

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: george <973be878edd6d4f79692a3827f20a6f7fe0ed0362f269ff6b6ba8867033d7d8e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:53:58 -07:00
Tom Brow
770ab7ff82 perf(mobile): decode relay query responses off the UI isolate (#8669)
### Context

Profiling on an iPhone 12 mini (Profile build, real community) showed
`RelaySessionNotifier._postForEvents` producing contiguous main-isolate
busy runs with p90 110 ms and max 124 ms (27 runs of 50 ms or longer).
Stacks were dominated by `jsonDecode` of `response.body`, with
`NostrEvent.fromJson` smaller. The whole HTTP response decode ran on the
UI isolate: gzip inflate (done by `dart:io`), the UTF-8 decode behind
`response.body`, JSON parsing, and event parsing.

### What changes

- Successful `/query` and `/profiles/search` responses (the only two
callers of `_postForEvents`) are now decoded in a single `Isolate.run`
from `response.bodyBytes`: inflate, UTF-8, JSON, validation,
`NostrEvent.fromJson`. The parsed events come back by ownership transfer
(`Isolate.exit`), not a copy.
- Gzip: the query transport now sends `Accept-Encoding: gzip` and sets
`autoUncompress = false`, so compressed bodies reach the isolate still
compressed. Whether bytes are gzip is decided by the gzip magic bytes,
so a transport that already inflated (or an injected test client) still
works. Error bodies are inflated inline (small).
- Unchanged: `FormatException` messages, the malformed-event wrapping,
`RelayException.body`, and the rate-limit gate reading the error body.
- Fencing (Review-Proven Rule 2): the `_disposed` / `_ageRestricted` /
`_contextGeneration` check still runs before the decode and now runs
again after it. If the session retires during the decode, the query
reports `StateError` even when the decode itself failed, matching the
old ordering where a retired session never decoded.

### Design notes

- `Isolate.run` per response instead of a long-lived worker: no
lifecycle or disposal to manage, no idle isolate heap on a small phone,
easy to fake in tests, and the same shape as `parseProfileEventBatch`.
The main-isolate side of a spawn measured at microseconds, so there is
no size threshold; small responses take the same path.
- Event id and signature verification does not happen on these events on
the main isolate today (nothing in the mobile app verifies queried
events; NIP-OA verification in profile parsing is already in an
isolate), so nothing was moved.
- Other HTTP decode sites found and left alone: invite create/join,
hosted join policy, BuilderLab account, media upload response, community
icon lookup, dev push lease NIP-11, archived-agents. These decode small,
rare responses (one small JSON object each), where an isolate adds
complexity without a measurable gain. Media and avatar byte downloads do
no JSON decoding. The mobile app has no `/count` caller.

### Validation

Added tests (`relay_session_test.dart`, through the production
`queryRelay` / `searchProfiles` path with the real isolate decoder):
parsing with non-ASCII content; profile search; gzip success body and
`Accept-Encoding`; gzip error body feeding the rate-limit gate and
`RelayException.body`; malformed JSON; non-list body; non-object
element; malformed event; invalid UTF-8; error status skips decoding;
session disposed, age-restricted, or retired-before-decode while a
decode is outstanding.

The fencing tests are falsifiable: removing the post-decode check fails
the disposed and age-restricted tests; removing the
retired-wins-over-decode-error branch fails its test; removing the
pre-decode check fails the never-decodes test.

`FakeRelay` (test helper) now injects an inline decoder, because
`fakeAsync` cannot deliver isolate results; it calls the same parse
function.

The default transport still honors `http.runWithClient`, which the
hosted journey tests use to fake the network
(`relay_http_query_client_test.dart`; fails without the fix).

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

Host benchmark (AOT executable on an Apple silicon Mac, median of 25,
copy of the parse logic, main-isolate stall measured with a 0.5 ms
heartbeat timer):

| Response | Main-isolate blocked before | after |
| --- | --- | --- |
| 500 events, ~630 KB | 2.4 ms | 0 ms (max gap 0.05 ms) |
| 500 events, ~630 KB gzip | 3.2 ms | 0 ms (max gap 0.19 ms) |
| 500 events with 50 20 KB avatars, ~1.6 MB | 5.1 ms | 0 ms (max gap
0.28 ms) |
| 500 events, ~4.5 MB | 14.0 ms | 0 ms (max gap 0.27 ms) |
| 500 events, ~4.5 MB (827 KB gzip) | 17.4 ms | 0 ms (max gap 0.35 ms) |

Total latency is unchanged within noise (for example 14.5 ms to 14.9
ms). The host is much faster than the 12 mini, so absolute device
savings should be several times larger.

On the iPhone 12 mini (Profile builds of base and this branch, real
community over the production relay, two runs per scenario; main-isolate
stalls measured with a 4 ms heartbeat timer, since the wireless device
tunnel has no VM service):

| Scenario | Main-isolate `_postForEvents` time, base | this branch |
Max stall, base | this branch |
| --- | --- | --- | --- | --- |
| Cold start + 60 s (~16k events, 7.8 MB gzip) | 282 to 295 ms (max 116
ms) | 0.01 ms | 147 to 150 ms | 100 to 102 ms |
| Inbox + search (~7k events) | 122 to 123 ms (max 111 ms) | 0.01 ms |
138 to 142 ms | 97 to 99 ms |
| Open 6 channels (558 events) | 5 to 6 ms | 0.01 ms | 88 ms | 86 to 87
ms |
| Background 10 s + resume (~7k events) | 118 to 124 ms | 0.01 ms | n/a
(suspension) | n/a |

- The production relay serves gzip (about 4.4:1; the largest catch-up
response is 3.15 MB on the wire, 13.9 MB inflated, 7k events). Inside
the isolate that response takes 30 to 47 ms to inflate, 39 to 55 ms for
UTF-8, 65 to 105 ms for `jsonDecode` and 7 to 32 ms for
`NostrEvent.fromJson`.
- Many 16 to 90 ms stalls remain on both builds; they come from other
work (mostly markdown build and layout), not response decoding.
- Home, channels, inbox, search, profile and a thread render the same on
both builds.

buzz-review-completed

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

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 08:44:52 -07:00
Tom Brow
a7e65c91da feat(mobile): choose remote agent models and gate agents on Builderlab sign-in (#8650)
Follow-up to [#8626](https://github.com/block/buzz/pull/8626). Mobile
remote-agent creation now matches desktop.

- **Model and reasoning effort:** Create agent has Model and Reasoning
effort menus. Their options come from the remote service's catalog, and
nothing is preselected (same as desktop). The server checks the
selection against the catalog again before registering, and saves it on
the agent.
- **Builderlab gate:** creating a remote agent requires a hosted
community and Builderlab sign-in. The Agents directory, community bots
and the Bestie chooser stay available without it. Signing out closes an
open Create agent sheet; the Agents sheet stays open.
- **Enrollment:** `attest-agent` enrolls the agent in the requested
community, as on desktop, so the separate `add-buzz-subscription` call
is gone.
- **Quieter progress:** spinners replace the progress bars and the
"Working…" label. Status and success text ("is ready", "Remote agent
deleted.", setup steps) is gone, and only failures show text. Refreshing
an already-loaded agent list no longer blanks it.
- **Shared setting menu:** `BuzzSettingMenu` is a bordered setting row
that opens a Material 3 menu spanning the row's width. Opening it
dismisses the keyboard. The channel-TTL setting in Create channel uses
it too.

Agent avatars and QR/link-based creation follow in stacked PRs. Editing
existing agents comes later.

| Agents | Create | Model menu |
| --- | --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/d0bee87c45d30b9bdd2163a75966ccd5d64f322a/pr-8650--1-agents.png"
width="240"> | <img
src="https://raw.githubusercontent.com/block/buzz/d0bee87c45d30b9bdd2163a75966ccd5d64f322a/pr-8650--2-create.png"
width="240"> | <img
src="https://raw.githubusercontent.com/block/buzz/d0bee87c45d30b9bdd2163a75966ccd5d64f322a/pr-8650--3-model-menu.png"
width="240"> |

### Validation

- Checked on the staging iOS simulator with a Builderlab account: the
Agents list, the Create sheet, and the model and effort menus (including
with the keyboard open).
- Added tests: catalog-gated model selection (including switching models
clears an unsupported effort), the Builderlab sign-in gate, controller
creation and resume with a model, a failed listing reported instead of
an empty registry, and create sheet busy and failure states (including
the catalog reload after a rejected create).
- The hosted-only agent tests are skipped without Builderlab defines,
which `mobile-test` doesn't pass. They pass locally with the staging
defines (20/20).
- Not covered: Android.

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

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: george <973be878edd6d4f79692a3827f20a6f7fe0ed0362f269ff6b6ba8867033d7d8e@sprout-oss.stage.blox.sqprod.co>
2026-10-29 08:43:45 -07:00
klopez4212
39b8b2b6a6 feat(mobile): unify Messages navigation with Solo and Team filters (#8649)
## Summary

Unify mobile conversations in Messages with All / Solo / Team filters, a
collapsing header, and separate Search, New, and default-agent
shortcuts. Move workspace actions into the community menu and keep
collapsed sections intact across filters.

Align Inbox and list typography, spacing, and muted controls. Preserve
native glass layering through navigation and give every filtered list
enough clearance above the footer. The default-agent shortcut reads the
shared encrypted preference and opens an existing chat immediately. When
unset, it opens Agents; a star action selects the default through a
production signed, self-encrypted preference writer. Save failures
remain visible and retryable.

Android now uses rounded navigation controls and filters, optically
balanced search icons, and expanding New/community menus with leading
Tabler icons matching iOS. Preset avatars retain transparent WebM
animation through an Android WebView renderer, with lifecycle pausing
and poster fallback.

### Related issue

None found in searches for mobile navigation issues and open PRs.

### Testing

- Android follow-up: ARM64 debug build, Dart analysis, and emulator
checks passed for menu placement, selection, icon alignment, search, and
avatar playback/pause/resume. The avatar implementation passed 3,840
Flutter tests and native Android tests before subsequent styling
iterations. Automated tests for the later menu/icon iterations were
skipped at the author’s request; those earlier results do not cover the
final styling changes.

- Mobile formatting/analysis and full test suite (3,838 passed, 23
skipped, plus 4 push-configuration tests); regression journey covers
filtering, collapse retention, and scrolling the last Solo row above the
footer.
- iPhone 18 Pro simulator (Honeycomb): filters, menus, Inbox,
Invite/Back, Switch Community, and default-agent chat/Back exercised.
iOS release build and Pairing Preview install/launch passed during
iteration, preserving the saved profile.
- P1 follow-up: production writer-to-reader widget journey covers
selection, publish failure/retry, and the unset footer opening Agents.
Simulator Agents open/close passes; its saved community has no
selectable agents, so live selection was not verified there.
- Independent agent review completed; its Solo footer-clearance finding
is fixed. The final clearance adjustment is covered by widget tests;
physical-device acceptance of that adjustment and hosted CI remain
pending.

Final Messages layout:

![Messages with All, Solo and Team filters and separate bottom
shortcuts](https://github.com/user-attachments/assets/562ddc3e-945b-4945-bd58-217a2c62b570)

Switcher layering during iteration (before → after):

| Before: filter edge showing through | After: Home hidden behind
switcher |
| --- | --- |
| ![Filter edge visible beside Switch
Community](https://github.com/user-attachments/assets/b32a0157-8b17-4f01-9a23-29b12ce3b1af)
| ![Clean Switch Community
screen](https://github.com/user-attachments/assets/222956e4-9989-4c8f-a162-65de45029845)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-29 14:27:22 +00:00
Luke Tornquist
974495fd4a fix(mobile): resume reconnect replay from the last live point and spread reconnects (#8659)
## Summary

A relay rolling deploy closes every socket with 1012. On reconnect,
mobile replayed each live subscription's **original filter** (startup
`since`, full `limit`) unless that subscription had received an event,
so quiet subscriptions re-read history back to app start every time.
Reconnects used a fixed 1s delay with no jitter. The delay also reset to
1s on every successful connect, so a client bounced from pod to pod
during a deploy kept coming back at the same moment as every other
client.

**This PR makes two changes:**

1. **Resume cursor.** If a subscription reached EOSE on a connection, it
records `liveUntil`: the time of the last frame received from the relay
on that connection, capped at the clock at close. The replay then uses
`since = max(original since, lastSeen − 5s, liveUntil − 960s)`, and
never goes past the filter's `until`.
- **Why 960s:** the relay accepts `created_at` up to ±900s from its
clock (`MAX_TIMESTAMP_DRIFT_SECS` in
`crates/buzz-relay/src/handlers/ingest.rs`). An event stored after the
close can therefore be backdated by that much, and the extra 60s covers
client clock skew.
- **Only relay frames count as liveness.** Close and error paths never
extend `liveUntil`, so a close noticed after the device sleeps doesn't
overstate how long the subscription was live.
- **Ambiguous EOSE is ignored.** If a subscription has a second REQ on
the same socket, that subscription doesn't advance `liveUntil` for the
rest of the socket. A CLOSED ends liveness for the rest of that socket,
because the retry is a second REQ. Subscription IDs are never reused, so
an old EOSE can't mark a re-subscribe as live.
- **No cursor:** a subscription that never reached EOSE keeps its
previous cursor, or replays as before.
2. **Reconnect spreading.**
- After a 1012 or 1001 close, the first attempt waits a uniform random
delay in [0, 10s]. A client already backing off waits the longer of that
delay and its backoff.
   - Ordinary backoff gets symmetric ±25% jitter, capped at 30s.
- The backoff resets only after a connection stays up for 60s, not on
connect. This matches desktop.
- Foreground resume and a manual `reconnect()` still reset to the base
delay, as before.

**Simpler?** The pure reconnect math (cursor, jitter, spread, stability)
lives in a new `relay_reconnect_policy.dart` and is table-tested on its
own. `relay_session.dart` was near its 1200-line cap, so its five small
private bookkeeping classes moved verbatim to
`relay_session_subscriptions.dart`. Net concept count is unchanged.

**Identified debt (not changed here):**
- Dart's WebSocket ping/pong isn't visible to the session, so a
long-idle but healthy connection that drops replays back to its last
application frame − 960s. This is still bounded, and much cheaper than
replaying from app start.
- The unread catch-up in `channels_provider.dart` runs one REQ per
active channel (`since = readAt + 1`, `limit: 1000`) on reconnect and
every 60s.
- A retryable CLOSED is retried with a 30s cap, no jitter and no attempt
limit.
- `lastSeenCreatedAt` advances on stored events received before EOSE. If
a relay returns stored events newest-first and the socket dies before
EOSE, the next replay can skip older stored events.
- The `lastSeen − 5s` term keeps its existing gap for events backdated
after a close.

### Related issue
N/A. This is part of a set of reconnect-load reductions. Companion PRs:
relay read-pool warm (#8651), desktop (#8652), agent harness (#8653).

### Testing
- Mobile gate on a Linux workstation, at the head SHA:
  - `dart format --set-exit-if-changed`: pass.
  - `flutter analyze`: no issues.
  - Gateway recipe tests: pass.
- `flutter test`: 3881 pass, 1 fail.
`push_gateway_origin_validator_test` fails the same way on `main` on
that host, due to a host git-config error.
- New `relay_reconnect_resume_test.dart`:
  - pure table tests for the cursor and the delay
  - a real-socket test that reads the 1012 close code
- journeys through `RelaySessionNotifier`. They check that a close or
error after an hour asleep doesn't extend liveness, that a second REQ or
a delayed EOSE for an unsubscribed ID can't advance the cursor, and that
a backdated event inside the 960s window is still replayed.
- 22 production mutations, each failing the new tests. They cover every
cursor term, the 960s window, the `until` clamp, the liveness cap, the
ambiguous-EOSE guard, CLOSED handling, jitter, the 1012 spread and
floor, the backoff reset, and close-code capture.
- `fake_relay.dart` now draws a fixed jitter midpoint, so existing
journey tests stay deterministic.

🤖 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 Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 17:35:12 -07:00
Tom Brow
bad9d26142 fix(mobile): push every message in your own Solo chats (#8615)
## Summary

Mobile push only covered Solo chats through explicit mentions. Solo
chats are private streams rather than DMs, and the push lease subscribed
channel-wide only to DMs. Agent replies in your own Solo chats therefore
never woke the phone.

The lease now subscribes channel-wide to the viewer's own joined,
unarchived Solo chats, using the same predicate as the Solo tab,
including older unlabelled Solo chats listed in the encrypted Solo
record. Self-authored messages stay excluded through the existing lease
ignores. Muted Solo chats stay out of the lease, like muted channels, so
they push only on mention. The lease waits while the Solo record loads
so an unresolved record never drops legacy Solo chats. If that record
fails with no cached copy, DM and mention updates proceed without the
legacy chats, which the Solo tab cannot list either, and they return
when it recovers. Solo chats created by someone else still push only on
mention, matching how they appear in the channel list.

A lease is bounded by its channel count (750), by the relay's advertised
plaintext and content sizes, and by NIP-44's 65535-byte plaintext
ceiling. DMs always stay, as before. Solo chats fill the remaining room
most recent first, sized with the publisher's own lease encoding, the
exact NIP-44 payload length and the largest endpoint the relay accepts.
Sizes are computed rather than built, so a relay's advertised limits
never drive an allocation. Solo chats beyond the room push only on
mention instead of failing the lease update or publication, and
Notifications settings says how many. A relay too small even for DMs and
mentions fails push setup visibly instead of saving a lease it would
reject. Publication accepts only endpoint grants in the gateway's
JSON-safe alphabet, so the sizing assumption holds.

Thread replies outside DMs still do not push unless they mention you.
That is a separate gap.

### Related issue

None found.

### Validation

- Added test: the desired lease includes joined DMs and the viewer's own
Solo chats, and includes legacy Solo chats from the Solo record, and
excludes archived and muted Solo chats, other people's Solo chats, and
unlabelled private streams. It fails without the fix.
- Added test: the sync provider holds the desired lease while the Solo
record loads, proceeds without legacy chats if the record fails, and
adds them once it arrives. It fails if loading is treated as empty or if
a failure blocks updates.
- Added tests: over the channel count, and over each size limit (relay
plaintext, relay content, NIP-44 plaintext), the lease keeps every DM
and exactly the newest Solo chats, reports the rest as omitted, and is
accepted by `publishBuzzDevPushLease` with a grant at the relay's
endpoint limit, while a max-length grant that JSON would escape is
rejected as not a gateway grant. A relay advertising a 2^50-byte
endpoint limit is sized without allocating. Each row fails if its guard
is removed.
- Added test: a relay whose content or plaintext limit cannot fit even
the lease without Solo chats rejects that lease at publication, and the
provider fails setup instead of saving it. It fails if the guard is
removed.
- Added test: the NIP-44 payload length matches real encryption at every
padding boundary up to 65535 bytes.
- Added test: Notifications settings shows "Older Solo chats notify only
on mention" with the omitted count. It fails without the row.
- Mobile lint and the full mobile test suite pass.
- Not exercised end to end on a device. A signed dev build was installed
on a test iPhone against a personal dev relay, but the phone's dev
identity owns no assistant there, so the device could not create a Solo
chat without exporting its key. This will be validated in dogfood. The
relay matcher already handles channel-wide `#h` leases for DMs, and Solo
messages use the same kind and scoping.

buzz-review-completed

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

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 16:44:59 -07:00
Tom Brow
4ff02bf03b perf(mobile): sign events with native libsecp256k1 on iOS and Android (#8654)
Mobile signs every Nostr event (sends, relay AUTH, media auth headers,
preference writes) in pure Dart on the UI isolate. On an iPhone SE (2nd
gen, Profile build) one BIP-340 signature costs about 10.7 ms and
deriving the public key another 5.6 ms, so a burst of signed writes
drops frames.

This adds `mobile/packages/buzz_secp256k1`, a small FFI package that
builds vendored bitcoin-core/libsecp256k1 v0.8.0 (ecdh, extrakeys,
schnorrsig modules) through a Dart build hook, so the same C and the
same Dart API ship on iOS and Android with no platform bridge code. All
13 `nostr.Event.from` signing sites now go through `signNostrEvent`,
which keeps the `nostr` package's event-id serialization and moves only
key derivation and signing to native code. Calls stay synchronous.

The package also exposes `schnorrVerify` and `ecdhX`; follow-up PRs move
verification and the NIP-44 conversation key over. Incoming relay events
are still not verified, unchanged by this PR.

Safety: the signing context is randomized once per isolate, secret keys
and keypairs are wiped from the native scratch buffer after each call,
aux randomness comes from `Random.secure()`, and the static verify
context runs libsecp256k1's self test before first use.

### Validation

- Added tests: BIP-340 official vectors and native-vs-pure-Dart
differential tests in the package; `event_signing_test.dart` signs with
64 random keys and checks pubkey, event id and signature against the
existing `nostr` builder and verifier, plus malformed-key rejection.
- Full mobile analyze and test suite pass locally.
- Builds: iOS release (framework ~1.3 MB) and Android debug APK with the
library for arm64-v8a, armeabi-v7a and x86_64 (~1.2–1.3 MB each).
- On-device (iPhone SE 2nd gen, Profile, median per op): sign 10,736 µs
→ 41 µs, derive pubkey 5,580 µs → 16.5 µs, full signed event 10,744 µs →
65 µs. Event-id JSON and SHA-256 are 23 µs, so they stay in Dart.
- Device, iOS (iPhone SE 2nd gen, Profile): `buzz_secp256k1.framework`
embedded; the signed-in app authenticated to a real community relay,
loaded channels and history, and paged older messages. No native-asset
or secp256k1 errors in the console.
- Device, Android (emulator, debug): APK contains `libbuzz_secp256k1.so`
and loads it. Against a local mock relay that verifies with the
pure-Dart `nostr` package, the app's NIP-42 AUTH, NIP-98 `POST /query`
headers, and kind 20001/30078 events all verified.
- Blossom GET auth, iOS: the dev relay returns 401 for unauthenticated
`/media/` GETs. From inside the running app, the production
`MediaGetAuthService.headersFor()` header for a nonexistent hash got 404
(accepted, file missing), compared with 401 without it. The community
has no relay-hosted image, so none was rendered.


buzz-review-completed (agent review, Codex and security review clean;
device validation above; human app test not applicable under the mobile
testing exception)

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

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: george <973be878edd6d4f79692a3827f20a6f7fe0ed0362f269ff6b6ba8867033d7d8e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 16:28:26 -07:00
Brad Seiler
d6196a16c1 fix(relay): revoke invitations and restrict claims (#8237) 2026-10-28 18:42:49 -04:00
Wes
5a8102ee5b fix(mobile): preserve invite onboarding and offer identity setup (#8617)
## Summary

Cold-start iOS invite links could open the welcome screen without
continuing sign-in, and accounts without a stored backup had no explicit
identity setup choice. Forward scene launch URLs to `app_links`, offer
creation for an unbound account with no backups, and offer matching-key
import for an existing binding without a backup. Existing key storage,
binding, backup, and admission checks remain in use.

Discover saved hosted destinations using `known-communities/list` and
remember successfully admitted destinations with
`known-communities/add`. Saved destinations are not membership proof;
normal admission and the existing failed-claim membership reconciliation
remain separate. A failed save preserves the intent and local community
for idempotent retry.

### Related issue

Related to #8599 (QR-first mobile onboarding) and #8601 (invite policy
consent; overlapping setup UI). No exact duplicate found in the open
onboarding PR / identity-backup issue search.

### Validation before the latest main integration

- 48 tests passed across five focused files: `hosted_journeys_test`,
`hosted_communities_test`, `hosted_communities_flow_test`,
`hosted_environment_flow_test`, and `hosted_multi_backup_recovery_test`.
Test defines enable hosted domains and the push gateway.
- Four additional pairing handoff tests passed with hosted domains
enabled after updating their shared HTTP fixture for community saving.
- Targeted Dart analysis: no issues. Required pre-commit and pre-push
hooks passed, including the broader mobile analysis/test lane and
file-size checks.
- Native iOS build passed during iteration. On an isolated iOS
simulator, a terminated app opened from an invite automatically entered
browser login; human recording confirmed login continued into identity
setup and new identity creation.
- Human staging testing on October 27 passed invite entry with an
existing email/password and new identity, community entry, messaging,
cold restart, and recovery on two separate clean iOS simulators. These
results apply to build `ff2138fc325790e0af4d9a64b2ed033fae3a8295`; the
backend backup-upload blocker described previously is no longer blocking
that tested flow.
- Fresh-account signup through an invite is deferred. Physical-camera QR
scanning, native regression/mutation coverage, independent review, and
native retesting after the latest main integration remain outstanding.
- Known limitation: failed-claim membership reconciliation still calls
`communities/joined`; its endpoint/response contract needs alignment
with the supported backend before that fallback is considered ready. The
successful primary recovery path does not prove this fallback.
- No account reset tooling, environment-specific credentials, generated
dependency files, or recording artifacts are included.


### Current review fixes and validation

Head `9902a98d42904a4a7d44a79541d81ceb94d8eac3` addresses the two review
blockers and both small import races:

- Hosted imports explain a linked-account key mismatch; the shared
self-hosted/occupied-device message remains unchanged. The
linked-account journey enters the wrong key through the actual sheet,
checks the visible error and empty storage, then imports the correct
key.
- Closing the import sheet while its operation is pending now waits for
that operation and resumes successful setup. The new-account journey
covers creation and import, including dismissal while awaiting the
status response, binding, backup receipt, and retry.
- An unbound account with newly appeared backups refuses direct import,
preserving recovery's receipt-before-adoption rule. The existing
multiple-backup invariant covers matching and different imported keys.
- The configured route journey uses host labels and its existing fake
account now supports destination saving; the real-account journeys
separately exercise persistence.

53 tests passed across six focused files with CI's
`BUZZ_AUTH_DOMAINS=buzz.xyz,builderlab.xyz,builderlabstaging.xyz` and
push-gateway define. In a separate clone with independent Git
metadata/build output, restoring the old production import paths failed
the three intended regressions; restoring the fixes passed all 43 tests
in that control. Required pre-commit and pre-push hooks passed,
including mobile analysis/tests and file-size checks. Hosted CI and
native retesting of this head are still pending.

The earlier independent review was at `10815e5d9`; these fixes still
need reviewer confirmation. Optional accessibility and import-navigation
refinements are deferred. Fresh signup, authenticated production
add/list and the membership fallback remain open as described above.

### Staging testing and production configuration

Staging selection is build configuration, not a changed production
default. `HostedEnvironment` is unchanged by this PR:
`BUZZ_BUILDERLAB_URL` defaults to `https://app.builderlab.xyz`;
`BUZZ_AUTH_DOMAINS` is empty by default (hosted account features
disabled). Builds that enable production hosted domains receive these
onboarding behavior fixes after merge. This PR does not deploy anything
or redirect production builds to staging.

Check out this PR (`gh pr checkout 8617` in a dedicated `block/buzz`
checkout/worktree), install dependencies per `mobile/README.md`, and
boot a clean iOS simulator. With the repository-pinned Flutter
toolchain:

```sh
. ./bin/activate-hermit
./scripts/mobile-worktree-overrides.sh
cd mobile
flutter run -d <clean-simulator-udid> \
  --dart-define=BUZZ_BUILDERLAB_URL=https://app.builderlabstaging.xyz \
  --dart-define=BUZZ_AUTH_DOMAINS=communities.public.builderlabstaging.xyz \
  --dart-define=BUZZ_DEBUG_PRIVATE_LOGIN=true
```

Private login requests an ephemeral browser session only in debug
builds. Hosted relay assertions remain off by default; this test does
not validate assertion-enabled admission. Generated local Xcode
settings, dependency metadata, credentials, and simulator state are not
included in the PR.

- Invite test: open a valid public-staging invite in the simulator
browser, choose **Accept invite in Buzz**, sign in or sign up, complete
identity setup, enter the community, and send a message.
- Recovery test: preserve that simulator, start another clean simulator
with the same build, choose **or sign in with email**, and sign into the
same account. Verify the recovered public key matches, the saved
community and earlier message return, and a new message sends.
- Fresh signup: repeat invite entry using a new account. This still
needs human verification.

Keep this PR draft while the listed validation gaps remain.

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
2026-10-27 16:31:31 -06:00
Tom Brow
d4a07063c8 feat(mobile): manage Builderlab remote agents (#8626)
Adds Builderlab remote agents to mobile: a member signed in to
Builderlab can list, create and delete their own hosted agents from the
channels header, and active remote agents show as Online.

Everything is gated by `HostedEnvironment.enabled` (the build-time
`BUZZ_AUTH_DOMAINS` define). App Store and internal release builds do
not set it, so they render and fetch nothing new.

### Behavior

- **Agents** button in the channels header (native iOS bar and Material
header) for hosted communities only. It opens a sheet listing the
community's agents; tapping one pushes its profile inside the same
sheet.
- **Create**: for the member's own agents (the NIP-OA owner is the
member, not the community). Registers the agent with Beekeeper, attests
it to the community, saves optional private instructions, subscribes it
to the relay, then publishes the kind 30177 registration with a NIP-OA
owner proof. A failed step resumes with the same agent identity on
retry; instructions stay in secure storage, never in preferences.
- **Delete**: removes the relay registration, confirms it is gone, then
deletes the Beekeeper agent. A partial failure keeps a retry row in the
list.
- **Presence**: an ACTIVE agent with a live kind 30177 registration on
the active relay shows Online in profiles, channel rows, DMs and
mentions, since remote agents wake on messages. Local presence data is
unchanged.
- Sign-in reuses the shared hosted flow. The account's saved Buzz key
must match the server binding; nothing is adopted or rewritten.
- `BuilderlabAccountClient` gains an allowlist for the six Beekeeper
routes desktop already uses.

Not included from the prototype: the separate entrypoint and second
sign-in, test-community creation, staging-only hosts, QR-code agent
links, and the USB proxy.

### Validation

- Added tests: build-time gate, account/key binding before listing,
setup resume with private instructions, deletion contract and retry,
owner-proof verification, status parsing, presented presence requiring a
live registration in the active community and following registration
publish and removal (mutation-checked), account-change clearing, load
retry, recovery of a pending setup whose instructions were lost, a lost
registration response that can only resume with the same idempotency key
(from any community), an HTTP-rejected registration that can be
discarded, deletion of other agents while a setup is unfinished,
rejection of owner proofs whose signed conditions or profile signature
fail, registration publish and removal over a loopback WebSocket relay
for an `http://`-saved community, and widget tests for in-sheet detail
navigation, creation overlay with failure then resume, and delete
confirmation.
- The new tests also run in the CI lane with `BUZZ_AUTH_DOMAINS`
configured, so the enabled path is covered.
- Full mobile format, analyze and test suite pass locally.
- Independent agent review: no gate leak or key-handling issue found;
its robustness findings (discarding a setup that never got a server
identity, clearing registry state on account change, retrying a skipped
load, guarded notifications) are fixed; discard and account change have
added tests.
- Simulator: a `BUZZ_AUTH_DOMAINS` debug build launches to Builderlab
sign-in. The signed-in flow was not re-run on this branch.
- Codex Security Review: an earlier head received a published review,
and its two findings were fixed. On 745ad85 and be2e31b the review hit
its 45-minute timeout and was cancelled without a result, so the final
head has no published security review.
- Human app test: not applicable under the mobile testing exception. The
end-to-end create, mention and delete flow was demonstrated on the
prototype branch against Builderlab; this PR keeps the same Beekeeper
and relay contracts.

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

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: george <973be878edd6d4f79692a3827f20a6f7fe0ed0362f269ff6b6ba8867033d7d8e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:05:44 -07:00
Tom Brow
f167c4c3e1 fix(push): accept canonical channel UUIDs across versions (#8609)
Starter channels use UUIDv5 IDs, but mobile push accepted only UUIDv4
channel IDs, so building a subscription that included a starter channel
threw locally. After #8608, no non-v4 ID reaches mobile push today:
positive `#h` filters cover joined DMs only, and mutes are limited to
subscribed channels. This PR is therefore a safety net for future
starter-channel subscriptions, and it prepares mobile for a relay that
accepts them.

Mobile now accepts canonical lowercase, hyphenated UUIDs of any version
in subscriptions. It recognizes both the current `uuid-v4-lowercase`
relay descriptor and a future `uuid-lowercase` descriptor. Against a
`uuid-v4-lowercase` relay, mobile checks positive and mute channel
filters before enrollment or publication, keeps the desired filters, and
rechecks relay capabilities every five minutes instead of sending a
lease the relay would reject.

The relay is unchanged. It keeps validating and advertising
`uuid-v4-lowercase`. Installed mobile builds reject any other
`h_grammar`, and they parse the descriptor before renewal, opt-out and
revocation, so changing the advertisement now would leave their leases
active until expiry. The relay validation and advertisement change will
follow in a separate PR once those builds have drained.
`docs/buzz-push.md` records this sequencing.

### Validation
- Regression tests reproduced the UUIDv5 rejection in Dart before the
fix. Added coverage checks positive and mute filters, persisted
subscription round trips, encrypted lease content, malformed IDs,
retained bounds, parsing of both descriptor grammars, and
pre-publication rejection of non-v4 IDs against a `uuid-v4-lowercase`
relay.
- Mobile push tests on the current head: 104 passed, 4 skipped. `flutter
analyze` on the push code and tests is clean.
- Before the split, exercised actual mobile lease construction, NIP44
encryption and NIP42 publication against an isolated local relay. The
relay half of that exercise is no longer part of this PR.
- Relay source is identical to `main`, so the relay serves the same
descriptor that installed builds parse today.

The standing mobile test exception applies. Agent review and
changed-behavior exercise are complete.


buzz-review-completed

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: george <973be878edd6d4f79692a3827f20a6f7fe0ed0362f269ff6b6ba8867033d7d8e@sprout-oss.stage.blox.sqprod.co>
2026-10-27 11:12:33 -07:00
Tom Brow
b77ad2ed10 fix(mobile): align Solo agent policy and labels with the owner's inventory (#8616)
## Summary

`main` now discovers Solo agents from the owner's kind 30177
managed-agent inventory. This PR aligns two remaining parts of that path
with desktop's agent library.

- **Unsupported policies fail closed.** Desktop parses each agent's
latest 30177 record as `ManagedAgentEventContent` and drops records it
can't parse. Mobile only checked `name`. It now also requires an integer
`parallelism` in `u32` range, a `respond_to` of `owner-only`,
`allowlist` or `anyone`, an allowlist that is absent or a list of
strings, and string-or-null optional text fields. A newer unsupported
record hides the agent instead of falling back to an older one. Records
written by the apps always carry these fields.
- **The owner's name labels the agent in Solo's selection, header and
creation surfaces.** The directory already used the 30177 `name`.
Message authors and the typing indicator still use the agent's profile
name. The existing-chat header, pending-change label, fallback avatar
initials (header, native header action, new-chat row) and the resumed
native creation's saved agent preferred the agent's self-published
kind:0 name. They now use the owner's name first and fall back to the
profile. Profile artwork is unchanged.

### Validation

- Added tests: a table of 11 unsupported policy shapes that must hide
the agent, accepted policy shapes, and journeys where the owner's name
("Owner Name", "Alpha") differs from the cached profile name
("Confusable …"). The journeys cover the new-chat row's title and
initial and the existing-chat header tooltip, initial and pending label.
They also assert the native new-chat payload and picker row, which
already used the inventory name.
- Mutation checks: removing the policy check fails 10 tests; preferring
the profile for the header name, header initial or new-chat initial
fails the matching journey. The saved-agent name on resumed native
creation has no dedicated test.
- The full mobile suite passes (3,806 tests), and `flutter analyze` is
clean.
- The human-testing requirement does not apply under the mobile client
testing exception.

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

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:05:46 -07:00
klopez4212
b14884980e Speed up mobile Solo chat creation and agent discovery (#8547)
## Summary

Solo compose now opens before agent discovery finishes, and chat
creation avoids full channel-list reloads while overlapping independent
reads. Durable retries, ownership checks, and relay confirmation remain
intact.

The agent picker uses the owner's saved inventory, excludes known
archived identities, verifies profiles off the UI thread, and reuses
avatar artwork.

### Related issue

No matching issue found. Related: #8546 adds project selection to the
same compose surface.

### Testing

- Full mobile suite: 3,755 passed, 19 skipped; four additional artifact
tests passed.
- Analysis, formatting, file-size checks, and iOS Release build passed.
- Regression tests fail when the old blocking paths are restored.
- Installed on a physical iPhone with existing profile/community
settings preserved. Owner tested the earlier creation/picker
improvements; final compose timing awaits device feedback. No timing
recording captured.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-27 15:09:22 +00:00
Tom Brow
5996e5dd8b fix(mobile): isolate push setup failures from app rendering (#8610)
Push subscription setup ran synchronously inside the root widget build,
so an invalid subscription could replace the entire app with Flutter's
error surface. Push initialization and the cold-start native
notification read could also prevent the app from rendering.

Keep push work independent of the app subtree, observe asynchronous
persistence failures, and retain the original errors and stack traces.
Setup retries use bounded backoff; Settings shows a failure and offers
manual retry without resetting navigation. Retry preserves a healthy
revocation worker, and community-removal callbacks follow a recovered
worker so lease cleanup continues after setup recovery.

Related: [#8608](https://github.com/block/buzz/pull/8608) fixes the
specific unrelated-mute input that exposed this startup coupling.

### Validation

Added Flutter journeys cover invalid subscriptions, asynchronous storage
failure, dependency initialization failure, retry exhaustion and manual
recovery, navigation preservation, accessible retry actions, and
pending/failed native startup reads with notification-target recovery.
Both original rendering regressions failed before the fix.
Community-removal coverage also verifies cleanup after a worker
initialization failure and after retrying healthy setup; that regression
failed before the worker fix.

The full mobile suite passed (3,782 tests; 19 existing skips), as did
the four unconfigured-build tests and static analysis. The corrected
head also passed the full pre-push mobile checks and tests, plus 42
focused push and community tests. Rendered widget evidence uses mocked
native platform channels; no device APNs delivery test was performed.

Rendered Flutter widget evidence with synthetic data and mocked native
platform views.

### Setup failure
The app remains usable and Settings offers a retry after push
initialization fails.


![notification-setup-failed](https://raw.githubusercontent.com/block/buzz/af12b52923a75c6ad9fda774b9f4b3af37961422/pr-8610--notification-setup-failed.png)

### Recovery
Retry clears the error without replacing the navigator or leaving
Settings.


![notification-setup-recovered](https://raw.githubusercontent.com/block/buzz/af12b52923a75c6ad9fda774b9f4b3af37961422/pr-8610--notification-setup-recovered.png)

<!-- buzz-review-completed -->

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-26 19:32:38 -07:00
Wes
8ceca8ac46 perf(mobile): batch initial channel read-state seeds (#8607)
## Summary

Opening an uncached community seeded each previously unseen channel's
read marker separately. Every seed copied and serialized the full
read-state snapshot, started three preference writes, and notified
listeners. Home now collects those seeds and saves/emits once, using the
existing monotonic marker admission rules.

For a 400-channel fixture with one existing marker, initial seeding
falls from 399 full snapshot saves to one. Existing unread frontiers,
local-only seeds and explicit read publishing keep their behavior. No
transport, cache, relay or unread eligibility changes. This is
independent of #8580 and #8490.

## Validation

- `just mobile-install mobile-check mobile-test`: clean analysis; 3,779
tests passed, 19 skipped, plus four checks for builds without push
configuration.
- Real Home/channel-provider/read-state journey covers 400 channels,
preservation of an existing unread highlight, persisted marks, no seed
publication, and no repeated seed save on refresh. It replaces the
earlier fake-notifier seed test.
- Restoring the old per-channel loop makes the new journey fail at the
single-snapshot assertion.
- Diff and file-size checks pass.
- Merged current main (`209fcd217`) at `d34d29627`; retained both the
cold read-seeding and cached-community landing test registrations. The
production diff is byte-for-byte unchanged from the version reviewed by
the Court at `6a232ed37`. The complete mobile gates above passed again
on the merged tree.

Device latency is unmeasured. The Court source review found no blockers
at `6a232ed37`; app/device testing and human acceptance remain
unconfirmed. To try: open a community without a saved channel list,
confirm its channels load and existing unread highlights remain correct,
then refresh and reopen it. The performance claim here is reduced save
work, not a measured end-to-end speedup.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Star Lord <b89298dbe87c6b3fd8a425b535d9d161f23c88d555a47925ae0b04dfe02b201e@buzz.block.builderlab.xyz>
2026-10-26 13:36:23 -06:00
morgmart
209fcd2172 fix(mobile): ignore unrelated mutes when building push subscriptions (#8608)
## Summary
Muting a starter channel (#welcome-everyone or #general) could leave the
iOS app on a blank screen after launch.

Starter channels use UUIDv5 IDs. Push subscription setup checked every
synced mute against the lease's UUIDv4 channel grammar and threw
`FormatException: Push subscription channel ID is invalid.` from
`pushSubscriptionSyncProvider` during build, so the first frame never
rendered. Whether you saw a blank screen depended on whether channels
and mutes finished loading before the first frame.

Mutes only feed the lease as ignore filters for subscribed (DM)
channels, and mutes sync across every community and channel type. So the
builder now matches mutes against the subscribed channels before using
them, instead of validating every mute. Subscribed channel IDs are still
validated as before.

### Related issue
None found.

### Testing
- Captured the failure on a dogfood iPhone build from the device system
log, with a stack trace through `buildDesiredBuzzPushSubscriptions` and
the muted ID `fee88e92-da9f-5958-a910-48db3264ad38` (#welcome-everyone).
Unmuting that channel from desktop fixed the device immediately, which
confirms the cause.
- New regression test `ignores mutes outside the push channel grammar`
fails on `main` with the same exception and passes with this change.
- The pre-push `mobile-checks` hook (`just mobile-check && just
mobile-test`) passed on this head.
- Screenshots: not applicable. This changes no UI. It only stops a
startup failure.

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

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Carl <c217fe6b9d958f41c3a5e030dccc7f626775a923089cb6491305eade75ea1f1b@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:59:50 -07:00
Wes
ab73cd9fb5 perf(mobile): restore cached channels before community sync (#8580)
## Summary

Returning to a previously visited community shows its saved channel list
immediately, including offline, while membership and unread state
refresh in the background. Initial revalidation failures keep the list
visible and retry every 60 seconds without overlapping background
attempts. After successful history revalidation, ordinary reconnects use
metadata-only refreshes. A reconnect arriving during a background
request queues one follow-up attempt; timer ticks never queue
overlapping work. History deadlines retain the full-revalidation retry
until history succeeds.

Snapshots are disposable, versioned and scoped by canonical relay URL
and signing identity, capped at 2 MB / 2,000 member channels. They
include hidden-DM IDs and prefer fresher in-memory state. Community
removal and identity replacement retire outstanding writes; re-adding
unchanged credentials preserves the current cache and writer. Startup
sweeps orphan snapshots without waiting for disk acknowledgement;
community mutations also sweep orphan snapshots, including data restored
without secure community records. Cache-delete failures do not block
sign-out or strand push revocation; deletion is attempted before
journaling and retried by orphan cleanup. Successfully persisted
unchanged snapshots skip disk writes; failed writes remain retryable.

The cold path reuses fetched membership rosters, overlaps
profile/latest-message requests, shares verified DM profiles with
avatars, and fetches the own profile once per authenticated connection.
Community connection work overlaps the switching animation; avatar
hydration no longer gates landing. Full channel refreshes also
revalidate cached DM participant profiles so relay-side name changes
reach labels and avatars; metadata-only reconnects and background
refreshes reuse admitted profiles.

Me-chat and invite starter recovery use an explicit fresh membership
fetch that propagates errors while leaving the cached list visible.
Cached layout/read preferences permit immediate landing; newer
cross-device preferences may update ordering or badges after reveal.

Integrated with main’s #8490 idle/resume changes: hidden chats detach
live subscriptions, background membership polling stays quiet, and each
resume reconciles once. Chat history follows main’s live-admission-first
ordering to avoid missing messages committed during relay admission;
cached community landing remains immediate.

## Validation

- `just mobile-install mobile-check mobile-test`: clean analysis; 3,778
tests passed, 19 skipped; four checks for builds without push
configuration passed. File-size and diff checks passed.
- Provider/storage journeys cover offline startup, failed initial
revalidation recovering live delivery, bounded retries, lightweight
reconnects, scope isolation, default-port URLs, padded/mismatched stored
credentials, credential replacement, backup-restored orphan data,
removal races, failed platform deletes and failed writes.
- Follow-up regressions cover interrupted native Me-chat lookup, invite
failure propagation, retry after an initial list failure, history
deadlines, queued reconnects, escaped-path keys, unchanged credential
re-adds and non-blocking startup cleanup. Reverting each of the eight
behavior/caller/cleanup-order fixes fails its corresponding regression.
- DM-name regression changes only the relay profile, verifies a full
refresh updates the channel label and shared profile cache, and confirms
ordinary reconnects avoid another profile request. The original
cache-only implementation fails this regression.
- Original signed-in iPhone 18 Pro debug simulator measurements: Buzz's
413 channels went from 6.26–6.30 s to approximately 1.5 s on returning;
all three warmed communities switched in 1.47–1.54 s. Real socket pause
restored the cached list offline and retained it on reconnect. These are
debug simulator observations, not release-device benchmarks.
- Integration with current main also passes the real-session
resume/background/AUTH and live-admission/detach journeys.
- Follow-up failure paths were exercised with deterministic
provider/storage tests and widget journeys; the running simulator was
not modified during this follow-up.

Human acceptance remains pending; this PR stays draft. Try switching
among previously visited communities, opening a channel, restarting and
repeating. First visits and oversized snapshots require network loading.
Cached metadata can be stale until refresh; it lives in ordinary
preferences without additional application-level encryption. The
snapshot contains no message bodies or credentials. Persistent
disk-deletion failures can leave metadata until a later cleanup
succeeds.

---------

Signed-off-by: Star Lord <b89298dbe87c6b3fd8a425b535d9d161f23c88d555a47925ae0b04dfe02b201e@buzz.block.builderlab.xyz>
Co-authored-by: Star Lord <b89298dbe87c6b3fd8a425b535d9d161f23c88d555a47925ae0b04dfe02b201e@buzz.block.builderlab.xyz>
Co-authored-by: Rocket <0c41329ade659e01801116549bc617351689892a382bc864a1186dc9191293a7@buzz.block.builderlab.xyz>
Co-authored-by: Groot <f4c08302591916fb4b80926bf7e18d22eef8aa0c877e4e841acc46405a45700b@buzz.block.builderlab.xyz>
Co-authored-by: Mantis <530af7635ef7d0e1a5189d234338db88cb4ef1ede7119b4ebb4fb05c3eafe3b1@buzz.block.builderlab.xyz>
2026-10-26 11:57:07 -06:00
Wes
d090ac24bb perf(mobile): cut idle and resume sync churn (#8490)
## Summary

Mobile does background work nobody sees, and it waits on round trips it
doesn't need. This PR fixes four things:

- **Chats you've left let go.** A chat with no listener for 30 s drops
its live and typing subscriptions. Coming back shows the cached timeline
right away, then resubscribes and catches up.
- **No requests before AUTH.** The relay rejects any REQ that arrives
before NIP-42 AUTH completes. History requests are now held until the
socket authenticates, and they flush after the rate-limit gate. Live
subscriptions skip their REQ, and the replay on connect sends it.
`auth-required` is terminal for a live subscription, so a pre-AUTH live
REQ could be dropped for good.
- **Resume reconciles the channel list once.** `resumed` is published
before the session hears about it. So the channel list now decides one
microtask later: if the session is reconnecting, the connect does the
reconcile.
- **The 60 s backstop only runs in the foreground on a connected
session.** After an OS suspension, the missed tick fires at unfreeze,
about 450 ms before the app hears `resumed`, while the session still
looks connected. Without this, that tick triggered a second full sync.

Scope note: while the app is in the foreground, the backstop still
re-downloads membership every 60 s. That matches desktop, which
refetches every 60 s while focused. To drop it, mobile would need to
subscribe to the live membership notices (44100/44101), as desktop does.
That would be a follow-up.

### Related issue

N/A. This is follow-up work from the mobile perf dive. None found.

## Before and after

Simulator setup: iPhone 18 Pro simulator, debug build, a local relay
behind a proxy that adds ~120 ms RTT, and a seeded account with 120
channels. I scripted one identical run against each build:
1. Open 5 channels.
2. Visit 20 more channels.
3. Wait 40 s.
4. Stay idle in the foreground for 3 min.
5. Go to the background for 3 min.
6. Resume.

`main` is `ca3be5501`; the branch is `d77c4e86f`. The last amend changed
only test names. Since then, chat history waits for the live EOSE again
(see the note below), so channel open is back to `main`'s order.

| | `main` | this PR |
|---|---|---|
| Live subscriptions after visiting 20 chats | 61 | 11 |
| Resume: membership reads | 2 (244 kB) | 1 (122 kB) |
| Resume: websocket REQs | 36 | 31 |
| Resume: REQs sent before AUTH | 9 | 0 |
| Resume: REQs rejected as `auth-required` | 2 | 0 |
| Idle foreground, 3 min | 3 membership reads, 12 REQs | same |

Notes:
- **Channel open:** an earlier revision requested history without
waiting for EOSE, which cut the median open from 297 ms to 197 ms.
Review showed it can lose a message. The relay registers the live REQ
only after its access checks, so a message committed in between reached
neither the window read nor the subscription. A timestamp overlap only
covers messages signed within it. Keeping that speedup safely would mean
a second window read on every open, so it's left out.
- **Idle foreground is unchanged by design.** See the scope note above.
- **Background traffic:** the simulator suspends the app shortly after
it leaves the foreground, so both builds sent almost nothing during the
3 minutes in the background. The backstop fix shows up at unfreeze
instead, as the missing second membership read.
- **Resume inbound events:** in this one run, events received over the
websocket on resume were 247 kB on `main` and 338 kB on the branch. I
haven't attributed the difference.

## Test

These extend the existing journeys, plus one new journey:

- **`channel_messages_journey_test`:**
- The window read waits for the live REQ's admission, and a message
signed a minute earlier but committed before admission shows up.
- After the listener leaves, the chat keeps following for 29 s and stops
at 31 s.
- Coming back shows the cached rows immediately, then catches up on a
message sent while away.
- **`relay_session_test`:** a stale-connected resume sends nothing
before AUTH. Then exactly one history REQ and one live REQ go out after
AUTH.
- **New `channels_sync_journey_test`:** drives the real lifecycle,
session and channel notifier against `FakeRelay`:
  - A backgrounding just before a backstop tick sends nothing.
  - A resume after the session paused syncs once.
- An OS-suspension resume with AUTH held syncs once, after AUTH, and
sends no REQ before it.
- **`FakeRelay`:** gained `holdReq`, `holdAuth`, `before_id` pagination
and an injectable clock.

Each guard was mutation-checked, and every mutation failed its test:
- live REQ sent before AUTH
- history sent before AUTH
- resume refresh not deferred
- backstop without the lifecycle check
- history read alongside the live REQ
- a 30-day detach delay

Follow-up, not in this PR: a history REQ already in flight on a socket
that gets replaced still waits for its 8 s timeout. Princess Donut found
this in review; it predates this change.

## Validation

- `flutter analyze`: clean
- Full mobile suite at `678617b`: 3,696 passed
- Court review (Princess Donut on the relay session, Mongo on chat
subscriptions, Mordecai on the channel list plus a challenge pass): no
blocking findings. The rate-limit flush ordering came from that review.

**Human test:** Wes ran `da5077d` on the simulator with his real account
and confirmed it works. `460dc35` then fixed a review blocker: a chat
rebuilt while detached stayed detached. A retest of that path is
pending: leave a chat for more than 30 s, background the app for more
than 5 s, resume, then reopen the chat.

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

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:24:43 -06:00
klopez4212
dc25198d53 Add project selection to new Solo chats on mobile (#8546)
New Solo chats can optionally choose a project in the Flutter form and
native iOS sheet. Project appears above the agent picker with matching
neutral text, and the choice survives retries and restarts.

Validation: full Flutter suite passed; analysis and iOS builds passed.
Installed in Pairing Preview on iPhone Air and visually approved,
preserving saved preferences. Successful live chat creation remains
unverified because no agents were available.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-22 15:33:49 -07:00
klopez4212
f7708d0694 Polish mobile onboarding and profile setup (#8438)
Originally stacked on #8375, now merged. Rebased onto main.

Polishes the onboarding landing page, community-link entry, and recovery
sheets. Profile setup uses a consistent sheet with a larger avatar,
inline display name, and draft photo/emoji/animated editing; iOS uses
native controls and coordinated avatar/sheet transitions.

Validation: mobile analysis, full tests, and normal pre-push checks
passed after rebase. Prior iOS build and device walkthrough passed;
native validation was not repeated after this rebase. Snapshots omitted
as requested.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
2026-10-22 17:27:38 +00:00
Mohammad Asfour
1a54eec3db fix(mobile): keep token labels visible in authored Markdown links (#7545)
## Summary

On iOS, an authored Markdown link renders as blank when its label is
recognized as a Buzz channel, mention, or custom emoji. For example:

```markdown
[#2959](https://example.com/)
```

The surrounding text remains visible, but the link has no visible label
or tap target.

Buzz's `_ChannelLinkMd`, `_MentionMd`, and `CustomEmojiMd` components
return `WidgetSpan`s. `gpt_markdown` also renders an authored link
through a `WidgetSpan` and reparses its label through the registered
components. Because the Buzz components were allowed to run inside link
labels, the result was a placeholder nested inside another placeholder,
which does not paint on iOS.

This PR adds the same scope restriction used by `gpt_markdown`'s
built-in link component:

```dart
@override
Set<MarkdownScope> get scopes =>
    MarkdownComponent.allScopesExceptLinkLabel;
```

The override is applied to all three widget-based components. Authored
labels such as `[#2959](url)`, `[@Alice](url)`, and `[👋](url)`
therefore remain ordinary link text, while the same tokens outside links
continue rendering as Buzz pills and custom emoji.

The PR also updates the shared test helpers to find `RichText`
subclasses. Since the upgrade to `gpt_markdown` 1.2.1, body paragraphs
can render through `BidiRichText`; `find.byType(RichText)` does not
match that subclass and could allow an absence assertion to pass without
inspecting the message text.

The handling of unknown bare `#tokens` is deliberately unchanged.
Whether those should remain non-interactive pills or become plain text
is a separate product decision.

### Related issue

Fixes #6124.

This supersedes #6135. That PR solves the same issue by maintaining a
custom fork of `gpt_markdown`'s link component; this PR instead uses the
scoped-component API already available in `gpt_markdown` 1.2.1.

### Testing

Focused suites:

```bash
flutter test \
  test/features/channels/message_content_test.dart \
  test/features/channels/message_content_custom_emoji_test.dart
```

Result: 95 tests passed, 0 failed.

Additional checks:

- `just mobile-check` — passed; analyzer reported no issues.
- `just mobile-fmt` — passed; 549 files formatted with 0 changes.
- Six new regression tests cover channel, mention, emoji, plain-link
control, and outside-link behavior.
- Removing the `_ChannelLinkMd` override causes two regression tests to
fail; removing `_MentionMd` or `CustomEmojiMd` causes one test to fail
in each case.
- Three tests in `message_content_test.dart` directly assert that the
nested-placeholder structure is absent. The custom-emoji tests
separately verify literal text and the absence or presence of
`CustomEmojiImage`, depending on whether the shortcode is inside or
outside a link.

The complete local mobile suite reports 15 failures that reproduce
identically on a clean checkout of `main` at `44316ff7`. In [CI for that
same commit](https://github.com/block/buzz/actions/runs/34238736818),
`Clients / Mobile`, `Mobile`, and `Mobile Swift Domain / Mobile Swift`
all passed.

No native-device screenshot is included. The nested-placeholder fix was
verified through the rendered widget structure, but an iOS simulator
build was blocked by the existing `MLImage.framework` arm64-simulator
incompatibility.

Signed-off-by: Mohammad Asfour <masfour700@gmail.com>
Signed-off-by: Samin Yeasar Sohag <sssohag172@gmail.com>
Co-authored-by: Samin Yeasar Sohag <sssohag172@gmail.com>
2026-10-21 10:24:32 -07:00
Tom Brow
a93f3282cc perf(mobile): retain preference crypto across reconnects (#8442)
Foreground reconnects rebuild preference managers and repeatedly derive
the same NIP-44 keys on the UI isolate. Retain the six crypto objects in
providers scoped to relay configuration and identity while preserving
manager refresh, subscription, and publication behavior. Community
changes, key rotation, and sign-out invalidate the retained objects.

RelayConfig normalizes the signing key once at its shared accessor, so
identity, theme encryption, event signing, and NIP-42 authentication
receive the same value, including when an active configuration key has
surrounding whitespace. The identity-export authorization snapshot uses
the same normalized key for the active-identity check and the outgoing
recovery payload. The branch incorporates main's relay-scoped preference
managers.

### Validation
- Regression exercises six production crypto providers across
reconnects, configuration/key changes, and sign-out. Reintroducing a
session dependency makes it fail.
- Added a loopback WebSocket regression that exercises actual
RelaySocket NIP-42 authentication with a padded key and verifies its
challenge, relay, pubkey and signature. It fails before shared key
normalization.
- Added a connected-theme regression for whitespace-padded keys,
encryption round-trip, and a valid published event signature. It fails
before the normalization fix.
- Added padded-key identity export coverage through authorization, SAS
confirmation, exactly one normalized payload, and successful completion.
Separate relay-change and key-rotation tests verify both fresh and
expired grants fail closed. The padded-key regression failed before the
fix.
- Current head: all 48 focused pairing/crypto/auth tests and mobile
analysis passed. All [CI
checks](https://github.com/block/buzz/actions/runs/39497098787) passed,
including mobile test shards, Android, native Swift tests and release
builds. Codex feedback is addressed; Jude approved this exact head after
independent regression and mutation checks. Fresh [Codex security
review](https://github.com/block/buzz/pull/8442#issuecomment-6300273942)
found no concrete issues.
- Previous head: 122 config/socket/crypto/theme tests, 44 read-state
tests, and all [GitHub CI
checks](https://github.com/block/buzz/actions/runs/39491550425) passed,
including mobile test shards, Android, native Swift tests and release
builds.
- Original implementation: native iOS Debug build and full local
repository checks passed. A paired simulator background/resume capture
had no samples in the targeted crypto constructors or key derivation; an
82 ms UI frame remained. Debug/JIT, profiler overhead and shared-host
activity prevent an overall speedup claim. The later normalization
change is covered by the production-provider regression above.
- Two image decode timing failures occurred in the initial local full
suite; direct baseline/patch runs, the subsequent full repository run,
and the latest full mobile suite passed.

Cold-start credential validation is unchanged: padded persisted keys are
rejected by AuthNotifier before RelayConfig is constructed. The
normalization regressions cover active configurations and identity
export, not broader persisted-key acceptance.

<!-- buzz-review-completed -->

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-20 14:57:53 -07:00
klopez4212
5fefec604f fix(mobile): restore channel icons and align chat colors with desktop (#8478)
## Summary
- Restores `#` and lock icons for public and private channels on Home,
with matching loading placeholders.
- Updates the dark-mode navigation colors to the desktop design-system
tokens (read and unread text, section headings).
- Replaces the chat color picker with the seven desktop accents (Gray,
Chartreuse, Flame, Magenta, Iris, Cobalt, Jade). Saved legacy colors map
to the closest one.
- Uses white text in your own bubbles, and black where white falls below
4.5:1 (Chartreuse, dark-mode Gray). Links (underlined), quotes, tables
and code use the same text color.
- Insets code blocks in bubbles evenly, with corners concentric to the
bubble and even inner padding.
- The swipe-to-reply indicator uses the chosen chat color.

## Testing
- `flutter analyze` and `dart format` are clean. The full mobile suite
passes (3,705 tests).
- Installed and checked on a Pixel 10 and an iPhone Air against a real
community.

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

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Queue <874bf2d815c2c4b459b90438c669c57f2702f3a76bb6e16fa97184259aac3e9e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:48:12 -04:00
klopez4212
c9e35f8ac7 fix(mobile): restore Solo chats from channel labels (#8466)
Restore Solo chats and project placement on mobile using `buzz.solo`
labels and creator metadata. Invalidate stale channel details when
identity changes so opening and sending preserve refreshed Solo
identity; shared chats retain ordinary sending. New mobile Solo chats
publish the discovery label, and the personal send guard enforces
ownership even when route hints or legacy placement suggest Solo.

The current desktop producer lives in `block/buzz-app`. [Contract
documentation](0201ca0572/docs/solo-mobile-contract.md)
links its production creation call to relay identity generation and
mobile regression coverage. Public metadata fields are documented.

Validation: clean analysis; 3,542 mobile tests (5 skipped), 4 push
checks, and 280 core unit/doc tests passed. Removing the cache fix
reproduces the send failure. Local push hooks were skipped with owner
approval after an unrelated terminal timeout and disk exhaustion; full
local CI is not green. GitHub CI must validate the pushed commit. The
initial build was installed/launched on iPhone; follow-up native and
live cross-client confirmation remain pending.

The ownership regression supplies a valid owned member recipient and
checks the exact rejection. A New chat UI test captures the real signed
kind-9007 command. Both regressions were mutation-verified: removing the
ownership guard or the production creation label causes the
corresponding test to fail.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-20 11:10:24 -04:00
Wes
3ec6327618 fix(mobile): keep cached state with its community (#8349)
## Summary

One identity can be paired with several communities, but mobile keyed
several caches by pubkey only. Mobile keeps a single `ProviderScope` and
doesn't remount on a community switch, so one community's data showed up
in another, and some of it was published to the other community's relay.

- **Channel groups**: the second community showed the first community's
groups.
- **Read marks** (contexts, publishable set, NIP-RS client and slot
IDs): the first community's context IDs were merged into, and published
to, the second community's read-state slot.
- **Inbox done/unread rows and thread follows**: one shared 500-entry
cap, so activity in one community evicted another's entries.
- **Channel stars and mutes**: union-merged across communities and
published to every relay.

Every one of these is now keyed by pubkey + normalized community relay,
the same way channel sort already is.

**Switching communities.** The relay session rebuilds for the new
community before the previous community's managers are retired. Each
manager (groups, read state, stars, mutes) now checks that the session
still serves its own community before it publishes, merges a fetch, or
handles a live event. Previously, a pending publish flushed on dispose,
or an in-flight fetch, could cross into the next community.

**Old pubkey-only entries.** Channel IDs can repeat across communities,
so nothing tells us which community an old channel-keyed entry belongs
to:
- **Groups, stars, mutes and read marks** are dropped on upgrade, and
each community restores its own from its relay. Any change that never
reached a relay is lost, and a read mark the relay doesn't hold shows as
unread once. That's an accepted tradeoff: such changes would be very old
by now.
- **Inbox, thread follows, and read-state slot and client IDs** seed
every community, and the old entry is kept. The inbox and follows
entries are event IDs, which only ever match their own community. The
slot ID keeps each community publishing to the slot it already has on
its relay.

This PR also removes the **Pulse** feed (`features/pulse/` and the Nostr
filters only it used). Nothing in the app navigates to it anymore, and
its providers were among the community-unscoped caches.

### Related issue

None found.

### Testing

- Each affected store has a regression test through the real provider
that switches A → B → A. For groups, read marks, stars and mutes, the
test sets a value in A, switches to B while A's change is still pending,
and checks two cases: B's data arriving live and B answering A's
in-flight fetch. It asserts that nothing sent to B contains A's IDs, B
shows only its own value, and A comes back unchanged. Each guard was
removed in turn to confirm a test fails without it, except two that the
current switch order never reaches: the groups merge guard and the
read-state live-event guard.
- Upgrade tests cover each kind of old entry: dropped for groups, stars,
mutes and read marks, seeded for inbox, follows and slot IDs.
- `flutter analyze` is clean, and the full mobile suite passes in the
pre-push hook at `89ebfcc12`.
- Not yet exercised in the app.

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

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:59:38 -06:00
Wes
dcced2c4f7 fix(mobile): preserve saved identity lookup after enumeration fails (#8470)
An account-scoped saved identity could fail to open when secure-storage
enumeration succeeded while showing choices, then failed before
selection. Retain the discovered storage location and reread/validate
that exact record before falling back to inventory lookup. Only public
location hints are retained; signing material is read fresh.

Follow-up accepted at merge of #8375:
https://github.com/block/buzz/pull/8375#pullrequestreview-5577985447.

Validation: existing self-hosted widget journey now injects enumeration
failure after choices load and verifies the same key is admitted and
persisted. Three focused files passed: 37 tests with hosted domains
configured; 28 passed and 9 hosted-only skips with default
configuration. Changed-file analysis passed. In an isolated clone,
removing the location lookup failed that journey; control and restored
runs passed. Self-review found no blocking defect.

Draft acceptance gate: native/device behavior and human acceptance are
not established by widget fixtures. On a disposable saved identity,
enter a self-hosted community link, choose the identity, and confirm
admission preserves it; cancel/retry as well. Hosted live-backend
acceptance remains separate and pending backend availability. No full
local suite was manually run; mandatory hooks/CI provide broader
coverage.

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@feedback.communities.buzz.xyz>
2026-10-19 16:33:42 -06:00
Wes
91b1cc4761 perf(mobile): reuse the derived pubkey when signing auth events (#8460)
## Summary

Each relay `/query` signs a NIP-98 event, and each relay media image GET
signs a Blossom auth event. Both run on the UI isolate. `nostr`'s
`Event.from` derives the pubkey from the secret key on every call, which
is about a third of the cost of a signature.

Both call sites now pass the pubkey that `myPubkeyProvider` already
caches for the active key. The provider is derived from the same
`relayConfigProvider` state as the key, so the two always match.

This is the quick win. Moving signing to native secp256k1 or a worker
isolate would remove the rest of the cost, and that would be a separate
change.

## Before and after

AOT-compiled benchmark on Apple silicon, `nostr` 2.0.0 `Event.from`,
three runs of 50 signatures each:

| | ms per signature |
|---|---|
| `main` (pubkey derived) | 8.8 to 9.6 |
| this PR (pubkey passed) | 5.9 to 6.4 |

A scroll that shows 20 uncached relay-media images now spends about 60
ms less on the UI thread.

## Test

There are no new tests. The existing `queryRelay` test runs through the
real `relayConfigProvider` and `myPubkeyProvider`, and asserts the
signed event's pubkey for one identity. It does not verify the signature
or switch identities, and it would still pass with this optimization
removed. The media tests build the service directly, so they only cover
the fallback where the pubkey is derived.

Review traced the pairing instead. Both the key and the pubkey come from
`relayConfigProvider`. `_postForEvents` reads both and signs with no
await in between, and the media provider watches both. So the key and
pubkey change together on community switch and sign-out.

## Validation

- `flutter analyze`
- Full mobile suite: 3,534 passed.

**Human test:** Wes ran #8456, #8460 and #8462 together on the simulator
with his real account and confirmed they work.

buzz-review-completed

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

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:10:14 -06:00
Wes
b6aa5539e3 feat(mobile): account onboarding, key recovery and hosted reconnect (#8375)
## Behavior

Adds optional mobile Builderlab sign-in, identity backup/recovery,
community discovery/creation, and account-aware reconnect. **Builderlab
features are disabled by default:** when `BUZZ_AUTH_DOMAINS` is unset or
empty, account login, creation, backup/recovery, reconnect prompts, and
background account restoration are hidden/disabled. QR/code pairing,
links, and invites retain Nostr-only entry. Saved account state is
retained for later opt-in.


`--dart-define=BUZZ_AUTH_DOMAINS=communities.buzz.xyz,communities.public.builderlabstaging.xyz`
opts those DNS domains and their subdomains into required account setup.
The same policy gates pairing, deep links, durable community admission,
and reconnect. A nonempty list enables Builderlab features;
`BUZZ_BUILDERLAB_URL` alone does not. Enabled flows still require their
backend services. Existing Nostr relay authentication remains mandatory.

`BUZZ_HOSTED_RELAY_AUTH=true` enables scoped relay assertions and
reconnect prompts only for configured authentication domains. Configure
assertion issuance and each community's issuer binding before enabling
assertions; enable relay enforcement last. Account credentials stay at
Builderlab. Wrong-account recovery preserves keys; failed writes are not
replayed; authentication is bounded by request deadlines.

Hosted flows preserve keys across cancellation/restart, reconcile
interrupted creation/invitation acceptance, and support manual nsec file
import/export. Account identities are scoped to backend and
authenticated subject; reusing an existing local key for an unbound
account requires an explicit selection. Recovery reconciles the
advertised and recovered inventories before automatic selection.
Successful self-hosted admission retires its pending key, and iOS
startup removes interrupted export files. Adds authenticated relay `GET
/operator/communities/joined` for the companion Builderlab membership
endpoint.

## Validation

- Follow-up review repair at `66a12ac92`: self-hosted entry presents
readable choices and import despite incomplete enumeration, while
generation still requires a complete empty inventory; explicit selection
uses exact-key lookup. Consuming a value-equal duplicate now advances
dispatch. Reconnect requires an existing account binding or a
subject-scoped backup receipt before signing an assertion, and pins the
subject inside the serialized account request. An empty different
account is refused without sending key proof. Focused checks: configured
domains 58 passed; default 49 passed, 9 hosted-only skips. Separate
isolated mutations fail the incomplete-inventory sheet, exact-key reuse,
duplicate dispatch and reconnect journeys (including an
empty-account-only bypass); restored controls pass. Evidence uses
synthetic storage/network boundaries, not physical Android or live
backend association. All mandatory commit/push hooks passed; fresh
GitHub CI is pending.

- Bounded review repair at `d3cc0003f`: incomplete secure-storage
enumeration now blocks automatic identity generation while explicit
known-key reads retain their fallback; declined/failed onboarding links
release the queue; HTTP 429/5xx keeps Retry regardless of response
encoding. Default app-gate coverage exercises a bind link followed by
ordinary community entry. Focused checks: configured domains 47 passed;
default 38 passed, 9 hosted-only skips. Removing the three production
fixes in an isolated source/Git/build copy fails the
identity-resolution, declined/failed queue, and Retry journey
assertions; clean and restored controls pass. Storage and HTTP failures
are synthetic; physical Android and live outage behavior were not
exercised. Published after all mandatory commit/push hooks passed
(mobile analysis/tests, branch-skew and file-size gates); fresh GitHub
CI remains pending.

- Existing whole-PR source review cleared the reconnect defects at
`fb15d5b82`.
- Domain-policy increment: focused environment, entry, app-gate, pairing
and onboarding journeys pass with empty and configured domains. Existing
pairing provider/page tests pass. Changed Dart files analyze cleanly.
- Isolated source/Git/build mutation: forcing paired credentials through
hosted login fails the default-build journey; clean and restored
controls pass. CI now runs configured-domain journeys in addition to the
default-build suite.
- Integrated main through `5b433d929` at `0067c069c`, including its
pinned Flutter upgrade. Preserved the PR welcome UI with the updated
transition API and added Material surfaces around onboarding
selection/consent rows. Focused post-merge runs pass in both
configurations; all mandatory local push gates passed (mobile
analysis/tests, Rust tests, desktop-native checks, branch-skew and
file-size checks).

- Merge-review repair at `a315bce11`: focused identity/storage/admission
and widget journeys pass, including account switching with an empty or
already-bound second account, explicit reuse, and self-hosted
removal/rejoin with another key. Default-domain journeys pass too
(hosted-only cases intentionally skip there).
- Isolated source/Git/build mutation checks: bypassing account scoping,
reuse consent, pending-key retirement, or recovery inventory
reconciliation fails the relevant journey/invariant assertions; clean
and restored controls pass.
- Native `RunnerTests/IdentityFilePluginTests`: both tests pass on the
iPhone 18 Pro iOS 27 simulator with an isolated app identifier. At
`24a0dcaac`, the picker journey seeds residue in Library/Caches,
matching path_provider_foundation, and verifies plugin startup removes
it while preserving an unrelated file. The earlier tmp-only fixture did
not exercise the actual export location. This is synthetic native
cleanup/picker evidence, not a real terminated-share or live account
round trip.

- Follow-up: Caches and tmp cleanup; fallback to individually known
identity slots when Android bulk enumeration fails; explicit replacement
of a refused self-hosted key preserves its original before swapping the
retry slot. Configured-domain journeys/invariants: 34 passed; default:
31 passed, 3 hosted-only skips. Isolated enumeration/retry mutations
fail with passing controls. Bulk-read fallback preserves but cannot
enumerate arbitrary other-account slots until bulk reads recover.

- Configured-domain CI exposed a stale pairing-status assertion after
account scoping. The repaired test checks the admission mismatch,
preserved account/transfer keys, and no admitted community. The
configured-domain CI selection passes locally (56 tests); removing the
production mismatch guard fails the updated test, with clean/restored
controls passing. This is synthetic coordinator evidence, not physical
QR acceptance.

- Final review repair (`40e1e6101`): native iOS export creation uses
atomic complete file protection; normal and restart cleanup remain.
Account/policy/claim HTTP uses one 30-second total deadline with request
abort and body cancellation. Removed the unrelated NIP-FA spec edit.
- Focused account/admission/export tests: 25 pass; configured-domain
account/admission/onboarding journeys: 33 pass. Isolated deadline and
native-dispatch mutations fail, with passing clean/restored controls.
Native export/picker tests: 2 pass on iOS 27 simulator. The real
file-protection attribute assertion is physical-device-only because
simulator provides no Data Protection attribute; locked-device
protection and interrupted real sharing have not been observed.

- Opt-in UI: configured-domain CI selection passes (57 tests); default
selection passes (7 tests, 10 hosted-only skips); URL-only plus
whitespace/empty domains passes (6 tests, 9 hosted-only skips). Changed
Dart files analyze cleanly. Independent mutations that unconditionally
initialize the coordinator in the app gate, welcome entry, or link form
fail the default journey; clean/restored controls pass.
- Native welcome screens exercised on iPhone 18 Pro / iOS 27 simulator
in default and configured builds: default exposes community link / QR /
pairing code only; configured build additionally exposes Builderlab
connection and community creation. No live hosted login was exercised in
this check.

- Delivery at `e48fe73a9`: all mandatory push hooks passed (full mobile
analysis/tests, Rust tests, desktop-native checks, branch and file-size
checks). GitHub checks had not yet populated at the immediate post-push
snapshot.

## Review disposition

| Feedback | Disposition |
| --- | --- |
| Explicit self-hosted entry under unreadable inventory, duplicate
invite dispatch, reconnect cross-account binding
([entry](https://github.com/block/buzz/pull/8375#discussion_r4318421622),
[queue](https://github.com/block/buzz/pull/8375#discussion_r4318421640),
[reconnect](https://github.com/block/buzz/pull/8375#discussion_r4318455512))
| Repaired at `66a12ac92`; evidence above. Reconnect refuses unproven
ownership rather than silently establishing a binding. |
| Incomplete Android identity inventory, stranded deep-link queue,
transient HTTP retry classification
([review](https://github.com/block/buzz/pull/8375#pullrequestreview-5576864143))
| All three repaired in `d3cc0003f`; focused journeys and isolated
mutation evidence above. |
| Account cross-link, incomplete recovery inventory, committed invite
checkpoint, pending local key retirement/replacement | Repaired in the
preceding commits; journey and mutation evidence above. |
| iOS interrupted key export
([review](https://github.com/block/buzz/pull/8375#pullrequestreview-5575253252))
| Native cleanup plus complete protection at creation implemented.
Physical-device lock and real interrupted sharing acceptance remains
pending. |
| Trickle responses bypass idle timeout; unrelated NIP-FA edit
([review](https://github.com/block/buzz/pull/8375#pullrequestreview-5575764556))
| Total request deadline/abort implemented; spec edit removed. |
| Production domains rejected | Ordinary entry now needs no Builderlab
configuration. Opt-in account-required domains must still use supported
community mappings. |
| Lost Android Keystore recovery | Backup exclusions prevent the
reviewed stale-restore scenario; genuine key loss still has no in-app
recovery/reset and remains deferred. |
| Native secp256k1 dependency question | Obsolete against the current
base: upstream C integration landed in #8398; no package diff remains
here. |
| Operator membership endpoint contract | Documentation now states the
operator chooses the pubkey; no end-user session binding is implied.
Additional handler-level coverage is optional follow-up. |
| Optional Android partial document cleanup/activity retention; local
work after backup cancellation | Follow-up hardening, not claimed
implemented. Cancellation prevents remote backup upload. |
| Semgrep crypto inventory notices | Informational inventory, not
demonstrated defects. |

## Remaining draft gates

Native QR/browser-return acceptance remains unverified for this
revision. Optional Builderlab onboarding still needs backend
backup-status and joined-community routes, recovery
policy/configuration, and real backup/recovery/reconnect acceptance. The
empty-domain build removes these services as prerequisites for ordinary
pairing; it does not make the optional hosted flows backend-ready. Human
acceptance and current CI are still required before merge.



Review dispositions retained: production auth-domain configuration must
use supported community mappings; enabling `builderlab.xyz` does not add
support for `buzz.block.builderlab.xyz`. Recovery after genuinely losing
an Android Keystore key remains a follow-up, not a newly implemented
capability.


Human acceptance steps: build once without `BUZZ_AUTH_DOMAINS`
(including with a previously saved account) and confirm no Builderlab
controls/restoration while ordinary link and QR/code entry work. Build
with supported domains and confirm connection/creation appear; exercise
browser return and live backup/recovery/reconnect. On physical iOS,
exercise export and interrupted sharing/locked-device protection. Record
results before marking review complete.

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@wes.communities.public.builderlabstaging.xyz>
2026-10-19 15:46:58 -06:00
Tom Brow
dc4cdc4990 fix(mobile): reset community notification preferences (#8455)
Community notification choices now use `pushNotificationsEnabledV2`,
defaulting ON when absent. This gives every installed community a fresh
enabled preference, including communities previously disabled.
Subsequent explicit OFF choices persist per community through reloads
and reconnects. Previous values are deliberately discarded, not migrated
or preserved. There is no migration or compatibility read.

Enabled communities enter the existing APNs enrollment and active relay
lease publication path when connected to a push-capable relay. iOS still
controls notification display authorization. Notification eligibility
and lease addressing are unchanged.

Related work: [#8425](https://github.com/block/buzz/pull/8425).

### Validation

- Extended the settings integration test to load a saved community with
the previous preference OFF and no new preference and verify native
enrollment plus decrypted active lease publication, then OFF across
reconnect and ON again.
- The focused model test proves both previous values are ignored and
new-key OFF wins even when the previous key says ON. A temporary
compatibility-fallback mutation failed this test as expected; the
implementation was restored unchanged.
- Added storage coverage for independent community choices across
reloads; updated existing model round-trip coverage for the new key.
- Mobile dependency setup, formatting, analysis, and the full mobile
suite passed, including gateway-unconfigured coverage.
- Agent review found no blockers. Enrollment coverage uses the real
Flutter bootstrap, storage, signing, and lease construction with mocked
native and relay boundaries; no physical-device or live APNs delivery
test was run.

The fresh enabled preference is an intentional product exception to the
general zero-notification default in VISION.md. Subsequent user choices
and existing eligibility filters remain authoritative.

<!-- buzz-review-completed -->

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-19 10:09:25 -07:00
Tom Brow
8f2c7710e9 fix(mobile): default community push notifications to on (#8425)
New mobile communities currently require a manual Settings opt-in before
push can start. Default that local preference to on for new communities
and stored records without a preference. Preserve every saved `true` or
`false`, including values written by older clients. Older default-off
values are indistinguishable from explicit opt-outs, so this does not
migrate them to on.

Push still requires a configured gateway, authenticated connectivity,
and a valid relay capability. iOS continues to control display
authorization through its existing permission prompt. Notification
eligibility and subscriptions are unchanged. Settings retains its
existing opt-out control for enabled communities even when capability is
unavailable.

Drafted from the 0.24 baseline; release-branch inclusion is a separate
decision.

### Related issue

Closest related issue: #3238 (synced defaults). This change is limited
to the mobile device-local community preference; no duplicate found.

### Validation

- Added or updated coverage for new and missing preferences, saved
true/false persistence, opt-out after restart, capability-gated
registration, and Settings without a configured gateway.
- Full configured mobile suite: 3,499 passed, 4 skipped. Separate
no-gateway suite: 4 passed. Flutter analysis passed.
- Existing permission-denied and unsupported-relay tests passed as part
of the mobile suite. No physical-device or end-to-end APNs validation
performed.
- Full local repository gate passed (formatting, lint, unit tests,
desktop/web builds, Tauri checks/tests, and mobile tests).



- Follow-up review edits: restored the original VISION.md wording and
shared the default in one private Community constant. All 43 focused
community/bootstrap tests passed.

<!-- buzz-review-completed -->

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-18 16:10:01 -07:00
Tom Brow
819dd7924f fix(mobile): bump Flutter 3.47, resolving memory leak (#8419)
With accessibility semantics active, opening and leaving channels
retained disposed composer overlay states through Flutter’s semantics
traversal map. Those states kept departed route and message trees alive.
This upgrades the pinned Flutter SDK from 3.41.7 to 3.47.7, which
includes [the upstream
fix](3c96406cc4).

Includes SDK compatibility and formatter changes, preserves CocoaPods
integration, and gives create-channel radio lists a Material ink surface
required by the newer framework. Widget regressions verify both the
framework portal and the production channel/composer lifecycle: departed
routes, composer elements, and portal states are collectible while their
application’s semantics owner remains alive.

Related issue: no matching open issue or PR found.

### Validation

- [Current-head CI
passed](https://github.com/block/buzz/actions/runs/39085320351): all
mobile test shards, checks, Android APK, iOS release build, and native
simulator tests.

- Framework regression fails on Flutter 3.41.7 with three retained
states and passes on 3.47.7.
- Signed native Debug iOS Simulator build and paired-community
navigation replay: after 18 channel visits and forced GC, retained
disposed portal keys fell from 18 to 0 and route instances from 19 to 1.
- Review follow-up: all 365 focused channel/overlay tests, analyzer, and
complete mobile pre-push checks passed.
- Full local CI passed before the test-only review follow-up, including
Rust and desktop validation, frontend builds, analyzer checks, 3,499
mobile tests (four skipped), and the separate unconfigured-push test.

The replay used iOS 26.5 Simulator with accessibility semantics enabled,
CPU profiling disabled, and Debug/JIT builds. The before app was a
preinstalled emoji candidate; the after app uses this branch. Object
retention and the focused regression establish the fix; absolute heap
differences are not a controlled release-memory measurement. Android
native execution was not tested.

### Create-channel compatibility

The radio groups retain their layout after switching their container to
Material.

| Before (3.41.7) | After (3.47.7) |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/42525e1bd9d9f264d0142c34ec61dbb6937ef758/pr-8419--01-before.png"
width="280" alt="Original create-channel radio groups"> | <img
src="https://raw.githubusercontent.com/block/buzz/42525e1bd9d9f264d0142c34ec61dbb6937ef758/pr-8419--02-after.png"
width="280" alt="Updated create-channel radio groups"> |

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-18 12:44:43 -07:00
klopez4212
27b9c827fc Polish mobile message layout and navigation controls (#8427)
Align outgoing messages and galleries to the right, reclaim the outgoing
avatar gutter, and match desktop calendar dividers with balanced
spacing. Standardize navigation and sheet icons at 24 points, restore
native Edit/Done styling, and align section menus with trailing channel
indicators.

Validation: clean analysis; 3,513 mobile tests and push-configuration
checks passed; iPhone Release build installed and tested by the owner.
Includes regression coverage for message placement, date spacing,
gallery direction, and section alignment.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: fin <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-18 15:17:25 -04:00
klopez4212
3a45e87b25 fix(mobile): polish Inbox controls and system notices (#8423)
Use Tabler `filter-2` in Inbox and keep the iOS filter button rounded
when its menu closes. Remove shaded containers from chat system notices
while preserving spacing, profile links, and reactions.

Validation: mobile formatting, analysis, full test suite, and iOS
Release build; independent review found no blockers. Kenny verified the
icon and menu dismissal on his iPhone.

---------

Signed-off-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-18 10:31:21 -04:00
Tom Brow
a96ace4e55 fix(mobile perf): avoid eager emoji palette scans for prose (#8416)
Ordinary mobile messages eagerly built a set of every community emoji
shortcode and segmented their entire body just to decide whether to use
enlarged emoji rendering. Stop at the first non-emoji grapheme and build
the shortcode set only when a complete shortcode is encountered.
Emoji-only sizing and shortcode matching stay unchanged.

### Validation

- Full `just ci` passed.
- 125 focused detector and message-rendering tests pass; targeted
analysis is clean. The palette-access regression fails on the original
implementation (10,320 unnecessary reads) and passes with the fix. It
also checks that consecutive shortcodes share one palette scan.
- **Full Buzz app, real community data, debug/JIT iOS simulator, not a
rendering fixture:** two paired actual-input captures measured 125–127
µs per original detector call versus under 1 µs with the fix. All 5,200
executions per version agreed across 260 observed prose calls and a
2,580-entry palette. Instrumentation repeated each detector call, so
these captures are excluded from frame comparisons.
- Separate clean-code scrolling runs do **not** establish a reliable
smoothness improvement: quiet original UI-callback p95 was 10.1–12.6 ms
versus 10.1–12.9 ms fixed. A busy fixed run still reached 90.8 ms.
Normal app services stayed enabled; only scrolling was exercised. Live
background refresh, debug mode, host scheduling, and profiler overhead
limit interpretation. No physical-device/release-AOT or end-to-end
interaction-latency claim.

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-17 16:53:48 -07:00
Tom Brow
bf8b356f3b Fix iOS channel crash from unbounded scroll depth (#8407)
### Summary

Opening a channel on iOS 27.0.1 could terminate Buzz when a reversed,
lazily loaded timeline reported an unbounded scroll extent. Flutter
forwarded `Infinity` to the native navigation bar, and UIKit threw
`CALayerInvalidGeometry` while setting the scroll view offset.

Use the finite viewport depth for that unbounded case, preserving the
scrolled-under appearance. Reject non-finite offsets at the native
platform boundary as a final safety check.

### Validation

- Added tests: a Flutter widget regression for an unbounded reversed
timeline, and an iOS native regression for a non-finite platform-channel
offset.
- Reproduced the original crash by tapping `#general` on an iPhone SE
running iOS 27.0.1. With this change, the channel rendered and survived
repeated back-and-reopen cycles. Native logging confirmed the channel
now sends finite offsets where it previously sent `Infinity`.
- The native regression passed on iOS 26.5 and 27.0 simulators with
Xcode 27.0. Flutter widget tests and `flutter analyze` passed.
- A full channel-flow simulator run was not available: the isolated
simulator Profile build could not resolve `BuzzPushKit`. The iOS 27.0.1
physical run exercised the full channel flow.
- The full Flutter suite had eight notification and push test failures,
all reproduced on the unchanged 0.24.0 source.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-16 16:25:41 -07:00
klopez4212
c8ea4fb4eb feat(mobile): polish Home navigation and Solo workspace (#8393)
## Summary
Polish mobile Home and Solo: group Search/Inbox beside a separate
profile avatar, move Inbox and Settings to bottom-up pages with Close
controls, and place tabs opposite the plus/compose action. Morph plus to
compose clockwise when entering Solo and reverse counterclockwise when
returning Home, with a directional quarter-turn of the icon inside a
level button and a subtle scale pulse, respecting reduced motion. Align
Search’s Close control with Inbox, simplify its placeholder, and
initialize selected filter contrast correctly. Collapse the plus menu
when leaving Home. Center Solo’s native-glass New chat empty state and
keep it stationary behind the keyboard.

Standardize Tabler icons and neutral backgrounds, retain SF Back/Close
on iOS, use native Liquid Glass headers, tab container, and actions
(solid grey selected tab, slightly darker on iOS) on iOS and black
actions on Android, and smooth the expanded menu corners. Repair native
header safe-area/return layout and compose movement after cancellation.

### Related issue
None found for this UI refinement; follows #8337.

### Testing
- Formatting, analysis, full mobile suite: 3,487 passed, 4 skipped; 3
push-configuration tests passed. File-size checks passed.
- Base UI built and installed on Pixel and iPhone in place. Pixel
navigation/New chat cancellation checked. Subsequent glass and rotation
refinements are previewed on iPhone; motion feel awaits human
confirmation.
- Draft pending human confirmation of the final Settings entrance/return
behavior on iPhone.

### Pixel snapshots
| Solo | Expanded actions |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/16dc0f8af971e63d788fee20974997fab925dec9/docs/screenshots/mobile-home-solo/pixel-solo.png"
width="280" alt="Solo empty state with New chat" /> | <img
src="https://raw.githubusercontent.com/block/buzz/16dc0f8af971e63d788fee20974997fab925dec9/docs/screenshots/mobile-home-solo/pixel-actions.png"
width="280" alt="Expanded channel actions with smooth corners" /> |

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-16 12:28:02 -04:00
Logan Johnson
93ffc37cbd feat(mobile): find people by name through the relay's name-only profile search (#8333)
🤖
## Summary
In the mobile app, searching for "wes" did not find Wes. People search
matched text anywhere in a profile, including the image data of a shared
default avatar. Many agent profiles use that avatar, so they filled the
results and the real Wes never appeared.

Now every people search in the app uses the relay's new name search: the
Search page, adding channel members, new messages, and @mention
suggestions. It matches only a profile's name and display name and puts
exact names first. The desktop app (block/buzz-app#1084), agent tools
(block/buzz-app#1124) and the CLI (#8331) make the same change.

Compatibility: a relay without the new search answers 404 or 405. That
search uses the old search, and so do later searches on the same relay
for five minutes; then the app tries the new search again, so a relay
that is upgraded while the app runs is used soon after. A query or
result count that the new search cannot take also uses the old search.
Other errors are shown as before.

Order: search results keep the relay's ranking, so "Wes" comes before
"Abe Wes". Before, the Search page and the member and new-message
pickers sorted results by name. The list with no query is still
alphabetical. Each screen still applies its own filters.

Not in scope: the community invite screen accepts only an npub and has
no name search, so this PR does not change it.

### Related issue
Relay endpoint: #8301. This PR does not depend on it; either can merge
first.

### Testing
- Relay session tests: the name query goes to the new search with a
signed request; a 404 falls back and stays on the old search until the
retry time; a 405 falls back; other errors do not fall back; an
unsupported result count uses the old search.
- Updated tests for add members and @mention search, and a test that
search results keep the relay's order while the browse list stays
alphabetical.
- `just mobile-check` and the relay, channel, mention, invite and search
tests (1,962) pass.
- No screenshots: results look the same; only which people are found
changes.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-15 17:33:15 -07:00
klopez4212
b1d60f98e2 Bring synced Solo chats and projects to mobile (#8337)
## Summary
Bring desktop Solo chats and project organization to mobile, with synced
collapsible sections, shared unread highlights and tab badges, agent
selection, shared search, native iOS creation sheets, and inline typing
bubbles that transition into replies.

Resolve Solo placement for every chat entry route, retain encrypted
organization snapshots for offline reading, and let users resume or
discard unfinished chat and project setup without deleting existing
messages.

### Related issue
Related: #6565; expandable agent transcripts remain separate work.

### Testing
- Fresh Solo read state seeds each active chat from its captured initial
last-message timestamp before exposing row/section/tab unread state.
Baselines survive intervening channel updates and reset across
identity/community boundaries while surviving read-state readiness
changes. Regressions cover multiple chats, events before read-state
readiness and during/after seeding, and identity changes; removing
either the seed or the display gate fails them.
- Chat completion reconciles a retained Solo-save journal even when
placement is already confirmed. False/throwing cleanup regressions
ensure the outer intent remains until reconciliation and only one
placement is published. Typing terminal-close loops remain bounded; the
retry budget resets after an uninterrupted eight-second health interval
or transport reconnect.
- Typing retry budgets reset after sustained recovery; invalid Solo
updates retain cached rows with a retry warning that survives failed
refreshes. Project names use the editors’ 120-grapheme limit, including
emoji and combining characters. Regression coverage exercises repeated
recovery, invalid live/refresh heads, and character-limit boundaries.
- Rebased onto current main, preserving the newer Search controls,
Huddle timeline filtering, and message-media layout.
- Failed project journal writes reload storage before claiming recovery
and reopen the entered name for retry. Typing subscription rejections
are caught; terminal closures clear stale indicators and retry with a
bounded backoff, canceled on disposal. Regression tests cover both
false/throwing storage writes and initial/late subscription rejection.
- Explicit ordinary destinations skip optional Solo lookup waits.
Generic destinations perform one bounded lookup in the destination
(search no longer duplicates it); an unavailable classification falls
back to ordinary sending per the product owner's explicit choice.
Positively identified Solo destinations use Solo providers.
- Terminal Solo subscription closure preserves cached rows and exposes
live-update Retry. Page coverage verifies warning visibility, usable
rows during recovery, and a subsequent terminal close. Removing the
warning or the immediate ordinary-route guard fails its regression.
- Generic channel entries share an ordinary default when Solo
classification is unavailable, per the requested product behavior. Known
Solo placement takes precedence; callers can explicitly require
unresolved placement to remain blocked. Bounded initial lookup preserves
known Solo deep-link routing.
- Dirty newest snapshots and offline-save warnings survive transport
reconnects; Retry persists that same event even if the relay returns an
older head. Community changes replace the service, and agent-directory
reconnect refresh remains intact.
- Pending-save discard reloads failed removals. Repeated reconnect
testing confirms typing subscriptions are released on provider rebuild
and disposal; removing the existing cleanup fails the regression.
- Ordinary Home/create routes explicitly preserve private-channel
sending during Solo failures; identified Solo sends use the Solo sender
and explicit unresolved routes remain fail-closed. Solo unread replies
beyond the initial 200-event page are loaded through the oldest-unread
boundary.
- Chat/project/agent cleanup reloads failed removals, retains
same-process recovery, and protects replaced records. False/throw
regressions verify retries do not duplicate creation or membership.
Route, unread, and cleanup guard-removal mutations each fail their
regression.
- Ordinary channels, DMs, and forums remain sendable when optional Solo
sync fails; explicit unresolved placement stays blocked. Controlled
cache races retain the newest verified snapshot, keep usable chats
visible, and retry offline persistence without stale writes. Both
protection-removal regressions fail as expected.
- Confirmed journal cleanup checks false/throwing removal and
exact-record identity. Interrupted agent choices are visible
immediately, identify the saved agent, and cover Resume/Later/Discard
with accessible name/key-fallback controls.
- Notification deep links preserve the contiguous history cursor;
same-second messages infer the newest agent consistently. Failed
offline-cache writes surface Retry, with false/throwing storage failures
and offline restart covered.
- Composite history cursors cover 350 same-second events plus older
history; removing the ID cursor makes the regression fail. Agent changes
now journal before granting access, with restart/failure and
Resume/Later/Discard widget coverage. Signed malformed profiles remain
isolated.
- Native iOS avatar tests passed on the simulator: visible-row loading,
three-request concurrency, streamed byte limits, response validation,
downsampling/pixel limits, cancellation, and visible avatar updates.
- Solo and Home share observed-event/read-marker logic. Widget coverage
verifies unread rows, collapsed-section indicators, the tab badge before
first Solo visit, message/thread reads, forced unread, and filtering
archived/nonmember/unplaced chats.
- Chat intent is saved before ownership and organization preflight;
tests cover interrupted ownership lookup and a disconnect while the
native creation form is open.
- Mobile analysis, formatting, file-size checks, and full mobile tests
passed at `1d4c41354`.
- Project creation persists its encrypted name/UUID before connectivity
checks or relay reads. Restart tests cover Resume/Later/Discard, scope
isolation, stale-intent rejection, and lost acknowledgements. Removing
the pre-network save makes all four production-flow regressions fail.
- Regression coverage includes notification/Activity routing, inline
replies, unknown-placement send protection, restart recovery/discard,
and encrypted offline cache isolation/refresh.
- Earlier feature build exercised on a physical iPhone with existing
account/community data; review follow-ups have not been reinstalled on
the device. UIKit agent-row accessibility checks passed on an iOS
simulator.
- Earlier cross-platform `just ci` stopped on the existing `buzz-acp`
test `signals_cancel_startup_turn_and_pending_stdin_without_hanging`
(process exit-code assertion); no Rust production changes are included
in this PR.

### Remaining review limitation
Agent discovery is bounded to 10 pages / 1,000 profiles with automatic
retries disabled. Owner-scoped discovery remains open because the
current relay ignores `#auth` filters; supporting that query requires a
relay change.

### Snapshots
Sample data, shared Flutter/Android surface. Native iOS uses platform
sheets and controls.

| Solo: projects and chats | New chat | Choose an agent |
| --- | --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/d9b29aa079a3b9a88e8ed89b70df8d6af06fa41b/pr-8337--01-solo.png"
width="240" alt="Solo with collapsible projects and chats" /> | <img
src="https://raw.githubusercontent.com/block/buzz/d9b29aa079a3b9a88e8ed89b70df8d6af06fa41b/pr-8337--02-new-chat.png"
width="240" alt="New chat form" /> | <img
src="https://raw.githubusercontent.com/block/buzz/d9b29aa079a3b9a88e8ed89b70df8d6af06fa41b/pr-8337--03-agent-picker.png"
width="240" alt="Agent selection list" /> |

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-15 17:07:23 -04:00
Tom Brow
fea02d174d fix(ios): fetch profiles and avatars in notification extension (#8369)
A notification can arrive from someone whose profile the main app has
never loaded. The iOS notification extension now fetches and caches
sender profiles, channel details, and avatars itself. The main app
supplies account configuration and Keychain credentials only.

After resolving the message, the extension allows 1.5 seconds for sender
and channel details. It uses available stale data when a refresh is slow
or fails, delivers once, and lets late results update its private cache.
Profiles, channel details, and avatar refresh checks share a one-hour
freshness threshold. A verified avatar removal clears the previous
image. Account removal prevents further use of that sign-in's cached
data.

Profile, channel metadata, and membership use separate bounded
downloads, so an oversized roster cannot discard the smaller records.
Sender enrichment can finish while channel requests are pending. A
successful partial channel refresh atomically removes its old
counterpart, preventing a mixed group identity or recipient count.
Group-specific notification presentation still requires a verified
recipient count.

The
[specification](https://github.com/block/buzz/blob/native-notification-enrichment/docs/native-notification-enrichment.md)
describes the display, caching, and account-removal behavior. General
persistent image caching in Flutter remains separate.

### Validation

[Hosted CI](https://github.com/block/buzz/actions/runs/38780108487)
passed on the final commit, including all Flutter shards, Android, the
full iOS release and simulator builds, and native
controls/push-navigation tests. The [current-commit security
review](https://github.com/block/buzz/pull/8369#issuecomment-6197508385)
found no issues.

The native package passed 159 tests, including new coverage for cache
freshness, avatar replacement and removal, sign-in isolation, concurrent
storage, late results, single delivery, bounded downloads, and SVG
validation. A regression test exposed identical HTTP authentication
event IDs for requests in the same second; each request now includes a
unique nonce, matching the Dart and CLI clients.

Full local CI passed before that small authentication fix. The complete
native suite and signed device build passed again on the latest commit,
including response-size limits, overlapping avatar downloads, canonical
relay-origin matching, independent profile/channel refreshes,
account-key changes before and during requests, and a single initial
snapshot read across 64 accounts, signed IPv6 avatar authorities, and
partial channel refreshes. Focused Flutter regression tests passed 198
tests with three configuration-specific skips; seven avatar widget tests
also passed. Flutter analysis is clean, and the notification extension
builds for the simulator.

On an iPhone SE, the notification displayed the sender name, avatar,
channel, and message before the updated main app was opened. Repeat
notifications used a byte-identical private cache. Three notifications
delivered together displayed the avatar after the authentication fix.
The next notification also displayed correctly after terminating the
extension process. The existing generic-push design can select the same
newest unread message for several pushes; this change does not alter
message selection.

![Notification before first app launch after
upgrade](https://raw.githubusercontent.com/block/buzz/37d450855e026178836e2a9e05b0081ebbc3b479/pr-8369--before-first-app-launch.png)

Authenticated HTTP avatar fetching and persistent HTTP cache reuse also
passed on the SE. The first notification downloaded a relay thumbnail.
After terminating the extension and expiring its separate avatar entry,
the next notification displayed the avatar and refreshed that entry
without another media request in the relay logs. The main app remained
unopened throughout. The original app build and test profile were
restored afterward.

Automated tests cover slow and failed requests, stale images, removal,
and account revocation; those cases have not all been exercised on the
phone.

### Before merge

- [x] Verify authenticated HTTP avatar fetching and cache reuse on iOS
against a working relay media service.

<!-- buzz-review-completed -->

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-15 11:16:04 -07:00
klopez4212
d61fe2071d Use native UIKit scrolling for iOS photo carousels (#8361)
Builds on the mobile polish merged in #8359; this PR now targets `main`.
On iOS, inline photo galleries now use a UIKit collection view for
dragging, deceleration, and snapping. Photo taps retain the existing
full-screen viewer and reply/more actions; gallery scrolling continues
to fade the message avatar. The final photo stops at the full message
gutter rather than the smaller gap between photos. Android keeps the
current Flutter carousel.

Visible cells subscribe to continuing frames from Flutter's existing
authenticated image providers, preserving animated GIF/APNG/WebP media.
Request IDs fence late frames and cancel subscriptions on
reuse/disappearance/disposal. A four-slot bound includes retired
unresolved loads until their transport terminates. Shared media fetches
now use abortable HTTP requests with a 30-second deadline through
response-body completion, so stalled requests release their slots
without leaving live transports behind; native eager precaching is
disabled so it cannot bypass that bound. Frame conversion coalesces to
the newest pending frame. Native cells expose photo labels and positions
to VoiceOver, support RTL, and preserve the selected photo during
resizing.

Validation:
- Mobile formatting and analysis passed.
- Native simulator regression checks passed for geometry, RTL, snapping,
taps, frame replacement, cancellation, and resizing. The native CI lane
now runs the carousel executable.
- Full mobile suite and pre-push gates passed, including two-frame GIF
delivery, delayed-provider bounds/cancellation, real HTTP
stalled-header/body recovery with four requests maximum, disposal, and
unconfigured-push tests.
- Signed iPhone Release build of the frame/subscription implementation
passed. Physical iPhone testing covered the earlier carousel/gutter
revision; the new animation/cancellation changes have local Flutter and
native simulator coverage, not a new physical-device journey.
Preview-only signing overrides preserve its bundle/Keychain group;
production entitlements are unchanged. Local provisioning excludes
production push/App Attest capabilities.
- Full `just ci` was started but stopped after unrelated Rust/desktop
compilation consumed about 5 GB; broad CI is incomplete.

Device build required local Xcode 27 workarounds: iOS 16 deployment
target and a generated swift-secp256k1 cache compatibility edit. Neither
changes repository source.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-15 13:22:12 -04:00
klopez4212
42b395cdef Polish mobile bubbles, mentions, galleries, and huddle cards (#8359)
Builds on the merged themes work in #8358 and inherits its appearance
and chat-color settings.

Polishes mobile conversations with uniform rounded bubbles,
desktop-style text-only mentions and agent icon, aligned gallery counts,
fading gallery avatars, and standard iOS scrolling. Huddles use rounded
cards with attached reaction trays, without duplicate start/end notices;
other activity rows stay centered with smaller text.

Validation: mobile checks, the full mobile test suite, and the gallery
regression test passed. Release build installed on iPhone. Full `just
ci` could not finish: desktop compilation exhausted local disk space.

### Snapshots
Flutter-rendered light and dark previews using seeded sample messages.

| Light | Dark |
| --- | --- |
|
![light](https://raw.githubusercontent.com/block/buzz/62346699199dd707579f46d21608c2a49a8095ef/pr-8359--light.png)
|
![dark](https://raw.githubusercontent.com/block/buzz/62346699199dd707579f46d21608c2a49a8095ef/pr-8359--dark.png)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-15 12:39:21 -04:00
klopez4212
a5dfdd6d55 Align mobile appearance and channels with desktop (#8358)
Aligns mobile with desktop's neutral palette and six chat colors.
Appearance now offers Light, Dark, and System instead of themes, while
preserving community preferences and the single Home and Chat preview
pair.

Chat colors survive local preference reloads without historical-index
remapping, and the Chat preview shows the draft outgoing bubble color.

Channel and section icons are removed, section chevrons sit beside
names, and section dividers use 7% opacity. DM avatars remain.

Validation: `just mobile-check`, 35 focused tests, and the full mobile
suite passed (3,351 tests; 4 skipped). Updated light/dark previews were
rendered and visually checked. The earlier release was verified on
iPhone; this follow-up has not been reinstalled. The earlier
repository-wide lint passed, but full `just ci` was stopped for low disk
space. Hosted CI is running for the new head.

**Intentional design parity:** Section labels and chevrons retain
desktop’s `--text-unavailable` colors: light `#959595`, dark `#595959`.
Verified against `block/buzz-app` main at
`0201b54c496120a44e020834663dc534cae9a79f`. These colors do not meet
4.5:1 text contrast on the mobile section surfaces; the product decision
for this PR is to preserve desktop parity and leave them unchanged.
Kenneth explicitly accepts this known contrast tradeoff for this
PR/release and requests that reviewers treat it as a scoped product
exception rather than a blocker. This does not claim WCAG compliance or
change the repository-wide accessibility target. Any future contrast
adjustment should be coordinated across desktop and mobile.

### Snapshots

Channel list: physical iPhone. Appearance and chat colors:
Flutter-rendered previews.

| Channels | Light | Dark | Chat colors |
| --- | --- | --- | --- |
|
![channel-list-iphone](https://raw.githubusercontent.com/block/buzz/1effb78b36cbcd77bd6339f11742133ec9e8214a/pr-8358--channel-list-iphone.png)
|
![appearance-light](https://raw.githubusercontent.com/block/buzz/1effb78b36cbcd77bd6339f11742133ec9e8214a/pr-8358--appearance-light.png)
|
![appearance-dark](https://raw.githubusercontent.com/block/buzz/1effb78b36cbcd77bd6339f11742133ec9e8214a/pr-8358--appearance-dark.png)
|
![chat-colors](https://raw.githubusercontent.com/block/buzz/1effb78b36cbcd77bd6339f11742133ec9e8214a/pr-8358--chat-colors.png)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-15 08:45:16 -04:00
Wes
b77ba86262 perf(mobile): cut test wall time and run mobile CI lanes in parallel (#8347)
## Problem

Mobile pushes and PRs are slow.

- **Pre-push takes ~133s on a mobile-only change.** About 115s of that
is `flutter test`, and the finish time is set by a few files that wait
on real clocks.
- `push_presentation_export_test` (75s) and
`push_presentation_recovery_test` (19s) sleep through the full 7.75s
retry backoff.
- `channel_messages_provider_test` (70s) sleeps through recount and
ownership retry backoff on real time.
- **The required Mobile CI job takes ~15–16 min.** It runs tests
(390–430s on a 4-vCPU runner, 2 test files at a time) and then the
Android debug APK build (~400s, no Gradle cache) back to back.
- **Mobile Swift takes 18–21 min.** The unsigned release build (~4.5
min) waits behind the simulator tests.

Pre-commit is ~3s and isn't part of the problem.

## Change

**Tests** (each one still asserts what it did before)
- **Push exports.** Production retries wait through
`debugPushPresentationRetryWait`, which defaults to `Future.delayed`.
The export and recovery tests skip the real waits and assert the exact
250ms → 4s backoff schedule instead. Export fixtures sign each event
once per file.
- **`channel_messages_provider_test`.** Every test runs on `fakeAsync`.
A file-local `test` wrapper keeps the 90 call sites unchanged. Timers
fire in their real order, and a test that waits on work outside fake
time fails instead of hanging. Raising the production retry bound
(`attempt < 2` → `3`) still fails the 5 exhausted-retry tests.
- **`signed qualified caller`.** This test mounts the page 48 times and
opens the profile sheet twice per mount. It now disables the sheet
animation; it asserts which pubkey and labels show, not motion.

**CI**
- **Mobile runs as five parallel matrix lanes:** checks (format,
analyze, unconfigured push test, recipe test), three `flutter test
--total-shards=3` shards, and the Android build. The required `Mobile`
gate still reads the combined result.
- **Android restores a Gradle cache,** which is saved only on pushes to
`main`.
- **Mobile Swift builds the unsigned release** in a parallel lane beside
the native build, tests and simulator checks.

## Validation

Measured locally on an 18-core Mac with warm caches:

| | Before | After |
|---|---|---|
| `flutter test` (full suite) | 115s | 74s |
| Summed per-test time | 411s | 307s |
| `push_presentation_export_test` | 75s | 38s |
| `channel_messages_provider_test` | 70s | 22s |
| Pre-push, mobile-only push | 133s | 95s |

- The CI lane timings come from this PR's own run.
- `actionlint` passes, and so do
`scripts/test-ci-required-context-isolation.sh` and
`scripts/ci-required-gates.test.mjs`.
- The pre-push hook (format, analyze, full `flutter test`) passed on
push.

## Not in this PR

- `channel_detail_page_test` (~55s) is now the local long pole.
Splitting it into shard files would let it run in parallel, but it is
one 16k-line library with `part` files, so that is a separate refactor.
- Caching Hermit and pub on macOS would cut ~100s of setup from each
Mobile Swift lane.

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

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:57:47 -06:00