Commit Graph

253 Commits

Author SHA1 Message Date
Matt Toohey
fa9acdf7d9 fix(mobile): allow relay-sized join policy responses
Mobile read GET /api/join-policy through the generic 64 KiB hosted
response cap, but the relay allows each policy markdown document up to
256 KiB and Desktop accepts a 4 MiB policy response. A self-hosted
relay with a valid policy over 64 KiB was joinable on Desktop but
failed on Mobile with an invalid-response error before consent could
render, and every retry hit the same cap.

boundedHttpResponse and readJoinResponse now take a maxBytes that
defaults to the existing 64 KiB hostedResponseMaxBytes. Only
loadJoinPolicy opts into the 4 MiB cap that matches Desktop, so the
accept-policy, claim, and other hosted replies keep their limit. The
reader accumulates into a BytesBuilder so a multi-MiB body is not held
as a list of ints.

The invite consent journey now serves a policy over 64 KiB and
asserts that Mobile loads its full terms and claims only after native
acceptance. The hosted-admission "oversized policy" row used bare
whitespace, which failed as invalid JSON whatever the cap was. It now
sends valid JSON padded past 4 MiB. A focused test serves a relay-sized
policy alongside an accept-policy reply over 64 KiB: the policy reaches
consent, the reply is rejected, and no claim is sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
2026-11-04 14:56:40 +11:00
Matt Toohey
9c9b8fdd74 fix(invites): collect native consent for app invite handoff
Co-authored-by: Jitter <d14dfe033ef0f809866f9f984de04821b0d900d7652fd85a54776ee40ca3a68f@buzz.block.builderlab.xyz>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
2026-11-04 14:56:38 +11:00
Logan Johnson
1055f79d04 fix(mobile): show a taken channel name plainly and keep invites working (#8807)
## Summary

The relay is starting to refuse a new open channel when another channel
in the community already has that name
(https://github.com/block/buzz/pull/8782). Mobile handled that refusal
badly in two places:

- **Creating a channel with a taken name** showed raw error text:
`Exception: invalid: an open channel named "Launch" already exists`. The
create sheet now shows `An open channel named "Launch" already exists`
and stays open, so the user can pick another name.
- **Joining a community by invite could fail.** After an invite join,
the app joins two starter channels, `#general` and `#welcome-everyone`,
and creates either one if it can't find it. If an archived channel or a
forum already holds one of those names, the relay now refuses the
create, and the whole join failed. Now the app skips a starter whose
name is held by a channel it can't use, and the join succeeds. If the
holder is a live channel the app didn't see at first, the app joins that
channel.

Screenshots from an Android emulator against a local relay built from
https://github.com/block/buzz/pull/8782 are in the Testing section.

## Details

- **Error text.** A new `relayErrorMessage` helper
(`lib/shared/relay/relay_closed_policy.dart`) removes the `Exception: `
wrapper and the relay's machine prefix (`invalid:`, `blocked:`,
`duplicate:` and the other standard prefixes), then capitalizes the
first letter. Only the create sheet uses it. The other error paths are
unchanged.
- **Starter channels** (`ensureStarterChannels` in `lib/app.dart`).
After a taken-name refusal, the app reloads the channel list and looks
for the channel that holds the name:
  - A live open stream with that name: join it.
- An archived channel or a forum: skip that starter. An archived channel
can't be joined, and a forum is the wrong type for a starter.
- No visible holder: fail as before. The setup stays retryable, because
the holder may not be visible yet.
- **Which channel counts as the holder.** Only a channel that can hold
the name under the relay's rule counts: open, not unlisted, a stream or
forum. Channels now carry the relay's "unlisted" flag for this. An
unlisted channel with the same name (for example, an unlisted forum the
user is in) doesn't hold the name, so the app doesn't skip the starter
because of it.
- **A starter that was archived.** Desktop and mobile create each
starter at the same fixed id. When the starter at that id is archived,
the relay refuses a new create there with `invalid: channel is
archived`. The app treats that as an archived starter and skips it,
unless a live channel with the name exists. When the relay answers
`duplicate` and the reload doesn't show the channel, the app reads the
channel at the fixed id and skips the starter only if that channel is
archived.
- If `#welcome-everyone` is skipped, the join opens no particular
channel. Both callers already handle that.

### Related issue

None found. Client side of https://github.com/block/buzz/pull/8782.

### Testing

Android emulator, debug build of 778a4cada, local relay built from
https://github.com/block/buzz/pull/8782 at 0df245e3c, fresh database:

**Invite join while an archived `general` holds the name.** The app
skipped `general`, created `#welcome-everyone` at its starter id and
joined it. The archived `general` did not change, and no new `general`
was created.

![Invite join lands on
#welcome-everyone](https://raw.githubusercontent.com/block/buzz/ba7989fc9596b70ce950b391c7f5279d9353abbb/pr-8807--01-invite-skips-archived-general.png)

**Create a channel with a taken name.** `ops` is live. Creating `OPS`
shows the relay's refusal as plain text, and the sheet stays open.

![Create sheet shows the taken-name
refusal](https://raw.githubusercontent.com/block/buzz/ba7989fc9596b70ce950b391c7f5279d9353abbb/pr-8807--02-create-sheet-taken-name.png)

Automated tests: `invite_join_provider_test.dart` has a table row for
each holder case above (archived, forum, unlisted, appears late,
archived at the fixed id for both relay answers), plus the scope fences.
`channels_page_test.dart` covers the sheet text, and
`relay_closed_policy_test.dart` covers the helper. Each new skip fails
its row when it is removed. `just mobile-check` and `just mobile-test`
pass.

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

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 21:39:47 -05:00
Spencer Lewis
83d0bd3203 fix(mobile): stop QR pairing to organization relays from hanging (#9001)
## Problem

Pairing a phone to an organization relay by QR (for example Block's
`buzz.block.builderlab.xyz`) never finishes. After the codes match and
the phone confirms, it sits on the loading surface. About 2 minutes
later it returns to the welcome screen with "Lost connection to pairing
relay." The desktop stays on "Finishing pairing on your phone…". This
reproduces every time on Buzz 0.27.0, iOS 27.2, with desktop 0.7.7
(internal tracking: AIDA-2420).

There are two causes:

1. **Validation can never pass for organization relays.** After the
payload arrives, `_validateCredentials` opens a plain `RelaySocket`
without a `Nostr-Federated-Identity` assertion. Organization relays
refuse that upgrade with HTTP 401 until organization sign-in, which a
freshly pairing phone hasn't done.
2. **The failure hangs instead of erroring.** `RelaySocket.connect()`
catches the failed `ready` but keeps `_channel`. The validator's
`finally` then awaits `disconnect()` → `sink.close()` on a channel that
never opened, and that never completes. The phone waits until the
pairing relay's 120 s connection cap closes the socket, and
`_handleDisconnected` reports that as a lost connection.

Standalone repro with `web_socket_channel` 3.0.3 against the real relay:

```
ready failed: ... was not upgraded to websocket, HTTP status code: 401
sink.close() DID NOT COMPLETE within 30s (hang)
```

## Change

- `RelaySocket.connect()` drops a channel whose upgrade failed.
- `disconnect()` waits at most `RelaySocket.closeTimeout` (2 s). An
upgrade that is still pending (connect timeout 10 s) can outlive
pairing's 8 s validation deadline, and its close never completes either.
- `PairingNotifier` skips the pre-sign-in check for
`OrganizationEnvironment.protects` relays. Admission is left to the
saved community's session and the existing organization sign-in banner,
which matches how the hosted branch already defers admission. The
predicate is injectable (`organizationRelay`) for tests. Other relays
are still validated before saving.

Trade-off: an identity that the organization relay would reject is now
saved, and that rejection only surfaces after organization sign-in. The
hosted path has the same property.

## Related issue

No existing GitHub issue covers this failure; searched open issues and
PRs for pairing relay, lost connection and `_validateCredentials`.
Closest:

- #8043 / #8060 cover the same `_validateCredentials` path from the
other side: a failed connect currently returns normally. They're
complementary. With #8060 merged, organization relays would fail pairing
with an explicit 401 instead of hanging, and this PR keeps them out of
that pre-check. Whichever merges second needs a small rebase in
`_validateCredentials`.
- #3181 is a different failure in the same flow.

## Testing

- New `relay_socket_liveness_test` cases check that `disconnect()`
completes in three situations: after a refused (401) upgrade, after a
timed-out handshake, and while the upgrade is still pending.
- New `pairing_provider_test` case: a malformed key is rejected locally
before anything is saved, an organization relay is saved without the
relay check, and a non-organization relay is still validated and
rejected.
- Each new test fails with its fix removed. The failures were
"disconnect() hung on a channel that never opened", "disconnect() hung
on a pending upgrade", and `validated` being true.
- `just mobile-check` and `just mobile-test` pass: 4229 passed, 40
skipped.
- **Simulator run (iOS 27.0 simulator, iPhone 18 Pro, Block organization
dart-defines) against desktop 0.7.7 on `buzz.block.builderlab.xyz`:**
pairing completed (desktop reported the phone paired; identity
transferred; community saved) instead of hanging until the pairing relay
closed. The app then showed the organization sign-in banner and opened
browser sign-in for block.builderlab.xyz, as expected. Okta in the
simulator requires FastPass, so the final relay connection after sign-in
was not exercised there.
- Human device testing is still pending.

## Follow-up

- Test on a real phone with an organization build. The simulator
couldn't complete Okta FastPass, so the relay connection after
organization sign-in still needs checking.

---------

Signed-off-by: Spencer Lewis <slewis@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:17:15 -08:00
Tom Brow
1bef1da77d fix(mobile): show newly created remote agents in the new-chat picker (#9030)
After creating a remote agent on mobile (for example in onboarding), the
agent doesn't appear in the Solo **New chat** agent picker. Mentioning
it in a channel, or restarting the app, works around this.

The picker's agent list (`meAssistantsProvider`) is loaded once and kept
for the app session. Creating a remote agent publishes its owner
registration (kind 30177), and Beekeeper publishes the agent's attested
profile, but nothing told the cached list to reload. So the new agent
stayed missing until something unrelated (a relay reconnect or an app
restart) rebuilt the list.

With this change, the list reloads whenever this device publishes or
removes a remote agent registration, using the controller's existing
`registrationRevision`. Agents created on another device still show up
only after the next reload, as before.

### Validation

- Added test: `Solo agent choices reload after this device creates an
agent` (hosted build) creates an agent through the real
`RemoteAgentController.create` path and checks that the picker's list
includes it. It fails without the fix (list stays empty) and passes with
it.
- Full mobile suite passes, and so do the hosted-build `shared`,
`features/agents` and `features/channels/me` suites. Analyzer and format
are clean.
- Automated simulator pass on the fixed build (signed in to a
Builderlab-hosted community): opened **New chat**, the picker listed all
7 agents, picked one, opened an existing Solo chat and the Agents page,
then reopened the picker and it still loaded correctly. Human testing is
waived per the repository's mobile testing exception.
- Limitation: I didn't create a real remote agent on the simulator,
because its Builderlab web sign-in stalled. The create → picker path is
covered by the added test.

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

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:04:07 -08:00
Tyler
dafe31bfe5 feat(mobile): persist frontier-only NIP-RS read state across devices and restarts (#8998) 2026-11-03 18:10:28 -05:00
morgmart
c24b0f472e Open the new Bestie's chat after creating it (#8972)
Stacked on #8957 (Kenny's Bestie onboarding).

In #8957, tapping the empty Bestie slot opens Create agent, and saving a
first agent makes it Bestie, but you land back on home and need a second
tap to start talking. This takes you straight into the new Bestie's chat
once creation finishes, which matches where the group DM landed: pick a
name and avatar first, then you're chatting with it.

- Creating a first agent from the empty slot opens its chat.
- Cancelling, or creating an agent that doesn't become Bestie (you
already own others), still returns home as before.
- The slot stays disabled while the create flow is open, so a double tap
during the account check can't open it twice.
- Opening an existing Bestie is unchanged; that code just moved into a
shared step.

Validation: new widget test covers both the create and cancel paths, and
it fails without the change. The mobile widget tests touched by the
launcher pass (508), and `flutter analyze` is clean on the changed
files.

Not yet checked: creating an agent on a real device. #8957's recording
also stops before submit, so the real create → chat handoff (including
how long the new agent takes to appear) still needs a device run.

---------

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Carl <carl@agents.local>
Co-authored-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Morgarita <6e967af659416160ab4f2fd56d74c8c6f273791ee7bfc9468a1be37c387947be@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:59:20 -08:00
Tom Brow
bf13f7c83b fix(ios): mint NIP-FI assertions in the notification extension (#8982)
Organization relays that enforce NIP-FI (federated identity) require a
`Nostr-Federated-Identity` assertion on `/query`. The iOS notification
service extension signed its `/query` with NIP-98 only, so the relay
answered `401` and every push from an enforcing organization relay
showed the generic "You have a new message." body.

The app already mints these assertions in Dart from its organization
account session, but that session lives in the app's own keychain, and
assertions last minutes, so a cached one is useless by the time a push
arrives. This change lets the extension mint its own:

- **App:** whenever the organization `BuilderlabAccountClient` adopts or
clears its session, the app writes or deletes a device-only keychain
item in the group it already shares with the extension. The item holds
the organization origin, the session credential and the organization's
exact relay hosts (from `BUZZ_ORGANIZATION_RELAYS`). Builds without an
organization never write it.
- **Extension:** for a community whose relay host is one of those exact
hosts, `BuzzRelayAssertionIssuer` requests an assertion from the
organization issuer (`/api/goose/v1/buzz/identity/assertions`, session
credential plus NIP-98 with the community's account key, the same
request the app makes), checks it was issued for that key, and caches it
in-process until 30 seconds before expiry. Both `/query` paths (message
lookup and sender/channel enrichment) send it. Other relays get no
header and never see the session.
- Signing out awaits removal of the shared copy and fails if removal
fails; each restore that finds no session retries the removal.
- If issuance fails or the user is signed out, the extension sends the
query without the header and the relay refuses it, which keeps today's
generic fallback.

Not covered here: relay-hosted avatars on enforcing relays still load
without the assertion (the notification text and title are unaffected),
hosted Builderlab communities, and Android.

### Validation

Added tests:
- `testOrganizationRelayQueriesCarryMintedAssertion`: against a stub
relay that answers `401` without the assertion, two organization pushes
resolve with message body and sender name from a single issuance, a
non-organization relay gets neither the header nor the session, and
after sign-out the notification falls back. Removing the header from the
query path makes this test fail.
- `testOrganizationAssertionIssuanceFailuresSendNoHeader`: refused,
wrong-key, expired and header-injection responses produce no header.
Subdomains and other ports of the organization relay host are never sent
the session.
- `builderlab_account_test.dart`: the shared copy follows exchange,
restore and clear; a failed removal fails sign-out, and the next restore
retries it.

Focused existing validation: the full BuzzPushKit suite, the hosted Dart
tests, `flutter analyze`, and an iOS device build of the app and
extension.

Not yet validated: a live push from the Block relay on a device.

## Before merge

Live device push test: waived by Tom (2026-11-03).

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 19:01:42 +00:00
Tom Brow
40fe4b5d9a perf(mobile): evict verified-profile interest in one pass (#9007)
## Summary

Follow-up to [#8931](https://github.com/block/buzz/pull/8931), from
Wes's [review
note](https://github.com/block/buzz/pull/8931#discussion_r4456891410).

[#8931](https://github.com/block/buzz/pull/8931) replaced the
per-eviction scan in `VerifiedProfilesNotifier.track` with `keys.first`.
That was much cheaper than main, but not constant time: removing from a
Dart map leaves deleted slots, and each new `keys` iterator walks past
them. One `track` call that overflows the 1,024-subject cap by E
subjects still costs about E²/2 slot checks. Wes measured ~3 ms at 4,300
subjects, 62 ms at 8,600 and 205 ms at 17,200.

Eviction now takes the overflow from a single iteration
(`keys.take(overflow).toList()`), so it's linear. Which subjects are
kept doesn't change: still the 1,024 most recently inserted.

The [#8931](https://github.com/block/buzz/pull/8931) eviction test
counted keys yielded by the map, which `keys.first` keeps at one per
eviction, so it couldn't fail on the quadratic path. It now counts
iterations started over the interest map and expects exactly one per
`track` call. Main's scan and the merged `keys.first` loop both start
one per eviction.

### Testing
- Falsification: with the merged `keys.first` loop restored, the new
test fails (`Expected: <1>, Actual: <3276>`). With this change it
passes.
- `flutter test test/shared/verified_profiles/`: 31 passed. The existing
"keeps the 1,024 most recently tracked subjects" journey still passes,
so retention is unchanged.
- `just mobile-check`: clean.
- `just mobile-test`: 4,227 Flutter tests passed, 40 skipped, plus 4
push and 27 crypto tests.
- Automated app run: installed this build on an iOS 27 simulator (iPhone
17 Pro) signed in to a Builderlab community, opened the inbox, a channel
timeline with images and the image viewer, and the channel details
member list, which all track verified-profile subjects. No errors. That
community is small (30 members), so the overflow path itself is covered
by the unit tests, not the app run.

- Agent review: an independent agent review found no blockers.
- Human test: waived under the mobile testing exception; the automated
app run above replaces it.

Not covered: Android, and no new device profile (Wes's AOT numbers above
are the cost model; this removes the E² term).

buzz-review-completed

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

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 10:32:56 -08:00
John Matthew Tennant
33eaa73c83 fix(mobile): let users sign out after leaving all communities (#8993)
## Summary
Fixes a dead-end in signed-in mobile onboarding: after leaving every
community, Back kept returning to the same Add a community screen
without a way to end the account session.

Show “Signed in as [email]” on that screen. Back asks “Sign out of
BuilderLab?” with the email; Cancel keeps the session, and Sign out
returns to the welcome screen while retaining saved Buzz identities.
Nested Back and Add community from an existing session keep their
navigation behavior.

### Related issue
None found in the existing issue and PR search for onboarding sign-out.

### Testing
The new widget journey failed before the change and passes through the
real onboarding widgets, account client, storage and flow with external
HTTP/browser boundaries mocked. It covers nested Back, Cancel, system
Back, durable session removal, retained identity/backup, and starting
email sign-in again. The existing hosted onboarding journey also covers
accounts without an email.

On iPhone 18 Pro simulator (iOS 27), rendered the production onboarding
screen and native confirmation using a disposable account fixture. Live
browser account selection has not been verified; this clears the app
session, not website cookies.

To reproduce on an auth-enabled mobile build: sign in, leave all
communities, check the displayed email, tap Back, and choose Cancel. Tap
Back again and choose Sign out. The yellow welcome screen should appear,
with email sign-in available.

## Screenshots

Before, from the original reported phone screen:

<img
src="https://raw.githubusercontent.com/block/buzz/07440b3b10c3f48a6fd19c923f2ee5ba66d574f0/pr-8993--before.png"
alt="Before: account state not shown" width="300">

After:

Signed-in landing and native sign-out confirmation, using a disposable
account:

<img
src="https://raw.githubusercontent.com/block/buzz/a4e18c85bc3a19d971b08ff595ba6b081c67e20f/pr-8993--landing.png"
alt="Signed-in landing" width="300"> <img
src="https://raw.githubusercontent.com/block/buzz/a4e18c85bc3a19d971b08ff595ba6b081c67e20f/pr-8993--confirmation.png"
alt="Sign-out confirmation" width="300">

---------

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

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

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

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

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

Native menu with the left label fixed:

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

---------

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

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

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

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

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


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

---------

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

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

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

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

### Related issue
None found.

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

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

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:16:43 -08:00
Tom Brow
58eb36dd57 perf(mobile): cheaper identity-name rebuilds for large rosters (#8936)
## Summary

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

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

Labels are unchanged.

## Measurements

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

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

## Testing

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

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

---------

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

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

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

## Problem

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

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

## Change

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

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

### Related issue

None found.

## Testing

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

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

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

---------

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

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

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

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

## Measurements

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

## Testing

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

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

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 08:00:26 -08:00
John Matthew Tennant
972e89ac89 fix(mobile): allow community removal without a cached push grant (#8978)
## Summary

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

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

### Related issue

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

### Testing

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

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

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

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

### Related issue

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

### Testing

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

### Screenshots

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

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

After (this PR):

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

Signed-off-by: John Tennant <jtennant@block.xyz>
Signed-off-by: John Tennant <jtennant@squareup.com>
Co-authored-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz>
Co-authored-by: Aaron <f425b86c3b780d43f8d9cf1802429d04dd985bf92eac44fca9468b5ad3887c5e@buzz.block.builderlab.xyz>
2026-11-02 18:46:36 -08:00
Tom Brow
009dc103f6 perf(mobile): read the channel list after live changes, not every minute (#8910)
## Summary

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

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

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

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

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

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

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

## Measurements

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

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

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

## Testing

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

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

---------

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

### Behavior

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

### Demo

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


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

### Validation

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

Full mobile check and test suite pass locally.

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

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

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

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

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

### Before merge

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

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

---------

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

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

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

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

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

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

## What

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

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

## Testing

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

## Rollout

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

Signed-off-by: Bradley Axen <baxen@squareup.com>
Co-authored-by: claude00 <fb8b76a01257932b8ca35060862c29bd09b2137638573731c2d5074fc02c5385@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 12:13:51 -07:00
Tom Brow
da93ce3f62 fix(mobile): open the public Builderlab login the iOS bridge allows (#8914)
On main, "or sign in with email" on iOS fails immediately with "We could
not finish connecting. You can try again." No Builderlab page opens.

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

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

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

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

### Validation

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

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
2026-11-02 10:00:08 -08:00
Tom Brow
9f8913dd1b mobile: keep a thumbnail fallback apart from a direct load of the same URL (#8913)
## Summary

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

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

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

Also:

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

## Testing

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

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

---------

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

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

### Before / after

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

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

### Validation

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

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

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

---------

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

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

### Related issue

None found.

### Testing

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

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

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-02 17:00:12 +00:00
Tom Brow
464448c7b2 fix(mobile): resync read state from the last fetch, not the whole week (#8903)
## Summary

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

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

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

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

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

## Testing

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

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

---------

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

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

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

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

### Before / after

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

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

### Validation

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

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

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

---------

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

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

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

**What people see**

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

**How it works**

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

**Not yet covered**

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

### Validation

Added tests:

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

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

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

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

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

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 15:51:25 -08:00
Tom Brow
220cec677a fix(mobile): keep community icons on originals and record thumbnail 404s at fetch (#8896)
Follow-up to #8879, addressing its review.

## Changes

- **Community icons load the original again.** `AvatarImageContent`
gained a `preferThumbnail` flag, off by default. People and agent
avatars turn it on (as #8879 did for every caller); `CommunityAvatar`
leaves it off. Community logos are often transparent PNGs, and the
relay's JPEG thumbnail renders transparency black. This matches desktop,
which shows community icons from the original.
- **A missing thumbnail is recorded where its 404 arrives.** Before,
only the mounted `Image`'s error builder recorded it. If the avatar
scrolled away before a slow 404 came back, the thumbnail URL kept a 30s
failure cooldown instead, and showing that avatar again at any size
during the cooldown rendered initials and never requested the original.
The fetch now records a 404 on a relay `/media/<sha256>.thumb.jpg` URL
in the missing-thumbnail set and puts no cooldown on it.

## Tests

Two journey tests in `avatar_image_test.dart`, both failing on main and
passing here:

- An avatar unmounts before its slow thumbnail 404 arrives, then mounts
again at another size: it requests the original once, renders it (valid
image bytes, no initials), and leaves no cooldown.
- A 36pt `CommunityAvatar` with a relay-hosted icon requests and shows
only the original.

`flutter analyze` is clean. In the full local `flutter test` run, only
12 push and notification tests fail, and they fail the same way on main
in this environment.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:51:13 -08:00
Tom Brow
f6f6d9a673 chore(mobile): delete dead code with no remaining UI path (#8880)
## Summary

Deletes mobile code that no longer has a user-reachable path and has no
counterpart in buzz-app, so it is unlikely to come back for parity. Net:
104 files, about 5,500 lines removed. No intended behavior change.

- **iOS native message menu and action surface:**
`NativeMessageMenu.swift` and `NativeMessageActionSurface.swift`, the
`message`/`supportsMessage` coordinator branches, and the Dart selection
hook. Dart stopped creating this view in
[#8280](https://github.com/block/buzz/pull/8280). The native reactions
sheet in the same coordinator is kept.
- **Old home quick-actions launcher and `ComposeMorphIcon`:** only
instantiated in tests since the nav-bar creation actions replaced them.
- **Retired local theme stack:** `ThemeNotifier`, `AccentNotifier`,
`SchemeNotifier`, `adaptive_theme.dart`, `AccentPickerPage`, the
appearance-mode sheet and their helpers. These predate per-community
theme sync. The legacy prefs migration in
`community_theme_preference.dart` is kept.
- **Unused widgets:** `ProfileAvatar` and `MaskedAvatarBadge`.
- **Push permission-status plumbing:** the iOS
`notificationAuthorizationStatus`/`openNotificationSettings` handlers,
plus the uncalled strict age-gate snapshot registration and its Swift
`syncAgeGatePushSnapshot` path.
- **Orphaned files** with no importer: `profile_text_edit_sheet`,
`editor_option_button`, `retained_page_transition`,
`tappable_flapping_bee`, `agent_model_info`, `community_bot_profiles`,
`sheet_divider`, `success_haptic`.
- **Uncalled helpers:** the `pickAndUpload*`/camera helpers in
`media_upload.dart`, unused `NostrFilters` builders,
`mediaGetHeadersFor*`, and about 20 other uncalled functions, providers
and styles.
- **Removed-UI leftovers:** the never-wired invite directory providers,
`AuthNotifier.signOut` and `removeActiveCommunityForSignOut` (the Sign
out row is gone), `renameCommunity`,
`HomePage.profilePageBuilder`/`SettingsPage.embedded`, and the
create-forum branches of the create-channel sheet. Forum viewing is
unchanged.

Tests that only covered deleted code are removed. Tests of surviving
code that used deleted helpers as drivers were repointed to the
surviving API.

An audit of `mobile/android` found no dead Kotlin, resources, manifest
entries or Gradle dependencies, so Android code is unchanged.

## Validation

- `flutter analyze`: no issues.
- After rebasing onto main
([93b8b8f](https://github.com/block/buzz/commit/93b8b8febc)), `just
mobile-check` is clean (format and analyze), and `just mobile-test`
passes (4,070 passed, 35 skipped, 0 failed, plus the push-unconfigured
and secp256k1 suites).
- `dart format`: clean.
- Android: `just mobile-build-android` builds the debug APK, and
`./gradlew :app:testDebugUnitTest` passes.
- iOS: `build-for-testing` succeeds and BuzzPushKit `swift test` passes
(65 tests). In RunnerTests, 97 of 98 pass.
`testNativeNavigationTitleExpandsAgainAtTop` fails, but it fails the
same way on unmodified `main` (acf56fff6f) on the same machine (Xcode
27, iPhone 17 Pro simulator), so it is not caused by this change.
- Agent review of the diff ran. Its one blocker (stale `project.pbxproj`
entries for the deleted Swift files) is fixed.
- In-app smoke run at
[66141c1](66141c10b9):
a debug build on a fresh iPhone 17 Pro simulator (iOS 27), signed in to
the internal community. The following flows all worked with no errors:
- Home: the New menu (channel, DM, workspace, chat) and the
create-channel sheet, including its channel-type options.
- A chat, with the message action sheet (reactions row, Reply, Copy
link, Remind me, Mark unread, Copy text, Edit and Delete).
  - Appearance: the previews, and cycling the System and Light modes.
- Settings: the profile header, the display name editor, and Edit photo
(Image and Emoji tabs).
  - The Agents directory.
- Search, including the Channels filter, plus opening a team channel
from the results.
- Tapping a reaction summary, which opens the native iOS Reactions
sheet.
- The recording isn't attached here because it shows internal channels
and messages.

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

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:09:50 -08:00
Tom Brow
2e0abbcc0c fix(mobile): let slow image downloads finish (#8881)
On a slow link, a large image could never appear. Every image fetch was
aborted 30s after it started, even while bytes were still arriving, and
a failed fetch then sat on a 30s cooldown before it could be retried. At
240 kbps, anything over about 900 KB could not finish. On a shaped
iPhone SE, a 1.45 MB avatar was fetched, aborted at 30s and never shown.

- **Abort stalls, not slow downloads.** A fetch is now aborted when no
headers or body bytes arrive for 20s, so a dead connection still frees
up quickly. A download that keeps making progress can run for up to 5
minutes, so a server that trickles bytes cannot hold a connection
forever. The 20 MiB response cap still bounds its size.
- **Cancel previews scrolled past in the iOS carousel.** The carousel
loads at most four previews at a time, and a preview scrolled past kept
its slot until its download ended. With the longer limit, four slow
previews could block the visible one for minutes. Now the carousel drops
a cancelled preview's pending image-cache entry, and with no one left
listening, the image loader aborts its HTTP request. Scrolling back
starts a fresh download with no failure cooldown. A download that
another consumer, such as the full-screen viewer or a gallery prefetch,
is still listening to keeps going, and its finished image is still
cached. Closing the carousel aborts its pending previews the same way.
- **Cancel gallery prefetches that leave the window.** The message
gallery prefetches a few pages around the current one with two workers.
Each prefetch used to hold its download until it ended, which also kept
the carousel's cancel from aborting it. Now a prefetch that leaves the
window, or whose gallery goes away, lets go and aborts the same way.

### Validation

Added tests (each fails without its fix):

- A body that trickles in for longer than the stall timeout still loads,
and one that never ends is aborted at the overall cap.
- In the iOS carousel, previews scrolled past abort their stalled
downloads (before headers and mid-body), the next preview loads, and
scrolling back starts a new download. The test fails if the carousel
doesn't evict, if the loader doesn't abort, or if the abort leaves a
cooldown.
- Closing the carousel aborts its stalled previews.
- In a message gallery, jumping to the end and back aborts both
in-flight prefetches and moves the workers to the new window.
- Abandoning a load another consumer still waits for keeps the finished
image cached with no second fetch; abandoning one nobody else waits for
aborts it.
- Rapid scrolling releases each cancelled carousel load at once, and
cancelled loads never reach UIKit.

The full mobile suite passes.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:57:11 -08:00
klopez4212
93b8b8febc Standardize mobile navigation edges (#8895)
Standardizes mobile navigation headers: Android uses Home’s shared
Flutter blur; iOS uses native soft edges, with graduated material
retained during large-title collapse and the iOS 26 fallback preserved.

Preserves Home’s upward title motion with a soft boundary and keeps the
avatar crop title visible.

Validation: 4,112 Flutter tests and 111 native tests passed after
merging main. Rendered partial-scroll regression verified on the iOS 27
simulator; removing either review fix fails its regression assertion.
The earlier preview was installed on a physical iPhone with the existing
profile.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-01 21:41:40 +00:00
klopez4212
634f48a8bd Mobile: open Solo chats with Bestie and workspace controls (#8884)
Mobile New chat now opens directly with the synced Bestie selected. The
first send creates and names the chat; leaving an empty draft creates
nothing. New and existing Solo chats can select or clear a workspace
from the header popover, with a checkmark for the active choice and a No
Workspace option. Workspace and agent controls have separate iOS glass
surfaces, Bestie stays at the far right, and Back uses a chevron. The
agent sheet fits its list, with scrolling for long lists. Workspace
creation stays in the Home New menu.

The New menu groups Solo above Team. Android expands across the full
control row; iOS keeps its native menu while Search and Bestie merge
into New through Liquid Glass, with synchronized icon motion and the
residual glass fading away. Empty workspaces use a compact, muted
message.

Validation: 4,055 mobile tests passed (34 skipped), static analysis
passed, and native menu checks passed. Earlier physical-device previews
and the iPhone New-menu animation were visually approved before this
consolidation.

This PR consolidates the controls previously developed in #8891 into the
existing draft/first-send implementation. The New menu animations,
attachment handling, and durable retries remain in this PR. The combined
controls have widget interaction coverage; the combined build has not
yet had a new physical-device walkthrough.

Current combined controls below are Android Flutter widget renders with
fixture data, not device screenshots. Images are GitHub attachments
only.

| New conversation | Workspace popover | Compact agent sheet |
| --- | --- | --- |
| ![New chat
composer](https://github.com/user-attachments/assets/f9ea2a16-342e-4cea-a436-0777ff5e37aa)
| ![Checked workspace
popover](https://github.com/user-attachments/assets/80eb7a71-e424-4188-86d7-eb6292b148bf)
| ![Content-sized agent
sheet](https://github.com/user-attachments/assets/be608383-0188-4339-b56b-924085137452)
|

Empty workspace widget preview (unchanged):

![Compact empty workspace
message](https://github.com/user-attachments/assets/387c0729-dea3-4250-bb54-afc4e61b54a7)

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-01 21:40:30 +00:00
Tom Brow
90772a43e9 fix(mobile): keep avatars and images loading on slow links (#8879)
On a slow link, avatars in a channel could fail to appear entirely. Two
causes:

- **Duplicate downloads.** Each decode size is its own image-cache
entry, so an avatar shown at two sizes (a message row and its reply
summary) started two downloads of the same bytes, which then competed
for bandwidth. The in-flight download is now shared per URL and auth
service.
- **Full-size avatars.** Avatars a few dozen points wide downloaded the
original upload, which can be megabytes. The relay generates a JPEG
thumbnail of at most 320px for every uploaded image, and desktop already
loads it for small avatars. Mobile now does the same for any image
format when an avatar is at most 240 physical pixels on each side. About
88% of relay-hosted avatars on the internal relay are PNG, with a median
size of 337 KB. As on desktop, the thumbnail is a still JPEG:
transparent areas render black and animation is lost. Rendered side by
side, mobile and desktop look the same. If the relay returns 404 for a
thumbnail (older or externally populated media), the avatar loads the
original instead, as the notification extension's avatar loader already
does.

### Before merge

- [x] Kenny Lopez reviews the visible changes ([approved in Buzz
#buzz-mobile](buzz://message?channel=9f18f3c6-b5e5-453d-b36d-65487913be0b&id=c8b01eaa3760e44c3b6583f2e02bec576d78679adfb364e5fe718cc0926bdc2d)).

### Validation

On an iPhone SE (iOS 27.0.1) running a profile build, relay traffic went
through a Mac-side proxy shaped to 200ms one-way latency, 240 kbps down
and 200 kbps up. I opened a busy channel and logged each relay image
fetch. Every fetch is aborted 30s after it starts, which
[#8881](https://github.com/block/buzz/pull/8881) changes separately;
with thumbnails the avatars below no longer come close to that limit.

| Avatar | Before | After |
| --- | --- | --- |
| A | 1.45 MB, fetched twice; both aborted at 30s, never shown | 14.5 KB
thumbnail, 3.6s |
| B | aborted at 30s, never shown | 14.5 KB thumbnail, 4.1s |
| C | 125 KB, fetched twice, 26s each | 21.5 KB thumbnail, 4.3s |
| D | 46 KB, 11.1s | 24.7 KB thumbnail, 4.6s |

Added tests (each fails without its fix):

- One URL resolved at two decode sizes downloads once.
- Small relay avatars request the thumbnail and large ones the original.
- A thumbnail 404 falls back to the original, and other thumbnail
failures show the avatar fallback.
- The thumbnail URL is built only for relay-hosted blobs
(`/media/<sha256>` with an optional extension), the same pattern
desktop's `mediaUrl` uses.

The full mobile suite passes.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 07:37:11 -08:00
Tom Brow
543ecf2b1f fix(mobile): list the same owned agents as desktop (#8857)
## Summary

Mobile's Agents modal listed agents that desktop does not show. Most
were agents created in the earlier Buzz app (this repository's
`desktop/`), which the owner had not imported into Buzz 1.0
([block/buzz-app](https://github.com/block/buzz-app)). @ completion
offered them as well.

Desktop lists an owned agent only when the owner's newest kind:30177
record for that agent has a `pluginId/typeId` `runtime` tag and a name.
Records written by the earlier app have no `runtime` tag. Importing an
agent on desktop republishes its record with one
([`listingsOf`](662e91800a/src/features/agents/service.ts (L1413-L1460))).
Mobile now applies the same rule:

- **Agents modal:** shows listed owned agents, plus the account's remote
agents that have a verified owner profile here (so one stays listed
while a deletion that already removed its registration is retried).
Owned kind:10100 directory entries no longer add agents on their own.
- **@ completion:** channel members are always offered. Listed owned
agents are offered in streams and forums, where they can be added, as on
desktop. An owned agent that isn't listed is no longer offered from the
directory or from search, because mobile can't import it. The check that
runs before sending a restored draft follows the same rule.

The owned-agent directory moved to `lib/shared/agents` so the channels
feature can use it.

Shared agents owned by other people still come from the kind:10100
directory, which desktop doesn't use for @. That difference is unchanged
here.

### Related issue

None found.

### Validation

iPhone 17 simulator, debug build, against a local relay seeded with one
owner and two owned agents. Both agents have a NIP-OA profile. "Current
Agent" has a desktop runtime tag. "Old App Agent" has a registration
written the way the earlier app writes it, with no runtime tag. The
before captures are `origin/main` with the same data.

| | Before | After |
| --- | --- | --- |
| Agents modal | ![Agents modal before: Current Agent and Old App
Agent](https://github.com/user-attachments/assets/9089a820-9b30-4372-a133-7a0aa00fb6d8)
| ![Agents modal after: only Current
Agent](https://github.com/user-attachments/assets/f0585e0c-1bbf-4605-910f-90f6f9dc033a)
|
| `@` in a stream | ![Mention list before: only the
member](https://github.com/user-attachments/assets/aa88a5d6-5dec-41e9-aebd-07a62d06e48e)
| ![Mention list after: the member and Current
Agent](https://github.com/user-attachments/assets/d4de914d-23a4-4437-83b7-4b23dd641aac)
|
| `@Old` in a stream | ![Search before: Old App Agent
offered](https://github.com/user-attachments/assets/e038eb64-1fed-40f8-960b-18d6fad0a86f)
| ![Search after: nothing
offered](https://github.com/user-attachments/assets/47246f69-e939-402d-acfc-d0c764df967c)
|

Added tests:
- Directory loading: remote, desktop, earlier-app, later-imported, and
reverted agents, plus agents owned by someone else; only the newest
record counts. A table test covers the runtime and name rule.
- Agents modal widget test: an earlier-app agent is not shown.
- Mention candidates: only listed owned agents are offered outside the
channel, including from search.
- Compose bar: a restored draft that mentions an unlisted owned agent
outside the channel is rejected before sending.

I disabled the runtime check and the unlisted-agent filter, and the four
new guarding tests failed. The full mobile suite passes.

Human test: not applicable for this mobile change under the author's
standing exception.

buzz-review-completed

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
2026-11-01 07:36:37 -08:00
Tom Brow
b41caeb510 fix(mobile): time out relay connects that never finish the handshake (#8878)
When a phone's network silently stops working (crowded event Wi-Fi, a
weak cell signal), Buzz Mobile can stay offline for minutes. In a device
test, the app took 30s to notice the dead connection, then hid the
channel's messages behind a loading skeleton. It tried to reconnect
once, and that attempt hung for 5 minutes (303s). It never retried,
because it only retries after an attempt fails.

This PR gives every reconnect attempt a 10s limit. When an attempt runs
out of time, the app closes it and tries again with its normal backoff.

### What was wrong

Opening the relay connection has two code paths. The one used with
hosted relay auth already had a 10s limit. The other path had none, so a
stuck attempt waited for the operating system to give up, which takes
minutes. Both paths now use the same limit. Each attempt also gets its
own HTTP client, so a timed-out attempt is actually closed rather than
left running in the background.

### Validation

I tested on an iPhone SE (iOS 27.0.1) running a profile build, with its
relay traffic routed through a Mac proxy that could silently drop
everything.

- **Before:** during a 5.6 minute outage, the app made one reconnect
attempt, which hung for 303s.
- **After:** during a 2.5 minute outage, each attempt gave up after 10s
and the app kept retrying. It reconnected 36s after the network came
back, a delay mostly set by the 30s cap on time between retries.

Added tests: `relay_socket_liveness_test` now covers an attempt that
never completes, with and without hosted relay auth headers. It checks
that the attempt fails and that the abandoned connection is closed. The
test fails without the fix.

Two existing tests were opening real relay connections by accident or
design:

- `deep_link_dispatcher_test` now uses a fake channel list, like its
neighboring tests.
- `remote_agents_test` talks to a real local relay on purpose, so it now
uses a real HTTP client instead of the test framework's mock.

The full mobile test suite passes.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
2026-11-01 07:32:28 -08:00
Tom Brow
5f98a1fa04 mobile: quiet agent messages and caught-up channel highlights (#8876)
Mobile bolded a channel whenever any reply in one of your threads, or
any mention, was unread. Catching up on the channel itself never cleared
those, so a channel could stay bold with nothing new in its timeline.
Agent progress messages (sent with the `agents` audience) also counted,
so busy agent threads kept channels lit.

This brings the mobile channel list in line with the desktop sidebar
rules in buzz-app's `docs/unread.md`:

- **Quiet agent messages** (`["audience","agents"]`) never count as
unread or earn attention, even when they mention you. They're dropped
where mobile records observed unread events, so the channel list, Solo
chats and app badges all agree, and Activity drops them too (paging past
a mention window they filled). If the bounded catch-up window comes back
full of quiet messages, its oldest one still counts as plain backlog, so
an older unread message hidden behind them keeps the channel unread.
- **Bold means ordinary backlog.** A channel's label bolds only for
unread top-level messages (what channel catch-up reads) or a manual
unread mark. A manual mark doesn't add a dot, but it keeps the dot of
any real attention.
- **A dot means attention.** Unread replies in your threads, mentions
and broadcasts show a small accent dot (the Activity row's unread dot)
without bolding. Opening the thread clears it, as does long-press → Mark
read, which the menu now offers for dot-only channels.
- Unread DMs bold and show the dot, as on desktop. Solo chats keep their
current rule.

Not included: desktop's popover listing the unread threads behind the
dot.

### Before merge

- [x] Kenny Lopez reviews the visible changes ([reviewed in Buzz
#buzz-mobile](buzz://message?channel=9f18f3c6-b5e5-453d-b36d-65487913be0b&id=c8b01eaa3760e44c3b6583f2e02bec576d78679adfb364e5fe718cc0926bdc2d);
his overlap question about #8883 is answered: the two are
complementary).

### Validation

- Added tests:
- A channel-list unit test covering backlog, reply, mention, DM, manual
unread (with and without attention) and Solo.
- Widget cases: a reply-only channel shows a dot, not bold, and clears
on the thread mark or on long-press → Mark Read. A mention after
catch-up is a dot. Backlog plus a mention gives bold plus a dot.
- Provider tests: quiet agent messages, both from catch-up and live, are
never recorded (fails without the fix). A full catch-up window of quiet
messages still counts (fails without the fix). Quiet mentions never
become Activity or Inbox items, even when 50 of them fill the mention
window in front of a real one (fails without the fix).
- Full mobile suite (`just mobile-test`) and `flutter analyze` pass
locally.
- Simulator recording against a local relay (iPhone 18 Pro, iOS 27). Sam
replies in the sim user's thread, which shows a dot without bold. Sam
then posts top-level, which bolds the label. Opening the channel reads
the backlog, so it returns to regular with the dot. Long-press then
offers Mark Read, which cleared the dot. That last step finished just
after the clip ends.
- Not exercised live: the agents-audience exemption, because the CLI
can't set that tag. The provider test covers it.


https://github.com/user-attachments/assets/55f74377-d7f3-4b31-a0c8-b456f4e05770

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 07:31:19 -08:00
Tom Brow
83ec9c0e2b fix(mobile): show hosted community names consistently (#8872)
When a hosted relay has no metadata name, the hosted add paths save its
host (`remote-agent-pilot.communities.buzz.xyz`) as the community name.
Onboarding shortens that fallback to `remote-agent-pilot`. After
joining, though, the community switcher, the remove dialog, the theme
preview and the identity export showed the full host, so the same
community appeared under two names.

This change shortens the name in the model instead of at each call site.
`Community` and `AddCommunityDeepLink` keep the saved value private, and
their public `name` getter returns the display form, using the existing
`HostedEnvironment.communityDisplayName` rule. Existing and future
readers get the short name without opting in. Serialization still writes
the saved value, so stored data is unchanged and no migration is needed.
Custom names and non-hosted hosts pass through unchanged, as before.
Community identity is unaffected because it uses `id` and `relayUrl`,
never the name.

Beyond the captures below, the same getter shortens the name in the iOS
push notification subtitle, onboarding's "Adding …" progress text, the
"You joined …" invite sheet, and the VoiceOver labels for removing and
loading a community.

### Before and after

| | Before (`main`) | After |
|---|---|---|
| Community switcher |
![01a-before-switcher](https://raw.githubusercontent.com/block/buzz/62847164da6a77ae3f60295097d4b4ac8c4f630c/pr-8872--01a-before-switcher.png)
|
![01b-after-switcher](https://raw.githubusercontent.com/block/buzz/62847164da6a77ae3f60295097d4b4ac8c4f630c/pr-8872--01b-after-switcher.png)
|
| Remove dialog |
![02a-before-remove](https://raw.githubusercontent.com/block/buzz/62847164da6a77ae3f60295097d4b4ac8c4f630c/pr-8872--02a-before-remove.png)
|
![02b-after-remove](https://raw.githubusercontent.com/block/buzz/62847164da6a77ae3f60295097d4b4ac8c4f630c/pr-8872--02b-after-remove.png)
|
| Theme preview |
![03a-before-theme](https://raw.githubusercontent.com/block/buzz/62847164da6a77ae3f60295097d4b4ac8c4f630c/pr-8872--03a-before-theme.png)
|
![03b-after-theme](https://raw.githubusercontent.com/block/buzz/62847164da6a77ae3f60295097d4b4ac8c4f630c/pr-8872--03b-after-theme.png)
|
| Identity export |
![04a-before-nsec](https://raw.githubusercontent.com/block/buzz/62847164da6a77ae3f60295097d4b4ac8c4f630c/pr-8872--04a-before-nsec.png)
|
![04b-after-nsec](https://raw.githubusercontent.com/block/buzz/62847164da6a77ae3f60295097d4b4ac8c4f630c/pr-8872--04b-after-nsec.png)
|

### Before merge

- [x] Kenny Lopez reviews the visible changes ([approved in Buzz
#buzz-mobile](buzz://message?channel=9f18f3c6-b5e5-453d-b36d-65487913be0b&id=c8b01eaa3760e44c3b6583f2e02bec576d78679adfb364e5fe718cc0926bdc2d)).

### Validation

- Captures come from iPhone 17 simulators (iOS 27) running `main` and
this branch. Both builds used the same scratch-only seed with two
communities whose saved names are their hosted hosts. The seed is not
part of this PR. The relays were unreachable from the simulator, so the
screens show loading placeholders.
- Changed tests: the community switcher journey now uses a hosted
host-name fallback for one community. It asserts the grid label, the
remove tooltip and the dialog text show the short name, and it fails
when the getter returns the saved value. The onboarding label table now
asserts the model's `name` itself is shortened. The hosted-flow fixture
records relay hosts instead of names, and the auth-domain tests look up
communities by relay URL.
- The full mobile test suite and `flutter analyze` pass. The CI step
that runs with configured authentication domains also passes locally
(113 tests).
- Not captured: the push notification subtitle, onboarding's "Adding …"
text and the invite sheet. Reaching them needs hosted sign-in or push,
which the simulator doesn't support.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 07:30:09 -08:00
Tom Brow
e71fbe23df fix(mobile): keep loaded timeline visible while reconnecting (#8864)
Returning to an already-open channel after a few minutes in the
background could replace its messages with the loading skeleton.

The relay session drops its socket five seconds into the background, so
nearly every return to the app reconnects. When that reconnect took more
than two seconds, the channel page covered the loaded timeline with the
connection skeleton until the relay came back. The messages provider
already keeps the cached timeline through a reconnect, so the overlay
was the only thing hiding it.

Now a loaded timeline stays visible while the relay reconnects. The
skeleton still shows when nothing has loaded yet, and forums keep their
existing behavior (skeleton only before any posts load).

To keep a reconnect indicator, the iOS title capsule now shows a thin
bar in the chat color (the Appearance accent) along its bottom edge
while a page with loaded content is not current. For a channel timeline
that covers both the reconnect and the catch-up after it (resubscribing
and fetching the newest window). The wait has no known length, so the
bar sweeps in, pulses until the channel is current, then fades. Reduce
Motion shows a static bar. Forums show it only while reconnecting,
because their posts provider doesn't refetch after a reconnect. The
native navigation view owns the bar, so it survives title rebuilds.
Android has no title capsule and is unchanged.

### Demo

iPhone 18 Pro simulator on a local relay: background the app for more
than 5s with the relay down, return, then let the relay come back. About
8s of relay startup is cut from the middle.


https://github.com/user-attachments/assets/e88d0191-766b-4054-bc00-99ed306cd0cc

### Before merge

- [x] Kenny Lopez reviews the visible changes ([approved in Buzz
#buzz-mobile](buzz://message?channel=9f18f3c6-b5e5-453d-b36d-65487913be0b&id=c8b01eaa3760e44c3b6583f2e02bec576d78679adfb364e5fe718cc0926bdc2d)).

### Validation

- Updated tests: the reconnect cases in `channel_detail_page_test.dart`
and `loading_review_tests.dart` now assert that loaded messages and
cached media stay visible and in place during a reconnect.
- Added test: the iOS navigation payload sets `titleRefreshing` while a
loaded timeline reconnects and clears it on connect.
- Added test: `isCatchingUp` holds from reconnect until the newest
window merges, and is already false when that state publishes.
- Added test: a failed catch-up after reconnect still notifies listeners
once, with `isCatchingUp` false, so the bar clears.
- `flutter analyze` clean, iOS simulator build succeeds, and the full
mobile suite passes on the final head.
- Checked visually on an iOS 27 simulator (demo above): loaded messages
stay visible on resume, and the bar pulses from foreground until the
reconnect's catch-up finishes, then fades.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 07:30:05 -08:00
Tom Brow
db4478e8d2 feat(mobile): organization sign-in for NIP-FI relays (#8700)
Block is turning on NIP-FI enforcement on the Block relay. Desktop gets
organization sign-in in block/buzz-app#1424. Mobile can already attach
NIP-FI assertions, but only for Builderlab-hosted communities, through
the build's single Builderlab account. The dogfood build needs that
account for public hosted communities, so it can't simply be pointed at
the Block tenant.

This adds a separate **organization realm**, configured at build time:

- `BUZZ_ORGANIZATION_URL`: the tenant's HTTPS origin (for example
`https://block.builderlab.xyz`).
- `BUZZ_ORGANIZATION_RELAYS`: the exact relay hosts that trust that
tenant.

The two must be set together. The app refuses to start if they're
malformed, overlap a hosted community, or reuse the hosted origin.
Without them, nothing changes.

For a configured relay:

- The app keeps its own organization session and signs in through the
browser at the tenant's existing login (`/api/goose/v1/auth/login`).
Block SSO happens there.
- It requests assertions from the same `/v1/buzz/identity/assertions`
route that hosted communities use, and attaches them to the relay
socket, HTTP, media, huddle and remote-agent requests.
- Unlike the hosted path, it doesn't require an identity backup before
the first binding. As on desktop, the tenant binds the member's existing
key on first issuance. A key already bound to a different one is refused
before any request is sent.
- When signed out, it connects without an assertion and lets the relay
decide. A bottom banner offers **Sign in**. After sign-in, the relay
reconnects with a fresh assertion. Hosted communities keep their
existing banner and flow.

The tenant accepts only the registered
`xyz.block.buzz.mobile:/auth/callback` return URL, so both realms share
it. Each `BrowserAuth` owner leaves callbacks whose `state` belongs to
another owner's attempt to that owner.

### Before merge

- [ ] Kenny Lopez reviews the visible changes.

### Validation

Added tests (`organization_relay_auth_test.dart`):

- Configuration parsing and exact host matching.
- An assertion is issued for the exact relay and key, then cached.
- A different bound key is refused.
- Signed out connects without an assertion.
- Other relays never receive the assertion.
- The shared callback is routed between the hosted and organization
owners.

Full `mobile-check` and `mobile-test` pass. RunnerTests pass locally
except `testNativeNavigationTitleExpandsAgainAtTop`, which this PR
doesn't touch.

Added tests (`organization_sign_in_test.dart`,
`relay_auth_isolation_test.dart`, `builderlab_account_test.dart`), each
failing without its fix:

- Concurrent account restores at cold start share one adopted account.
- An exchange from a cancelled attempt never changes a newer sign-in.
- Storage failures while clearing the intent or cancelling leave the
busy state.
- An unreadable saved organization session is replaced by signing in
again.
- A stalled organization issuer never delays a hosted relay's assertion.
This test needs `BUZZ_AUTH_DOMAINS` and skips without it. CI's
configured-auth-domains step doesn't list it yet, because that workflow
change needs a token with `workflow` scope.

Added tests also cover a restore after an offline start (the banner
clears once a relay retry restores the session) and the iOS bridge
allowlist (`/auth/native` and tenant login on Builderlab subdomains;
other hosts, paths, ports, userinfo and fragments refused).

Simulator (iPhone 17, iOS 27) with the dogfood defines against the
production Block relay `buzz.block.builderlab.xyz`, on a WARP Mac:

- The app starts with the organization configuration and connects to the
relay while signed out, showing the organization **Sign in** banner.
- **Sign in** opens the system sign-in session for
`block.builderlab.xyz`, and the banner shows **Finishing organization
sign-in…**. Cancelling returns to **Sign in** with no error. Before the
bridge fix, no browser opened and the banner reported that sign-in did
not finish.
- The bottom banners (organization sign-in, hosted reconnect, and the
sign-in recovery row) now sit above Home's floating Search/New bar,
which used to cover **Sign in**. A journey test checks the banner clears
the bar, and fails without the fix.

![Home with the organization sign-in banner above the floating
bar](https://github.com/user-attachments/assets/c6ab74f4-3d88-4fe0-ae16-042a548056fe)
- The simulator run stopped at the system consent prompt (SSO does not
work in the simulator). The app calls were driven through the VM service
(the same `signIn()`/`cancel()` the banner buttons call) because the
simulator could not be tapped.

Device (iPhone 12 mini) against bb-block staging in enforce mode, where
every probe without an assertion gets 401:

- Signed out, the organization banner shows and the app stays
responsive. Cancel at the consent prompt returns to **Sign in**, and
retry works.
- Block SSO through Okta completes, and the relay admits the reconnect
with the assertion.
- After relaunch the app connects, loads channel history, and shows no
banner for over 60 seconds.
- Before the restore fix, 2 of 4 cold starts showed "cannot use this
community with this key" although the session was valid. With it, 8 of 8
cold starts connected.

Known limitation: adding an enforcing organization relay by community
link is tracked in #8791.

The dogfood build enables this in squareup/buzz-releases
(`tomb/dogfood-organization-sign-in`). App Store builds stay unchanged.

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 07:23:12 -08:00
Tom Brow
acf56fff6f fix(mobile): use the desktop agent squircle for every agent avatar (#8873)
On mobile, the Create agent form previewed emoji and image avatars as
circles. Once the agent was created, its avatar showed as a rounded
square, and other surfaces used yet other radii. None of them matched
the agent squircle desktop uses.

This PR makes `lib/shared/widgets/avatar_shape.dart` the single source
of avatar silhouettes. Agents get desktop's squircle (traced from
buzz-app's `avatar-squircle.svg`), and people keep circles.
`avatarShape(isAgent:)`, `avatarPath()` and `AvatarClip` are the only
ways to get either shape, and the squircle border class is private.

Every agent avatar surface now goes through that bottleneck:

- Create agent form and avatar picker previews (previously circles for
emoji and photo avatars, and unclipped for catalog characters;
`PresetAgentAvatar` now owns the clip)
- The avatar picker's camera cutout (`ImageAvatarCapture` with `isAgent:
true`); profile editing keeps the circle
- Agents grid card portrait (previously a 22pt rounded rect)
- `AvatarImage` (message authors, DM rows, member lists), the profile
sheet, `SmallAvatar`, and the native avatar PNG used by iOS Add members
(previously 30% rounded rects)
- Huddle speaking halo, preparing placeholder, and spotlight frame
- The iOS Add members fallback tile (Swift), shown while an avatar
loads, now draws the same squircle instead of a 30% rounded rect

To catch future drift, `avatar_shape_test.dart` adds a source guard. It
fails when code under `lib/` picks an avatar shape inline from agent
identity (for example `isAgent ? BoxShape.rectangle` or `if (!isAgent)
return ClipOval`), hand-rolls the old 30%/60% agent radius, or uses a
person shape (`ClipOval`, `CircleAvatar`, `BoxShape.circle`) under
`lib/features/agents/`. Against main it flags 8 of the 10 old sites. It
is best effort: the native PNG's inline ternary and the card's RRect
didn't match those patterns and are fixed directly here.

The Create agent form also gets 8pt above **Choose avatar**, so the
button and everything below it move down from the preview, as Kenny
requested.

### Before / after

Captured on the iPhone SE (iOS 27) with a release build against a hosted
Builderlab community, using a throwaway agent with an emoji avatar. In
each pair, before (main) is on the left and after is on the right.

**Create agent form** (also shows the 8pt spacing)

![create](https://raw.githubusercontent.com/block/buzz/f1f6a019c69e245146db761a6344524969275d04/pr-8873--create.png)

**Avatar picker**

![picker](https://raw.githubusercontent.com/block/buzz/f1f6a019c69e245146db761a6344524969275d04/pr-8873--picker.png)

**Agents card**

![card](https://raw.githubusercontent.com/block/buzz/f1f6a019c69e245146db761a6344524969275d04/pr-8873--card.png)

**Agent profile sheet**

![profile](https://raw.githubusercontent.com/block/buzz/f1f6a019c69e245146db761a6344524969275d04/pr-8873--profile.png)

**Message author avatar** (`AvatarImage`)

![dm-message](https://raw.githubusercontent.com/block/buzz/f1f6a019c69e245146db761a6344524969275d04/pr-8873--dm-message.png)

**DM list row** (`AvatarImage`)

![dm-list](https://raw.githubusercontent.com/block/buzz/f1f6a019c69e245146db761a6344524969275d04/pr-8873--dm-list.png)

**Add members** (native iOS list, `nativeAvatarImage`)

![add-members](https://raw.githubusercontent.com/block/buzz/f1f6a019c69e245146db761a6344524969275d04/pr-8873--add-members.png)

Not captured on device: `SmallAvatar` (thread reply and search avatars)
and the Huddle halo, placeholder, and spotlight, which need a live agent
thread or huddle. Widget tests cover these.

### Validation

- Device: the before/after captures above. They predate the catalog
preview clip
([355ebca](355ebca27e)),
which only trims the transparent character art at the squircle corners.
The test agent was deleted afterwards.
- Added tests: `avatar_shape_test.dart` pins the squircle geometry to
desktop's mask and runs the source guard. The avatar picker journey now
checks that the catalog preview, emoji preview, and camera cutout use
the agent shape. Existing `AvatarImage` and channel-detail
Huddle/thread-avatar assertions were updated to the new shapes.
- Full mobile format, analyze, and test suite pass locally. The iOS
release build compiles with the Swift change.
- The device build predates a merge of main that brought in
[#8773](https://github.com/block/buzz/pull/8773). That merge touched the
card, create page, and profile sheet without changing any avatar shapes.

Human pre-readiness app testing is not applicable under Tom's standing
exception for Buzz mobile changes. An independent agent review ran, and
its optional findings were addressed in 00b1ec6.

buzz-review-completed

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:55:47 -07:00
Wes
f59b326568 Fix mobile onboarding policy loading, browser cancellation and pairing feedback (#8874)
Mobile onboarding could reject valid large legal documents, stay busy
after closing Android browser sign-in, and hide pairing-import errors
behind the community-link form. This change bounds join-policy responses
at 4 MiB while retaining 64 KiB for other responses, handles Android
browser cancellation through the native lifecycle/channel, keeps Cancel
sign-in usable, and shows friendly pairing errors on the landing screen
with a live-region announcement. Identity mismatch checks remain intact.

The browser-cancellation implementation incorporates the candidate from
#8847 (`cb241b3f1`); coordinate landing to avoid duplicate work. This PR
consolidates the three fixes exercised together during Android
onboarding acceptance.

## Validation

- Pixel 9 / Android 17, production hosted services, isolated debug
installs. Human acceptance plus native UI, saved public-identity and
restart checks as noted below. No iOS acceptance in this session.
- Latest build and invite-first acceptance: base
`a12ad1cdd29da8b2923eca5408e963a2659777ab` plus these patches.
- Focused tests on that base plus patches: **88 passed** across
hosted_journeys_test, hosted_admission_test, browser_auth_test,
pairing_hosted_handoff_test and pairing_provider_test, with
`BUZZ_AUTH_DOMAINS=communities.buzz.xyz`.
- Earlier baseline: 81 focused cases passed plus one
staging-configuration case passed separately (82 distinct cases across
seven onboarding/recovery files); these are historical, not current-head
certification.
- Earlier policy change: 41 focused tests, changed-file analysis and
isolated mutation check passed on the earlier base. Earlier browser
candidate native instrumentation/mutation evidence was not rerun on this
combined revision.
- Android debug APK built and installed successfully. Mandatory
commit/push hooks passed at `6442e5ff9`: analysis clean; mobile tests
4,047 passed / 34 skipped; additional package runs 4 and 27 passed;
file-size checks passed. These broad tests ran through the required push
hook, not a manually invoked suite. CI remains pending.

## Live testing report — October 31

| Journey | Result and evidence boundary |
|---|---|
| Fresh email signup / identity / hosted invite | Reproduced policy
response exceeding 64 KiB; after fix, admission and restart passed.
Profile/message visible locally; cross-account message receipt remains
unproven. Tested at `cfcaef4e9f` plus policy patch. |
| Browser close → retry → login → existing identity recovery → restart |
Passed at `d14f33a170` plus patches; recovered the expected public
identity and community. |
| Pairing a different identity into an existing install | Rejected
without replacing the original key. Initially hidden error became
visible with friendly copy; Back and restart preserved the community at
`b80217de3` plus patches. |
| Fresh pairing → wait for desktop approval → sign-in → community →
restart | Passed at `20742a236` plus patches; human confirmed approval
gate, saved public identity matched. |
| First-time desktop cancellation → welcome → fresh QR retry → community
→ restart | Passed at `20742a236` plus patches. Cancellation error
remained raw `user_denied`. |
| Android system Back while awaiting pairing approval | Human-confirmed
in already-onboarded Add Community; first-time phone-side Back variation
remains untested. |
| Wrong pairing code → rejection → correction → completion |
Human-confirmed at `a2540fbe3` plus patches. Exact error and
same-session versus fresh-QR retry were not independently captured. |
| Interrupt account onboarding at community picker → process restart →
recovery → admission | Passed at `a2540fbe3` plus patches. Restart
opened Add a community; Find my communities restored choices without
another login. Human completed admission. No destination had yet been
selected. |
| Invite-first into fresh signed-out app → intended community → restart
| Passed at `a12ad1cdd` plus patches; saved identity/destination
matched. Existing member and existing browser session. Initial Android
VIEW intent explicitly targeted the test package; ordinary HTTPS default
app-link resolution and new-member admission remain untested. |
| Malformed community input → error → Back → restart | Passed for `not a
community`; original community preserved. `not-a-community` is a valid
single-label hostname and was not counted as malformed-input evidence. |

## Remaining gaps and findings

Not release certification. Pairing landing-error regression/mutation
evidence is now recorded below. Current combined-revision native
instrumentation has not been rerun; its limited scope is described
below.

Deferred behavior, not implemented here:
- Wrong browser account after fresh pairing is correctly rejected, but
Retry reuses that app account session; in-app account switching must
preserve the transferred identity and destination. The live happy path
used a manual browser-account switch and fresh install.
- Pairing wait has no visible Cancel; tested system Back works.
- Desktop cancellation exposes raw error wording.
- Interrupted community selection requires an extra Find my communities
step.

Master checklist remains partial: seven categories exercised this
session, signup/community creation has historical coverage, seven
categories unexercised. New-member link-first admission, deeper
interruption/server-commit recovery, expiry/revocation/network failures,
key import, organization/self-hosted paths, phone-to-desktop transfer,
eligibility gates, accessibility/app-link variants and iOS require
separate coverage. Channel-discovery work is separate (#8869).

## Review checkpoint

[Carl’s
review](https://github.com/block/buzz/pull/8874#pullrequestreview-5724095754)
found no code blockers at `6442e5ff9`. Carl also reviewed test-only
follow-up `d43958653` and found no issues, accepting both validation
gaps as closed. A subsequent review identified stale callback → resume
cancelling a newer Android login. A narrow native fix and regression
validation are in progress; prior readiness attestation is withdrawn
pending this behavior change.

- **Android stack check passed (Pixel 9, Android 17):** after fresh
account sign-in, the human stopped at the community picker without
closing/restarting. `dumpsys activity activities` showed exactly one
MainActivity in Invite First task 567, with no CustomTab in that task or
the full component list. ChromeTabbedActivity remained in a separate
pre-existing browser task. This rules out a lingering duplicate activity
in this run; it does not independently trace `onNewIntent` or establish
all Android/browser variants. Binary production sources match
`6442e5ff9`.
- **Pairing regression/mutation passed:** extended the existing QR
journey using the real encrypted NIP-AB import and hosted coordinator.
Asserts visible friendly mismatch feedback, live-region wrapper,
unchanged stored community/account identity, and Back to home. Reused
the existing socket fake. Hosted journey + pairing provider files: 70
passed with configured auth domain; opt-out hosted journey file: 3
passed / 21 skipped; changed-file analysis clean. Isolated clone:
control passed, hiding the landing error failed the visible text
assertion, restoration passed. No production source change in this
follow-up.
- **Duplicate landing:** #8847 is superseded by this combined PR; its
Android slice is included here. No merge performed.

BrowserAuthPluginTest manually drives plugin hooks using an unmanaged
Activity and fake browser launcher. It proves the plugin state machine
only, not MainActivity wiring, real lifecycle order, process death, or
duplicate app_links callback delivery. Current CI does not run Android
instrumentation. Android ignores the debug ephemeral-login flag and
shares browser cookies.

Readiness checklist: agent review completed on production changes and
test follow-up; agent exercised the changed behavior; human completed
the documented Android journeys and final sign-in check.



## Stale callback follow-up (`b7aaaa788`)

Addresses [the later Android lifecycle
review](https://github.com/block/buzz/pull/8874#pullrequestreview-5724197312):
a recognized callback now clears browser-dismissal tracking before
rejecting a mismatched state. A stale callback cannot complete or cancel
the newer login. The existing native journey now includes the
intervening resume, successful current callback, and genuine browser
dismissal after a stale callback. Only two native files changed; policy
and pairing fixes are unchanged.

Validation: 88 focused Dart tests passed. Android app and test APK build
passed. On Pixel 9, the native plugin journey passed; in an isolated
source/Git/build clone, removing the reset failed at the newer attempt's
expected-zero-replies assertion; exact restoration passed. This proves
the plugin state machine on Android, not a real late browser callback
through OS lifecycle delivery. Carl reviewed `b7aaaa788` and found the
fix correct and minimal with no blockers. Human browser-close/retry
check remains pending; PR stays draft until that passes. Current-head CI
is also required for merge.

Accepted limitation: if a recognized stale callback arrives after the
current browser tab has already gone, automatic dismissal is suppressed
and the user may need the visible Cancel button. The callback cannot
complete sign-in without matching state; Dart validation remains
authoritative.

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
2026-10-31 12:43:01 -06:00
klopez4212
a12ad1cdd2 feat(mobile): refine agent profiles and model selection (#8773)
## Summary
- Simplify agent profiles with Message/Bestie actions, relay-authored
published instructions (unavailable without a config channel),
management details, public-key copying, and a separate final Delete
section.
- Sort the Agents directory by name and mark the selected Bestie with a
muted filled star.
- Place Model and Thinking together below Name. Use native iOS Liquid
Glass menus and preserve selection callbacks as controls become enabled.

Related: #8685.

## Validation
Full mobile checks passed. Regression coverage includes relay-author
filtering, unavailable instructions, metadata retry, resizing native
pickers, accurate deletion state, selection after loading, and Bestie
switching. Real iOS simulator taps verified both pickers; the user
confirmed the earlier iPhone build works. Review fixes were checked with
widget journeys and the native UIKit resize/selection probe.

<details>
<summary>Previews</summary>

Synthetic fixture renders:

| Profile | Create agent | Bestie card |
| --- | --- | --- |
| ![Profile with separate Delete
section](https://github.com/user-attachments/assets/4b6e7cf5-b152-43a3-99d9-7c1ee65d0c70)
| ![Model and Thinking
settings](https://github.com/user-attachments/assets/c81dc138-d5d3-43bb-873b-a64171155a7d)
| ![Muted Bestie
star](https://github.com/user-attachments/assets/5ce95e3b-2403-41c5-b6d6-52b8b60cc7d6)
|

Native iOS simulator interaction with a synthetic catalog:

![Both picker values updated after native menu
selections](https://github.com/user-attachments/assets/a3ee6d4d-91e0-4df6-b4cf-b66d5c1a2e87)

</details>

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-31 11:28:14 -06:00
Tom Brow
e3b1ef0d84 docs(agents): mark desktop/ as legacy, not a reference for mobile (#8868)
Agents working on the mobile app have been looking at `desktop/` for
implementation and design cues. `desktop/` will soon be superseded by
[block/buzz-app](https://github.com/block/buzz-app), while mobile is
current, so it should never serve as that reference.

- Root `AGENTS.md`: labels `desktop/` as legacy in the repo layout, adds
a callout at the top of the Desktop App section, adds the same guidance
to the Mobile App section, and drops "matches desktop" from the mobile
theme note. Mobile kind constants now sync with
`crates/buzz-core/src/kind.rs` (also in the `nostr_models.dart` comment)
instead of desktop.
- `VISION_MOBILE.md`: replaces the line that allowed the desktop
implementation to inform shared mobile rules, so the vision and agent
guidance agree. Written intent still takes priority.

### Validation

Documentation-only. Reviewed the diff and links. Codex review's one
finding (the vision conflict) is addressed.

buzz-review-completed

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

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:46:42 -07:00
klopez4212
d14f33a170 fix(mobile): keep toasts above native navigation and sheets (#8866)
## Summary
Keep mobile toasts above navigation and modal sheets with an app-level
overlay and a tiny transparent iOS composition boundary. Preserve Retry,
automatic dismissal, and taps on underlying controls.

### Related issue
No duplicate found in the toast issue/PR search.

### Testing
Full mobile tests and analysis pass; iOS simulator and device builds
succeed. Verified layering, Retry, dismissal, and underlying taps in a
native simulator preview; human confirmed on iPhone with their existing
profile/community. Broader repository checks are in progress.

Before / after (isolated native simulator preview using production
widgets):

| Before | After |
| --- | --- |
| ![Toast hidden by native
navigation](https://github.com/user-attachments/assets/981bf68f-dcea-4584-a071-94ecdad8f1a7)
| ![Toast above native
navigation](https://github.com/user-attachments/assets/d75a28c6-cfce-4eed-87a3-548d03c72559)
|

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

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-31 16:17:47 +00:00
klopez4212
2ca77747d1 Fix mobile Add Community loading and membership restoration (#8796)
## Summary
Open Add Community immediately from the post-login picker and return
directly on Back, without membership restoration or repeated discovery
during navigation. Completing Add restores the saved selection while
keeping the newly added community active; failures retain progress and
offer Retry.

Follow-up to #8729. Mobile app update only; no relay deployment
required.

## Testing
`just mobile-install mobile-check mobile-test` passed (4,025 standard
tests), plus 4,049 Builderlab-enabled tests on the merged revision.
Production-path journeys cover hosted and self-hosted completion,
partial restoration and Retry, persisted memberships, and the active
destination. Removing the completion hook makes the new journeys fail.
Independent agent review found no blockers. Native walkthrough and human
verification remain pending.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-31 16:01:15 +00:00
klopez4212
cfcaef4e9f Update mobile toast animations (#8855)
Updates mobile toast animations and styling.



| Sign in | Retry | Copied |
| --- | --- | --- |
| ![Reconnect to
Builderlab](https://github.com/user-attachments/assets/5298b507-df5b-49da-a021-03f337e997fa)
| ![Save error with
Retry](https://github.com/user-attachments/assets/492c8929-4f91-4342-943b-a72b96547992)
| ![Copied
confirmation](https://github.com/user-attachments/assets/864fd629-7414-415e-81ef-5f7d3d5333a4)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-31 07:50:00 -07:00
klopez4212
9c3ba83adb Simplify signed-in mobile community onboarding (#8852)
Replace the signed-in mobile welcome screen with **Add a community**: QR
scan first, then grey secondary actions for community links, finding
saved communities, and creation. Hide **Find my communities** when none
are known; refresh on entry/resume and every 30 seconds while active,
retaining results on transient failure.

Community switcher → Add keeps its two-action flow and uses the same
grey secondary link button.

Validation: Flutter analysis, full mobile suite (4,014 tests),
Builderlab-enabled full suite (4,036 tests), widget interactions, and
independent agent review. Draft pending native sign-in and human
testing. Snapshots render the production Flutter widgets; native
navigation/status bars are omitted.

| Communities available | No communities | Switcher → Add |
|---|---|---|
| ![Onboarding with
communities](https://github.com/user-attachments/assets/444b05c5-711f-41df-9c50-293ad9e13767)
| ![Onboarding without
communities](https://github.com/user-attachments/assets/db78a490-bd8e-4d78-97b4-a391b52290d8)
| ![Switcher Add with grey secondary
button](https://github.com/user-attachments/assets/de9e42d2-dd53-462b-98c7-da87223e5dd4)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-31 07:46:44 -07:00
Logan Johnson
547de61aa5 fix(mobile): show channel posts before a burst of join notices (#8798)
## Summary

When many people join a channel at once, the mobile app could open that
channel and show only a "… joined" line, with none of the channel's
posts. You had to scroll up before any messages appeared. With this
change, the posts always show first, and the join lines appear among
them in the right places.

The cause: the app loads the newest 50 timeline rows when a channel
opens, and join notices counted toward those 50. A burst of 50 or more
joins filled the whole first page. The app combines a run of joins into
one line only when it draws the screen, so the user saw a single "Guest
10 joined … and 48 others" line. The app loads older rows only after the
user scrolls.

This is the mobile port of the desktop app's fix,
https://github.com/block/buzz-app/pull/1983, which has already merged.
It needs no relay change.

- History pages no longer include join notices, so the first page is all
posts.
- The app reads join notices with a separate read, newest first, 500 per
read. It reads down to the oldest post loaded so far, and continues
after each older page loads. Like desktop, it stops after 2,000 notices.
- Each notice read also reads the reactions on those notices, and any
removals of those reactions. A page brings these back for its own rows,
but notices are no longer on the pages, so they need their own read.
These reads name the channel in `#h`, so a system channel, or an
unlisted channel opened by a non-member, still returns them (NIP-CL).
Deletions are also read without `#h`, because a deletion can be stored
with no channel. Each read names at most 500 target ids, so it stays
under the relay's 1 MiB request limit. A notice's reactions show even
when they sort below the oldest loaded post.
- Posts show as soon as their page loads. The notices follow and are
merged into the timeline, so they do not replace rows that arrived live
in the meantime.
- A notice that is older than the oldest loaded post waits until its
page loads. This prevents a join from showing at the top of the timeline
with a gap of missing posts below it.
- If a notice read fails, the posts still show. The next page load tries
the notices again; if a page loaded while the read was running, the read
runs once more right away.
- The same applies to every system notice (joins, topic changes, channel
created or archived), as they share one event kind.

### Related issue

AIDA-2328. Desktop fix (merged):
https://github.com/block/buzz-app/pull/1983.

### Testing

Before (left, older build) and after (right, this branch) on the Android
emulator, against a local relay. #general and #4things each have 4 posts
followed by 60 joins:


https://github.com/user-attachments/assets/53b80942-d8c3-4604-8b95-cc5ca7ab6946

![Before and after: the old build shows only a joins line; the fix shows
the 4 posts above the joins
line](https://github.com/user-attachments/assets/58e121b9-855c-4844-a73a-c71b54252db2)

- New `channel_messages_provider_test.dart` cases run against the real
channel provider, with only relay responses faked:
- A burst of 500+ joins does not hold posts back. If the first notice
read fails, the posts still show. A later page read retries the notices
and continues the descent.
- Notices in the same second as the oldest loaded post follow the post's
position within that second.
  - The descent stops at 2,000 notices.
- A notice keeps its reactions, and a removed reaction stays removed.
Reactions are read past the relay's 1,000-event page. The fake relay
applies the NIP-CL rule, so a read without `#h` finds no channel
reactions.
  - A page that loads while a notice read fails still gets its notices.
- `just mobile-check` and the full `just mobile-test` pass at
`7a38763af`.

Not yet tested on a physical device, or on iOS.

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

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 20:34:41 -04:00