Commit Graph

385 Commits

Author SHA1 Message Date
klopez4212
5c2ea2e507 feat(mobile): add full-screen profile setup with inline avatar editing (#8628)
Replace the iOS profile setup sheet with a full-screen name → avatar
flow. Require a display name, enable avatar Continue after Done, and let
Skip save the name without an avatar. After a failed save, Not now
continues without saving the draft.

Embed photo, emoji, and animated avatar editing with native Back/Done,
matching 220-point previews, a rounded-notch pencil edit badge, an entry
haptic, equal emoji-preview spacing, and 200 ms transitions that move
the avatar into its editor position without resizing. Apply appearance
changes live across setup and editing while preserving the draft. Keep
the camera exit rail stationary, use a solid shutter, and present photo
positioning full-screen with Back/Done. Preserve the main Flutter view
during presentation and hide underlying controls from VoiceOver while
editing.

Validation: Flutter analysis, 3,796 Flutter tests, and 106 native tests
passed. Built, installed, and launched the isolated iPhone preview;
inspected the native emoji/badge rendering. The local preview entry
point is excluded. Draft pending real login/join → profile-save
verification and owner verification of the updated device flow.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-27 22:47:35 +00:00
Wes
5a8102ee5b fix(mobile): preserve invite onboarding and offer identity setup (#8617)
## Summary

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

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

### Related issue

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

### Validation before the latest main integration

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


### Current review fixes and validation

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

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

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

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

### Staging testing and production configuration

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

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

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

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

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

Keep this PR draft while the listed validation gaps remain.

---------

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

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

### Behavior

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

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

### Validation

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

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

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: george <973be878edd6d4f79692a3827f20a6f7fe0ed0362f269ff6b6ba8867033d7d8e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:05:44 -07:00
Tom Brow
b929b2cf84 fix(mobile): replace a push enrollment the gateway rejected (#8629)
An iPhone could stop receiving push permanently after the gateway
rejected its enrollment, even after a reinstall.

Before sending an enrollment, iOS saves the exact signed request in the
Keychain and replays those bytes until the gateway answers, so a lost
response cannot orphan a committed installation. The saved request was
replaced only on `404`. When the gateway rejected it with `401
invalid_attestation` (or `400 invalid_request`), each later attempt
replayed the same rejected bytes for the saved request's whole lifetime
(up to 30 days). The Keychain survives reinstall, so reinstalling did
not help. We saw this on an internal device: in production, its gateway
requests failed with repeated `401`s and it never got an installation.

The gateway returns `401` before it looks up a committed installation,
so replaying the same bytes can never recover one. iOS now replaces a
saved request rejected this way with a fresh challenge and App Attest
key, once the request is old enough. The first replacement waits an
hour, and the wait doubles with each further rejection. Every
replacement creates and attests a new key, so without the wait a device
the gateway keeps rejecting would create a key on every retry. With it,
such a device creates about ten keys over the saved request's 30-day
lifetime, yet still recovers on its own once the cause is fixed.
Requests saved by earlier builds carry no preparation time and are
replaced right away, which recovers devices that are already stuck.

Each replacement attempt is saved before any key is generated, and the
rejected request stays saved until the fresh request overwrites it. A
failed challenge fetch, a failed attestation or an app restart therefore
waits instead of creating another key. If the fresh request also fails,
the error still propagates and that request is saved for the next
attempt.

A `404` (the challenge is gone and nothing committed) still replaces
right away, as before, so ordinary lost requests recover at once. A
`409` or `404` answer to a request marks it as rejected. A conflict with
a live installation for the same token consumes the challenge, and a
fresh request answered `404` has outlived its own, so either replay then
returns `404`. That `404` waits like a rejection, so neither case can
create a key on every retry. This covers a request that did commit,
whose response was lost and whose replay arrived after its attestation
certificate expired. Enrollment then succeeds once the old installation
expires. Recovering that installation itself would need the gateway to
match an exact replay before checking certificate validity. That is out
of scope here.

`400` keeps the saved request, as before: it can be a temporary policy
rejection (for example a lowered lifetime limit) of a request that did
commit. Transient failures (for example `5xx` or network loss) also keep
it.

Companion gateway PR [#8630](https://github.com/block/buzz/pull/8630)
logs which App Attest check rejected a device, so a `401` like this can
be diagnosed from gateway logs.

### Validation
- Added tests: one table-driven driver test. It loses the first
response, then has the replay answered with `400`, `401`, `404` or
`503`, at various ages, and the fresh request answered with `201`,
`401`, `404` or `409`, or lost. Each row retries twice and checks the
saved request and the number of App Attest keys generated. Rows that
keep being rejected then check that the wait doubles, and that a
replacement whose attestation fails after generating its key records the
attempt, so the next retry waits even with a new driver instance. An
ordinary `404` replacement whose attestation fails is checked the same
way. With the source change reverted, the `401` rows fail. Removing any
of these guards also fails the test: the age limit, the doubling, the
immediate replacement for older builds' requests, saving each attempt,
the `404` and `409` marks, keeping `404` out of the count, or keeping
`400`.
- The full BuzzPushKit suite passes locally (97 XCTest and 65 Swift
Testing tests).
- Not exercised on a device. The replay path needs a real device whose
saved enrollment the gateway rejects, and the simulator does not support
App Attest.

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

---------

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

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

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

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

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


buzz-review-completed

---------

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

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

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

### Validation

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

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

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:05:46 -07:00
Logan Johnson
a4e83dfb50 fix(mobile): open message links to public channels the user has not joined (#8518)
🤖
## Summary
- **Problem:** On mobile, tapping a link to a message in a public
channel did nothing if you had not joined that channel. The app showed a
brief "Channel not found in this workspace" notice and dropped the link,
although you are allowed to read that channel.
- **Now:** The app asks the relay for that one channel. If the channel
is public, it opens as a channel you can read but have not joined, the
same as opening it from Browse channels. Private channels and DMs still
open only when you are a member.

## Details
- Before, the link handler looked for the channel only in the app's
loaded channel list. That list holds the channels you joined. It holds
other public channels only after Browse channels has loaded them.
- When the channel is not in that list, the app now reads that channel's
metadata from the relay (kind:39000, the event that names and describes
a channel). The relay returns it only if you may see it. The app then
refuses private channels and DMs, as Browse already does.
- If the lookup fails, the app shows "Could not open the channel". If
the relay returns no channel, it still shows "Channel not found in this
workspace".
- Links are handled in arrival order. While one link is being looked up,
later links wait, including links to channels you already joined. When
the lookup ends, the app handles the next link. A slow relay can
therefore delay later links by up to the lookup timeout (8 s). The app
shows no loading indicator during the wait.
- A link to a channel already in the list opens at once, even while the
app reconnects to the relay. A link that needs the relay lookup waits
until the relay connection is signed in, then opens without a second
tap. If the connection drops during the lookup, the app keeps the link
and tries again after it reconnects.
- During hosted setup the app turns link navigation off. If a lookup
ends during setup, the link stays queued and opens when setup closes. It
does not open a channel over the setup screen.

### Related issue
None found. #8292 covers a different part of message links.

### Testing
- New dispatcher tests cover a public channel you have not joined, a
private channel you have not joined, and a failed lookup. All three fail
on main and pass here.
- Delayed-lookup tests hold the relay response open. They cover
navigation turning off during the lookup (the link opens only after
navigation turns back on; this test fails without the fix), a queued
link behind a slow lookup, and the dispatcher closing during the lookup.
- Reconnect tests start with cached channels and a reconnecting session.
An unlisted-channel link sends no lookup and stays queued, then opens
after the session connects. A listed-channel link opens at once while
reconnecting. A session drop during the lookup keeps the link and
retries it, both when the session returns after the cancelled lookup
ends and before it ends. The three reconnect cases fail on the previous
head.
- `flutter test test/features/channels test/features/onboarding` (1990
tests) and `flutter analyze` pass on the rebased head.
- End-to-end on an Android emulator (API 36) against a local relay.
Alice made a public channel `#launch-notes` and posted in it. In a
private channel `#team`, she posted a link to that message. The phone
user is a member of `#team` but not of `#launch-notes`. The same install
was upgraded in place from a main build to this branch's build.

| Before (main): tap the link | After: tap the same link | After: link
to a private channel you are not in |
| --- | --- | --- |
| <img width="300" alt="Tapping the message link shows 'Channel not
found in this workspace' and stays in #team"
src="https://github.com/user-attachments/assets/aa4341aa-8d87-4a78-a805-ef71f4bd6cc5"
/> | <img width="300" alt="Tapping the message link opens #launch-notes
with Alice's message and a 'Join this channel from Manage to
participate' footer"
src="https://github.com/user-attachments/assets/267930ab-b723-4d97-bb7f-960fe1497e63"
/> | <img width="300" alt="Tapping a link to a private channel still
shows 'Channel not found in this workspace'"
src="https://github.com/user-attachments/assets/10780121-c3a4-4cf4-a8d1-a6994cac0101"
/> |

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-27 14:01:12 -04:00
klopez4212
ff9eb8f4dc Make mobile onboarding QR-first and synchronize scanner motion (#8599)
Make QR scanning the primary onboarding action, with full-width
contrasting buttons inside the existing gutters. The landing offers
**Scan a QR code** and **or sign in with email**. A two-finger double
tap on the open welcome area reveals **Enter a community link** and
**Use a pairing code**; a screen-reader action provides the same access.
Action buttons remain outside double-tap detection so scanner taps
respond immediately. Pending sign-in keeps its cancellation action.

The Dynamic Island scanner uses 230 ms opening and closing springs.
Welcome content fades and scales with the same progress, camera startup
begins on the first frame, the scanner prompt fades with the camera
reveal, the live preview adds a 230 ms fade when camera startup
completes, and opening gives selection haptics. Native iOS status
indicators request a matching 230 ms fade, returning when dismissal
begins. Reduced motion is respected.

Validation: full mobile suites and analysis run through local checks and
pre-push hooks. The 230 ms iOS Release build was signed, installed, and
launched in the separate Onboarding app on iPhone Air. The owner
reviewed the simplified landing and immediate tap response; the latest
230 ms timing is installed for comparison. The latest 230 ms build,
including the prompt fade, is installed and launched for comparison.
Native status-bar timing has not been independently measured on-device.


Product intent confirmed by Kenny: the supported visible onboarding
choices are email sign-in or QR scanning. Camera permission/startup
failure intentionally does not expose manual pairing or community-link
recovery. Those remain hidden developer options, with no keyboard path
by design.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-27 16:32:04 +00:00
klopez4212
b14884980e Speed up mobile Solo chat creation and agent discovery (#8547)
## Summary

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

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

### Related issue

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

### Testing

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

---------

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

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

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

### Validation

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

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

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

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


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

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


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

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

---------

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

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

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

## Validation

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

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

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

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

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

### Related issue
None found.

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

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

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

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

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

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

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

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

## Validation

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

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

---------

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

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

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

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

### Related issue

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

## Before and after

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

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

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

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

## Test

These extend the existing journeys, plus one new journey:

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

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

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

## Validation

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

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

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

---------

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

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

---------

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

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

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

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
2026-10-22 17:27:38 +00:00
klopez4212
8260b6ef4c feat(mobile): group typing avatars into one animated indicator (#8543)
## Summary
Replace stacked mobile typing rows with one animated avatar cluster and
one typing bubble. People and agents share diagonal, triangle, and
four-corner layouts, with a circular +N badge for overflow and
reduced-motion support.

Includes an opt-in local demo (`BUZZ_TYPING_PREVIEW=true`) with mixed
human/agent avatars and count controls; it never publishes typing
events.

### Related issue
Follow-up to #8505. No duplicate found in the typing-avatar PR search.

### Testing
- 3,736 mobile tests plus four unconfigured-push tests passed; analysis
and file-size checks passed.
- Installed and iterated on a physical iPhone with the existing
profile/community preserved; owner reviewed the motion and badge sizing.

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-22 10:20:23 -07:00
klopez4212
e8781caa6f fix(mobile): remove Browse channels from channel list (#8539)
## Summary
Remove the “Browse channels” action above the mobile channel list. The
list now starts with its channel sections or empty state. Remove the
unreachable browse sheet and its obsolete tests; keep channel directory
providers and the create-channel/direct-message actions.

### Related issue
No duplicate removal PR found. Related: #6243 introduced mobile channel
browsing.

### Testing
- `just mobile-check mobile-test`: formatting and analysis passed; 3,729
tests passed, 16 skipped, plus all four unconfigured-push tests.
- Release iOS build succeeded. Installed and launched in place on a
physical iPhone Air in Pairing Preview; existing preferences retained
except channel read-state updated after launch.
- Existing quick-action test asserts the browse button is absent.
- `git diff --check` passed.
- Pre-push hooks passed on commit `269df8b3e` (full mobile checks and
tests plus file-size gate). The first attempt timed out in the unrelated
iOS push-gateway validator test; the unchanged-commit retry passed.
- Full repository `just ci` remains in progress through native desktop
checks; not claimed green.

Draft pending owner visual confirmation and review. Screenshots have not
been captured; installation/launch is not visual acceptance.

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

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

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

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

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

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

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

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

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

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

### Related issue

Fixes #6124.

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

### Testing

Focused suites:

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

Result: 95 tests passed, 0 failed.

Additional checks:

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

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

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

Signed-off-by: Mohammad Asfour <masfour700@gmail.com>
Signed-off-by: Samin Yeasar Sohag <sssohag172@gmail.com>
Co-authored-by: Samin Yeasar Sohag <sssohag172@gmail.com>
2026-10-21 10:24:32 -07:00
klopez4212
0768397092 feat(mobile): show inline human typing and outgoing avatars (#8505)
## Summary

People typing in a mobile channel now appear beside their avatar in an
inline three-dot bubble, using the existing agent typing animation
instead of the separate composer status strip. Human avatars stay round;
agent identities retain their agent shape. Reduced Motion keeps the dots
still, and thread-scoped typing remains inside its thread.

Restore the viewer’s avatar on the right of their own messages in
channel and Me timelines. Grouped messages show the avatar on the final
bubble, with reactions and reply footers aligned to the bubble.

### Related issue

No duplicate found. Related: #3668 tracks observer-backed agent working
status; this PR changes presentation only and does not add observer
lifecycle behavior.

### Testing

- `just mobile-install`; final pre-push `just mobile-check && just
mobile-test` passed (3,728 tests passed, 16 skipped; 4 unconfigured-push
tests passed). Repository file-size gate and commit formatting passed.
- Regression checks fail against the original channel typing and
outgoing-avatar implementation.
- Pixel 10 / Honeycomb Studios: the recording build exercised inline
human/agent typing, avatar shapes, own-message avatars, and message
arrival. That build predates this fresh-main PR; the PR additionally
uses the shared agent identity lookup.
- Before/after widget-rendered previews use generic sample messages. No
recording identity, app-package override, or demo scripts are included.

### Before / after

Rendered from the production channel widgets using generic sample
messages; Reduce Motion enabled for the stills.

| Before | After |
| --- | --- |
|
![Before](https://raw.githubusercontent.com/block/buzz/cc8df55d3a8bbccee4205593fbcff6760a7d1143/pr-8505--before.png)
|
![After](https://raw.githubusercontent.com/block/buzz/cc8df55d3a8bbccee4205593fbcff6760a7d1143/pr-8505--after.png)
|

### Manual check

Open a channel and have another person type: their round avatar and
animated dots should appear at the bottom of the conversation and
disappear when the message arrives. Send two consecutive messages: your
avatar should appear once beside the final bubble. Repeat with Reduce
Motion and larger text.

Draft pending human testing of this PR snapshot. Do not merge based on
the earlier recording alone.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-20 21:59:41 -04:00
Tom Brow
93114b4279 fix(mobile): lazily lay out new-DM recipients (#8435)
The new-message picker laid out every recipient inside an outer scroll
view. Searching or changing selection could therefore mount the whole
directory in one frame. Use a bounded sliver viewport so offscreen
recipients stay lazy while the search field and selection chips still
scroll above the keyboard.

### Validation

- Current-head CI passed, including all Flutter test shards, Android,
and native/release Swift jobs. Code and security reviews found no
issues. After merging main and retaining both test imports, all 81
channel-page tests and the full mobile check/test suite passed.

- Added a 200-person widget regression: the original mounts all 200
rows; the fix keeps fewer than 30 mounted through open, search,
selection and removal, and can scroll to the final recipient. Covers
no-match and closing too.
- Strengthened the keyboard/chip test to verify the eight-recipient
limit and that removing a chip makes the ninth recipient reachable.
- All 79 channel-page tests passed; the strengthened limit test and
Flutter analysis passed.
- Native iOS simulator build passed. On a separately cloned paired
simulator, verified populated directory/search, eight selected
recipients, limit message, removal, no-match, scrolling to the final
recipient and close without creating a conversation. Debug/AX
instrumentation and a newer Flutter baseline make this functional
validation, not a controlled release-speed benchmark.
- Normal pre-push mobile checks passed, including formatting, analysis
and the full Flutter test suite. Required CI exposed existing formatting
in `message_carousel_preview_cases.dart`; a separate formatter-only
commit corrects it.
- The full local repository gate hit an unrelated Rust failure in
`buzz-acp::signals_cancel_startup_turn_and_pending_stdin_without_hanging`
(exit status `None` rather than `Some(143)`). No Rust code changed. The
gate stopped at unit tests, so later build stages did not run.

### Related issue

No duplicate recipient-layout fix found in the existing issue/PR search.

### Before and after

The synthetic 200-person picker renders identically before and after
(with a reserved keyboard inset). One image represents both; mounted-row
assertions establish the performance mechanism separately.

![Recipient picker before and
after](https://raw.githubusercontent.com/block/buzz/c9980118b75e64ee8ba0b559b7cc981ee59f809b/pr-8435--dm-directory.png)

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-20 15:17:52 -07:00
Tom Brow
bf4a163510 fix(mobile): lazily lay out channel member groups (#8437)
Opening a large channel member sheet mounted and laid out every person
in a grouped column. A captured Debug simulator entrance spent 242 ms in
the UI callback, including 223 ms in layout.

Build each person and agent as a lazy sliver row while preserving the
grouped cards, names, statuses, management menus, and activity actions.
Screen-reader row indices continue across both groups. A separate
formatting-only commit fixes an existing carousel-test formatter
discrepancy. No duplicate member-sheet performance PR or issue found.

### Validation

- Added a 200-member widget regression: the original mounts 100 people
initially; the fix mounts fewer than 20 nearby rows and reaches the
final person and agent.
- Added grouping, compact sizing, empty state, member tap identity,
accessible indices, and scrolled member/agent menu checks. Existing
channel-details “See all” navigation test passes.
- Mobile formatting, static analysis, and the full mobile test suite
passed on the committed tree.
- Synthetic before/after rendering is pixel-identical at the same
viewport.
- Populated native iOS 26.5 simulator workflow: open members, scroll
through people and agents to the end, then close. Final installed build
passed this workflow.

Debug, accessibility automation, profiler overhead, different Flutter
versions, and shared-host load limit timing conclusions. This removes
eager offscreen layout; it does not establish a controlled end-to-end
speedup or release-device performance.

### Visual check

Synthetic member sheet; before and after are pixel-identical at this
viewport.

![Synthetic member
groups](https://raw.githubusercontent.com/block/buzz/c327b1d973e6ec54a09358e8f6794dc0a2587304/pr-8437--members.png)

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-20 15:14:29 -07:00
Tom Brow
a93f3282cc perf(mobile): retain preference crypto across reconnects (#8442)
Foreground reconnects rebuild preference managers and repeatedly derive
the same NIP-44 keys on the UI isolate. Retain the six crypto objects in
providers scoped to relay configuration and identity while preserving
manager refresh, subscription, and publication behavior. Community
changes, key rotation, and sign-out invalidate the retained objects.

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

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

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

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

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-20 14:57:53 -07:00
klopez4212
ca35114209 fix(mobile): simplify and refine the quick actions menu (#8491)
Keeps the mobile + menu focused on Create channel and New direct
message, with 8-point facing corners, a 6-point gap, and the active
Home/Solo tab fill on iOS.

Moves Browse channels directly above the channel list, including its
empty state, preserving open-channel discovery, retry, and Join. Adds a
cold-start journey through the real channel loader and join action with
relay boundaries faked.

Validation: mobile formatting/analysis and full mobile test suite;
mutation check confirms the new journey fails when directory loading is
removed. The original menu styling was tried on a physical iPhone; the
replacement Browse entry has widget-journey coverage and has not yet
been tried on device.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-20 17:38:29 -04:00
Tom Brow
cdc2b129c0 fix(mobile): resolve DM display labels once per sort (#8488)
Home's DM sort resolves participant identity names again for every
comparator call. This work appeared in a captured 82.163 ms UI frame
after foregrounding the full app (Debug/JIT iOS simulator, shared host).
Resolve each lowercase display label once per sort, retaining the same
label ordering and channel-ID tie-breaker. Sort keys are
invocation-local, so later viewer, profile, owner, participant, and
community changes cannot reuse stale labels.

No matching open mobile sorting PR or issue found.

### Validation

- Added synthetic regression coverage for bounded contextual resolution
and ordering parity across identity/name/channel changes, ties, unnamed
DMs, and empty/singleton lists. The 80-DM fixture performs 80 contextual
resolutions instead of the original comparator’s 982; restoring that
comparator makes the regression fail.
- 133 focused tests passed, including existing Home scrolling/reconnect
widget tests and identity-name tests.
- Mobile static analysis passed; 3,697 tests passed with 16 existing
skips, plus four no-gateway configuration tests.
- No new full-app or hardware capture. The existing trace establishes
repeated synchronous work, not its release-build magnitude or the cause
of every scrolling stall.

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

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

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

---------

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

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

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

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

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-20 11:10:24 -04:00
Wes
edbfbf08db test(mobile): journey-first testing guidance and suite cleanup (#8397)
## Summary

This PR does two things. It updates the mobile testing conventions to
favor a few journey tests over many small logic tests. It also applies
those conventions to the existing mobile suite, one commit per step.

### Testing conventions (`AGENTS.md`, `mobile/README.md`)

- Start with one widget journey per user-visible flow, driven through
the real widgets, notifiers and coordinators. Assert what the user sees
and what is durably saved.
- Cover retry, cancel and resume as steps inside the journey, not as
separate tests.
- Fake only external boundaries (network, relay, OS, browser, storage).
- Use focused, table-driven tests only for security and data-loss
invariants, protocol or crypto vectors, and pure functions with many
input combinations.
- When review finds a defect, extend an existing journey or table
instead of adding a test per finding.
- Delete a lower-level test only once a journey demonstrably catches the
same failures.
- Every test must fail when the production code it guards is broken.

These conventions keep the Review-Proven Rules' safety bar and change
only how coverage is organized.

### Suite inventory and plan

The suite has 234 Dart test files, about 110k lines and about 2,600
declared tests. Native tests are separate: BuzzPushKit, RunnerTests,
NativeControlTests and Android. Line-saving estimates are hypotheses,
not a deletion budget. Each deletion needs a replacement that rejects a
representative production mutation.

1. **CI and reliability.** Run the native tests CI skipped, make
time-dependent tests deterministic, and make tests that could not fail
falsifiable. *(done, see below)*
2. **Shared harness.** Add one fake relay (socket plus HTTP boundary,
with the real `RelaySessionNotifier`) in `test/helpers/`. Today 38 files
each write their own `RelaySessionNotifier` fake. *(done, see below)*
3. **Per-area journeys.** Convert page tests that fake the flow's own
notifiers (for example, `channel_detail_page_test` overrides about 60
providers). Fold near-duplicates into tables. Remove constant-pinning,
pixel-geometry and call-count tests once a journey covers the outcome.
Split `channel_detail_page_test.dart` (17.7k lines). Each area commit
reports before/after counts and the mutation it rejects.
4. **CI speed.** Shard by file instead of by test, since every shard
currently compiles the whole suite. *(done, see below)*

Keep as-is: relay protocol and NIP handling, crypto vectors, auth, push
security, persistence and journal invariants, recipient routing, and
real-socket transport and cancellation tests.

### Step 1 changes

- **Android CI** runs the 23 JVM unit tests, which were never run
before. It retries the APK build once, only on SDK or plugin download
failures (the cause of 4 recent `main` failures).
- **iOS CI** runs all RunnerTests (previously 2 of 99) and the
previously uncalled Me-agent accessibility script. The Swift Native lane
builds the simulator app once, in the background while the simulator
boots and runs the native-control scripts, instead of twice. The lane
stays non-required.
- **Stale RunnerTests.** Two reaction-details tests still expected the
filter list that #8280 replaced; they now cover the toggle list. #8393
moved navigation bar controls onto a shared glass surface and made
unknown Tabler symbols assert. The avatar and members-activity tests now
find controls on that surface, three tests use real symbol names, and a
240 pt title width pinned to the old control sizes is removed (its gap
assertions remain).
- **Known bug, not fixed here.** Since #8393 the DM title avatar image
is nil, because `makeItem` returns avatar data as a custom-view item. No
screen passes a title avatar yet. The test marks it `XCTExpectFailure`.
- **Typing retry** and **sections backoff** tests use fake time instead
of about 37 s of real sleeps and a `Stopwatch < 100ms` threshold.
- **Manage-channel sheet tests** previously asserted inside callbacks
that the sheet's `catch (_)` swallows. A wrong saved description made
the test loop until killed. The tests now record arguments, assert
outside the caught code, and cancel instead of re-presenting endlessly.
- **`video_viewer_transport_test` removed.** It exercised only
`package:http`. Removing the production `request.sink.close()` already
fails `video_viewer_test`.

### Step 2 changes

- **`test/helpers/fake_relay.dart`** fakes only the websocket (through
the session's socket factory) and the HTTP bridge (through its client).
The real `RelaySessionNotifier` still connects from `build()`, handles
OK and CLOSED, batches events, backs off and replays subscriptions.
- The fake serves the NIP-CW channel window. It can hold OK and live
echo independently, reject a chosen publish, and drop the connection.
Any request it cannot answer faithfully fails the test at teardown,
because the app often catches relay errors and falls back.
- **`channel_messages_journey_test`**: history through the channel
window, a live message, a send that is pending until OK and stays one
row after the echo, a rejected send rolled back, then drop and reconnect
with missed and later messages.
- **`channel_send_journey_test`**: type and send on `ChannelDetailPage`
with no feature-notifier overrides. The row and sending spinner show at
once, OK settles them, and a rejected send puts the text back in the
composer and shows a snackbar.
- Existing per-file relay fakes and `SidebarRelay` are unchanged. Step 3
migrates them area by area.

### Step 4 changes

- `scripts/mobile-test-shard.sh` splits the 235 test files across the 3
shards, balanced by line count. `flutter test --total-shards` split
tests, so each shard compiled and loaded every file. Locally at 4
workers, one shard took 63 s instead of 73 s and used 40% less CPU.
`channel_detail_page_test.dart` (17.7k lines) is now the long pole;
splitting it in step 3 shortens the slowest shard.

Out of scope here: requiring branches to be up to date before merge (a
repository setting), and the onboarding tests in #8375.

## Validation

- Mutation checks:
  - Typing retry bound: test fails.
  - Sections backoff reset: test fails.
- Wrong saved description: fails in about 11 s. The old tests hung
instead.
  - Removed sink close: `video_viewer_test` fails.
- Changed Dart files pass locally, and `flutter analyze` is clean.
- Android `testDebugUnitTest` passes locally (23 tests). The Me-agent
accessibility script passes on a local simulator.
- Step 2 mutation checks, each made a journey fail: accepted OK ignored,
rejected row not rolled back, optimistic row not added, draft not
restored, local message left pending after OK, channel window replaced
by the fallback, live subscriptions not replayed after reconnect.
- Both journeys run in about 1 s on fake time.
- All 99 RunnerTests pass on an iPhone 16 iOS 26.5 simulator, the CI
destination, after rebasing on #8398.
- The shard script partitions all 235 test files exactly once.

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

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:19:07 -06:00
Wes
3ec6327618 fix(mobile): keep cached state with its community (#8349)
## Summary

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

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

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

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

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

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

### Related issue

None found.

### Testing

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

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

---------

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

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

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

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

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

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

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

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

## Before and after

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

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

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

## Test

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

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

## Validation

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

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

buzz-review-completed

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

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:10:14 -06:00
Wes
52aa37a929 perf(mobile): stop an open chat rebuilding on other channels' events (#8456)
## Summary

While a chat is open, a message in any other channel rebuilt every built
row of that chat. There were two causes:

- **The chat watched the whole channel list.** `channelsProvider`
republishes the list on every live event in any channel. The chat page,
its message list and the thread page watched all of it. They now watch
only their own channel's entry. The `#channel` name map comes from a new
`channelIdsByNameProvider`, which notifies only when a channel name or
id changes.
- **The root `App` rebuilt often, and each rebuild rebuilt every pushed
route.** It watched the agent observer and the user-status cache only to
keep them alive, and it watched the whole Me unread set. It now listens
to the first two and selects only whether Me has unread.

Rows still remount when a message arrives in the open channel. That
comes from `scrollable_positioned_list` keying rows by index, and it
gets its own PR.

## Before and after

Setup:
- iPhone 18 Pro simulator, debug build.
- Local relay with about 120 ms of added round-trip time.
- Seeded community: 120 channels, about 5.5k messages, a busy chat of
about 1.4k messages.
- The same scripted run on `main` at `5730c83f7` and on this branch,
counted with local probes that are not part of this PR.
- Counts are exact. Debug milliseconds are only useful for comparing the
two runs.

10 messages arrive in another channel while the busy chat is open:

| | `main` | this PR |
|---|---|---|
| Chat page rebuilds | 42 | 0 |
| Message row rebuilds | 1,218 | 0 |
| Frames with build time over 16.7 ms | 42 of 129 | 0 of 10 |
| Worst frame build time (debug) | 78 ms | 13 ms |

5 messages arrive in the open chat: 1,300 row rebuilds on `main`, 693 on
this PR. Most of the rest are the remounts from the index-keyed list.

## Test

A widget test publishes a new channel list where only another channel
changed, and fails if any `MessageContent` rebuilds. Reverting the
production change makes it fail.

## Validation

- `just mobile-check`
- Full mobile suite: 3,532 passed.
- The simulator run above.

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

buzz-review-completed

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

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:09:52 -06:00
Wes
db50afd8a7 perf(mobile): load the channel list's membership-scoped reads together (#8462)
## Summary

At startup and on every community switch, the channel list waits on a
chain of relay reads, one round trip after another. Four of those reads
need only the membership ids: channel metadata, hidden DMs, member
snapshots and Huddle starts. Yet each one waited for the read before it.

They now go out together, right after the membership list. Everything
that depends on them is unchanged: DM participant names still follow
metadata, and the latest-message batch still follows the filtered list.

- `Future.wait(eagerError: true)` handles every request's failure and
rethrows the first one unchanged, as soon as it happens. Before this
change a failed read also stopped the chain. Hidden DMs and Huddle
starts still fall back to empty on failure, as before.
- The refresh fence checks the combined result once, before any state is
written. That's the same fence check each read had before.

This keeps the duplicate member-snapshot read. Replacing it with the
membership list's events would need a relay-contract check first.

## Before and after

Setup:
- Simulator, debug build.
- Local relay with about 120 ms of added round-trip time.
- Seeded community with 120 channels.
- 5 cold launches each, after one warm-up launch, on the same build with
and without this change.
- Timed with local probes that are not part of this PR. All 10 launches
loaded 120 channels.

| | `main` | this PR |
|---|---|---|
| Connected → channel list ready (median) | 1,233 ms | 715 ms |
| Range | 1,154–1,297 ms | 689–747 ms |

## Test

A test parks the metadata, hidden-DM and member-snapshot reads at the
same time, then checks that all four reads were sent. If any read waits
on another, it stays unsent and the test fails. It fails on `main`,
where only metadata goes out, and on a version that sends metadata first
and the rest after. Three switch tests park before discovery now, so
their names say "during a directory refresh" instead of "after directory
success".

## Validation

- `flutter analyze`
- Full mobile suite: 3,535 passed.
- The simulator runs above.

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

buzz-review-completed

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

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:09:29 -06:00
Wes
bc9e7de904 fix(mobile): stop Settings rows blinking while Settings opens (#8463)
## Summary

On iOS, opening Settings made the Status, Profile and Notifications
cards disappear for most of the slide-up. They came back when the
transition finished. Closing Settings did the same thing.

**Cause:** Flutter's iOS compositor (`flow/view_slicer.cc`) puts Flutter
content that's painted after a platform view and overlaps no native view
into the background canvas. During the transition, Home's native nav bar
and glass tab bar are still on screen under the full-screen Settings
page, so the overlay created for Home's tab bar already covers the whole
background. Everything painted after the native presence pill, outside a
native view's rect, was hidden behind it. "Send identity to desktop"
stayed visible only because it overlaps Home's tab bar.

**Fix:** `IosPresenceMenu` mounts its native view only after its route
settles (animation completed, nothing covering it). During route
transitions, and while the native view is being created, it draws a
matching Flutter stand-in: same capsule, colors, SF Pro Text at the same
size and weight.

### Related issue

N/A, none found. Reported by Wes from a screen recording.

### Testing

- Reproduced in an uncommitted harness on an iOS 27 simulator: Home with
native nav bar and glass tabs, then `BottomUpPageRoute`, then a
Settings-shaped page using the real `IosPresenceMenu`. The cards blank
out on every open and close.
- A/B in the same harness: the blink goes away if any one of the three
is removed (native pill, Home nav bar, Home glass tabs).
- With this change, the open and close cycles in the harness show no
blank frames. At rest the native pill is mounted again. Its pixels are
identical to before, and the stand-in matches it within about 1px of
text rasterization.
- `settings_overlay_test.dart` now checks that the native pill isn't
mounted mid-transition, and that exactly one native view is created
after settling. The new assertion fails without the fix.
- Pre-push mobile checks passed: analyze, format, full mobile test
suite.

**Human test:** on iOS, open and close Settings from Home a few times.
Every card should stay visible throughout, and the "Online" pill should
open the native availability menu once Settings has settled.

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

---------

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

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


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

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

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

## Validation

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

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

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

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

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

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

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

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

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

## Review disposition

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

## Remaining draft gates

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



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


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

---------

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

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

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

### Validation

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

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

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

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-19 10:09:25 -07:00
Tom Brow
4f1f1b5300 test(mobile): remove flaky channel collection assertion (#8440)
Remove the intermittent `departed channel composer and route are
collectible` test introduced in
[#8419](https://github.com/block/buzz/pull/8419), as requested after its
CI failure could not be reproduced in focused macOS and Linux
investigation. Keep the Flutter upgrade containing the upstream leak fix
and all other tests. No replacement GC-based test is added.

### Validation

All 379 remaining channel tests pass, along with formatting and
analysis. The broad local gate was stopped during unrelated Rust
compilation; no full-gate pass is claimed.

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-19 07:12:44 -07:00
Tom Brow
3debf18f14 fix(ios): supply missing push notification sound at handoff (#8443)
Temporarily default missing notification sound until the [gateway
fix](https://github.com/block/buzz/pull/8431) is deployed. Preserve any
sound already provided by APNs.

### Validation

Final build 10151: automation verified the push banner on a real iPhone
12 mini against the unchanged staging gateway, and Tom confirmed hearing
the sound. iOS build, two existing handoff tests, and normal pre-push
passed.

### Before merge

- [x] Verify notification delivery and sound on the final iPhone build.

Replaces [#8429](https://github.com/block/buzz/pull/8429) with the
identical commit to obtain a fresh owner review. The prior review
requested tests; this temporary workaround intentionally adds no tests
or helper at the author's request. [Prior review
response](https://github.com/block/buzz/pull/8429#issuecomment-6256498498).

---------

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

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

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

### Related issue

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

### Validation

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



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

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

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-18 16:10:01 -07:00
klopez4212
bbb5183f33 feat(mobile): add swipe-to-reply message gesture (#8436)
## Summary
Swipe right on channel and thread messages to open the existing reply
destination. Adds a centred accent progress ring, Reply label, threshold
haptic/pulse, drag resistance, cancellation, and reduced-motion support.
Retreating below readiness re-arms feedback.

Includes a separate formatting-only fix required by main’s updated Dart
formatter.

### Related issue
Related message styling: #8281. No matching swipe-to-reply issue found.

### Testing
- Full pre-push checks passed, including mobile analysis/tests and
file-size checks.
- Widget coverage: routing, repeated threshold crossings, cancellation,
resistance, alignment, and centred scaling.
- Release preview installed on iPhone with the existing
profile/community; Kenny tested and confirmed the interaction.

Draft: native preview verification predates the rebase onto main’s
Flutter upgrade. No native gesture recording attached.

---------

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

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

Related issue: no matching open issue or PR found.

### Validation

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

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

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

### Create-channel compatibility

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

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

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-18 12:44:43 -07:00
Tom Brow
e048f5ac78 fix(mobile): bound search result rendering work (#8418)
Global search eagerly mounted every result and parsed entire messages
even for two-line previews. Large markdown hits could block the UI while
changing categories, updating results, or reopening search.

Build result rows lazily and bound long message previews to a literal
prefix of at most 512 UTF-16 units without splitting surrogate pairs.
Short messages retain rich formatting and mentions. Long snippets show
markdown punctuation literally; opening a result still uses the original
message. Truncated markdown is never passed to the rich renderer.

### Validation

- Added regressions for lazy message/channel/people rows, scrolling to
the final result, and a >100 KB markdown hit with a surrogate pair at
the preview boundary. All 23 focused search tests pass; full mobile
analysis is clean. The original large-hit test consumed a CPU core for
over 30 seconds before being terminated.
- Native iOS simulator build passed. Diagnostic Debug/JIT captures
attributed a 1,033 ms search frame and a separate 12,972 ms
result-update callback to widget/markdown work. A verified
populated-results run of the final candidate completed category
switching, scrolling, and closing, with a longest UI callback of 70 ms.
Separate instrumented Debug/JIT runs have uncontrolled host load,
warm-up, and live data; they are not release-performance benchmarks.
- Full local gate passed through desktop builds/checks, then failed two
unchanged native subprocess PID/timeout tests; both passed in isolation.
The remaining web build and mobile suite passed.

- [Codex
review](https://github.com/block/buzz/pull/8418#issuecomment-6243734066)
completed on the current head with no findings.
[CI](https://github.com/block/buzz/actions/runs/39082758977) passed,
including mobile checks, all three test shards, Android, the iOS release
build, and native simulator build/tests.

### Preview examples

Synthetic widget captures with app fonts, without real community data or
native iOS glass controls.

| Long message before | Bounded literal preview after |
| --- | --- |
|
![Before](https://raw.githubusercontent.com/block/buzz/5840caf6cc3e35ce0ecdba68cfbcb10506f3eb23/pr-8418--search-long-before.png)
|
![After](https://raw.githubusercontent.com/block/buzz/5840caf6cc3e35ce0ecdba68cfbcb10506f3eb23/pr-8418--search-long-preview.png)
|

---------

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

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

---------

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

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

---------

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

### Validation

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

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

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

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

### Validation

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

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-16 16:25:41 -07:00
Tom Brow
2de484baf7 docs(ios): document Xcode 27 development setup (#8400)
Document Xcode 27.0 as the recommended iOS development toolchain while
retaining Xcode 26 compatibility. Explain per-command selection, the
known Flutter native-assets compiler/SDK mismatch on hosts with multiple
Xcode versions, and where CI and release toolchains are configured.

The compatibility changes are already merged in
[#8379](https://github.com/block/buzz/pull/8379) and
[#8398](https://github.com/block/buzz/pull/8398). Related open work is
[#8330](https://github.com/block/buzz/pull/8330); this PR changes
documentation only. The release version bump remains separate in
[buzz-releases#110](https://github.com/squareup/buzz-releases/pull/110).

### Validation

- Ordinary unsigned Release build passed on [main at
73962435b](73962435b6)
with exact Xcode 27.0 (27A266a), the pinned Flutter SDK, and normal
CocoaPods installation. Buzz.app contains NotificationService.appex. No
dependency patches or plugin-validation bypasses were used.
- The merged direct-C change passed native tests on Xcode 26.5 and 27.0,
full local CI, and hosted Native and unsigned Release validation. This
documentation-only follow-up preserves that code.
- Documentation diff checks and local pre-push checks passed.

---------

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

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

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

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

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

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-16 12:28:02 -04:00
Tom Brow
73962435b6 Use upstream C secp256k1 for native Nostr signatures (#8398)
Replace the Swift secp256k1 wrapper with a small native adapter over
Bitcoin Core libsecp256k1. This is an alternative to the wrapper upgrade
in [#8381](https://github.com/block/buzz/pull/8381), not an additional
change to merge alongside it.

The adapter covers the three Nostr operations Buzz needs: x-only
public-key derivation, BIP-340 signing, and signature verification. It
compiles pinned, unmodified upstream C source through SwiftPM. There is
no build plugin, plugin approval script, or underscored Swift import.
The app and notification extension use an explicit C module map and
header search path.

This trades the third-party Swift wrapper for a Buzz-owned adapter and
responsibility for updating the vendored C source. Upstream v0.8.0
source, its license, and update provenance are included. The release
Xcode pin remains separate in
[buzz-releases#110](https://github.com/squareup/buzz-releases/pull/110).

### Validation

- Native package tests passed on Xcode 27.0 and 26.5: 96 XCTest tests
and 65 Swift Testing tests per toolchain.
- Added tests cover the official BIP-340 vectors for 32-byte Nostr
messages, invalid secret keys, and malformed input lengths.
- Full unsigned Release app and notification extension build passed on
exact Xcode 27.0.
- Vendored files were compared byte-for-byte with upstream v0.8.0.
- Full local `just ci` passed.
- [Hosted full unsigned iOS Release
build](https://github.com/block/buzz/actions/runs/38924020583/job/117051237094)
passed. [Hosted Native Swift
validation](https://github.com/block/buzz/actions/runs/38924020583/job/117051237153)
passed, including the complete simulator build and native controls/push
navigation tests.
- Codex code review and security review completed with no findings.

### Before merge

- [x] Tom selected the direct-C approach with vendored upstream source;
[the wrapper alternative](https://github.com/block/buzz/pull/8381) is
closed.

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-16 09:26:55 -07:00
Logan Johnson
93ffc37cbd feat(mobile): find people by name through the relay's name-only profile search (#8333)
🤖
## Summary
In the mobile app, searching for "wes" did not find Wes. People search
matched text anywhere in a profile, including the image data of a shared
default avatar. Many agent profiles use that avatar, so they filled the
results and the real Wes never appeared.

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

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

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

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

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

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

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-15 17:33:15 -07:00