Commit Graph

207 Commits

Author SHA1 Message Date
klopez4212
2ca77747d1 Fix mobile Add Community loading and membership restoration (#8796)
## Summary
Open Add Community immediately from the post-login picker and return
directly on Back, without membership restoration or repeated discovery
during navigation. Completing Add restores the saved selection while
keeping the newly added community active; failures retain progress and
offer Retry.

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

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

---------

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



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

---------

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

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

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

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

---------

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

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

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

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

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

### Related issue

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

### Testing

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


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

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

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

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

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

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 20:34:41 -04:00
Logan Johnson
851250524e feat(mobile): show your agents working in a thread on its root message (#8754)
## Summary

When you mention one of your agents in an ordinary channel on mobile,
nothing in the channel showed that the agent was working until its reply
landed. The agent answers in the mention's thread, so its typing (kind
20002) is thread-scoped, and the channel timeline hides thread typing.
The header working dot that used to cover this was removed in #8052,
#8075 and #8082.

This change brings buzz-app's behaviour to mobile. In buzz-app, the
thread summary under a message shows the viewer's own agents while they
type in that thread (`MessageRow.tsx` `useThreadAgents`, described in
`docs/agents.md`). Mobile now does the same. The root message's reply
row shows the working agents' avatars and the typing dots, and the row
appears even under a mention that has no replies yet. It then reads
"View thread". Tapping it opens the thread. When the reply arrives and
the typing ends, the dots go away and the row reads "1 reply".

The details follow buzz-app:

- Only your agents count. buzz-app's "your agents"
(`agentChoices.identities`) are your agent inventory, which is the kind
30177 records you author with archived agents included, plus your local
agents. It does not read the NIP-OA owner tag. Mobile uses the same
inventory. An agent that only names you as owner in its profile does not
count. People and other agents typing in a thread stay inside the thread
view.
- Working agents leave the participant stack for their own group: up to
two agent-shaped avatars side by side, then "+N", then the dots.
- The curved reply branch shows only when the message has replies.
- buzz-app's channel indicator by the composer also includes your agents
working in any thread ("Channel views include threads" in
`current-activity.ts`). Mobile's channel-level surface is the typing row
at the end of the timeline. In ordinary channels, that row now also
shows your agents that are typing in a thread, with the agent avatar
shape. It goes away when the agent's reply arrives.
- The spoken labels use buzz-app's strings: "View thread: Morty is
working", "View thread: 3 agents are working" and "View thread: 1 reply.
Morty working".
- Me and DMs already show thread typing inline, so they are unchanged.

Code changes:

- `TypingDots` is extracted from `AgentTypingBubble`, so the reply row
and the bubble share one animation. It respects reduce motion.
- `ReplySummaryLink` accepts a nullable summary and a list of working
agents with their names. `SmallAvatar` gains an `agent` flag for the
working group, and `InlineAgentTyping` a `knownAgents` set so the
channel typing row gives inventory agents the agent shape.
- `meAgentInventoryProvider` holds the inventory keys. It is read only
while a thread in the channel has typing. Each read, failed ones
included, is reused for 30 seconds, then read again if still watched, or
dropped. So the inventory refreshes every 30 seconds while it is
watched, and a short gap between an agent's messages does not query
again.
- The page selects a "pubkey root" key from channel typing, so typing
expiry refreshes do not rebuild it.

Less than buzz-app, not in this PR:
- buzz-app prefers lifecycle evidence (local agent state and kind 24200
observer turns) and falls back to typing. Mobile uses typing only. So
when an agent stops without replying, mobile keeps its dots until the
typing expires (8 seconds). A second buzz-app device without that
evidence behaves the same way. I found no 24200 publisher in buzz-app
itself. buzz-app publishes typing (kind 20002) for the agents it runs.
- The channel typing row reads "Morty is typing" where buzz-app's
indicator says "working". It has no popover that names the thread and no
tap action. It sits after the newest message, so it scrolls away with
the timeline, while buzz-app's indicator stays by the composer.

### Related issue

N/A. Reported in Buzz #buzz-bugs. Searched issues and PRs for
duplicates: none found.

### Testing

Android emulator, local relay, build of `3caa17a20`. Demo User mentions
Morty. Morty, Summer, Jerry and Rick send thread typing:
- Morty is a channel bot in Demo User's agent inventory.
- Summer and Jerry are in the inventory but are ordinary members, so
only the inventory gives them the agent shape.
- Rick names Demo User as owner in his profile but is not in the
inventory.

In the video:
1. The root message shows Morty and Summer, "+1", the dots and "View
thread", with no reply branch. The channel typing row reads "Morty and 2
others" with three agent avatars. Rick does not show.
2. Tapping "View thread" opens the thread before the first reply. Back
to the channel.
3. Summer and Jerry stop. Their typing expires, and only Morty is left
on both rows.
4. Morty replies. The typing row goes away, the reply branch shows, and
the root row reads "1 reply".

The UI tree confirmed "View thread: 3 agents are working", "Morty and 2
others are typing", "View thread: Morty is working", "Morty is typing"
and "View thread: 1 reply".


https://github.com/user-attachments/assets/2237e565-aa48-405a-84ca-39ab9a06b7a6

- The widget test "an agent working in a thread shows on its root
message" (`channel_detail_page_test.dart`, Typing indicator group)
covers these cases:
- A person, and an agent outside the inventory that names you as owner,
show no row and no channel typing row.
- Inventory agents also show in the channel typing row ("Morty and 2
others are typing", checked in the semantics tree), with the agent
shape. That row goes away when the reply arrives.
- Three inventory agents show two avatars with the agent shape, "+1",
"View thread", no branch, and "View thread: 3 agents are working".
  - The row opens the thread before the first reply.
- After the reply, Morty shows once, the branch shows, and the label is
"View thread: 1 reply. Morty working".
- With Summer still typing, Morty removed from the inventory stops
counting after the 30-second refresh.
- Each check was made to fail on purpose by removing the code it guards.
- `just mobile-check` and the full `just mobile-test` passed on
`3caa17a20` (3996 passed, 28 skipped).
- Auto and Cerveau reviewed it adversarially against buzz-app
origin/main. Their findings are fixed, and their last pass found
nothing.
- Not yet tested on iOS or by a human.

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

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 17:17:33 -04:00
klopez4212
f3a7bab098 Clarify mobile community onboarding and saved identity selection (#8729)
## Summary

Make community onboarding easier to scan: shorten hosted hostname
fallbacks, clarify Add Community instructions, and replace raw saved-key
buttons with identity cards. Use white pages, grey cards, ghost
secondary actions, standard page transitions, and a “Getting ready”
screen during joining. Native profile setup keeps a horizontal modal
transition. Identity cards load signed name/avatar metadata from saved
communities without switching accounts; unavailable profiles use a
neutral fallback. Cards show only the avatar, a 16px medium-weight name,
and muted npub; community lists are omitted. Retiring profile setup
closes its avatar editor too.

### Related issue

Follow-up to #8687 and #8599; no duplicate PR found.

### Testing

- Formatting and analysis passed. Full suite with hosted-auth defines:
4,019 passed, 5 skipped; includes profile-fetch and cancellation
regressions.
- Prior iOS device preview and native profile tests passed; hosted CI
native Swift checks passed on the preceding head. Latest Dart fixes are
not yet reinstalled on the phone.
- Simulator walkthrough and owner-tested iPhone preview use sample
identities and simulated admission.

Sample-data previews; the identity snapshot shows the
unavailable-profile fallback rendered by Flutter. Media is attached here
only.

| Community picker | Add Community | Saved identity |
| --- | --- | --- |
| ![Shortened community
name](https://github.com/user-attachments/assets/cf0af6aa-3a3d-4ffe-b469-e26f9bb92c30)
| ![White Add Community
page](https://github.com/user-attachments/assets/b2f71c77-1aa0-465a-b302-7eb5f1e92819)
| ![Saved identity card and ghost import
action](https://github.com/user-attachments/assets/47f20f34-f047-4ce5-befa-f56834096e95)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-30 19:00:42 +00:00
klopez4212
c0c606e2e3 Polish mobile navigation and restore community switch animation (#8732)
Removes aggregate unread dots from mobile filters, Inbox, and Solo
section headings. The aggregate-dot removal is an explicitly requested
product change. Solo and Team names match the current `block/buzz-app`
sidebar’s light/dark colours and 400/600 read/unread weights (not this
repository’s older `desktop/` implementation); Solo rows show
owned-agent working dots with reduced-motion support.

Restores the community-switch loading and avatar flight with a leftward
arc to the community menu in its current top-right position.

Mobile uses one fixed light/dark application palette; legacy catalog
theme names do not select navigation palettes. The 600 unread weight
follows [the current desktop sidebar
override](36a283b238/src/bundled/channels/Channels.module.css (L47)).

Working status uses live agent typing; desktop’s local waiting state is
not available on mobile.

Validation: clean analysis; 3,997 mobile tests passed, 28 skipped; four
push-configuration checks passed. Navigation styling visually approved
on iPhone; community-switch regression covers portrait/landscape and
normal/reduced motion; authenticated-shell recovery is covered. Hosted
CI passed at `bfeb0fecd`. Updated Pairing Preview installed on iPhone
for transition testing.

### Snapshots

Actual Flutter widgets with sample conversations; static working dots
for reduced motion.

| Light | Dark |
|---|---|
| ![Light theme: unread chat above read
chat](https://github.com/user-attachments/assets/d83b35d5-ab9b-44e0-b7d6-6527ef10a776)
| ![Dark theme: unread chat above read
chat](https://github.com/user-attachments/assets/45f64291-5d39-4e17-8f72-1aa1271c10b7)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-30 18:03:25 +00:00
Wes
75854111b8 fix(mobile): retain invite keys and reject incomplete onboarding (#8753)
Self-hosted invite claims retain the generated identity in secure
storage before the HTTP request and reuse it after a lost response or
restart. Saving or reopening an existing destination retires the
matching journal. Sign-out/removal deletes all matching pending key
copies before removing the saved community, preserving the community for
retry if cleanup fails.

Pairing verifies that relay authentication completed before
saving/activating a destination or reporting success. On iOS 16, hosted
entry permits recovery and reuse of backed-up identities, including
binding a recovered identity with a confirmed backup receipt. Paths
requiring a new secure backup explain the iOS 17 requirement before
generating or binding a new key. Self-hosted entry remains available.

### Validation

- Initial focused entry/recovery files: 83 passed, 1 skipped. Review-fix
focused storage, community, invite and hosted files: 82 passed, 1
skipped; extended invite journey: 17 passed, 1 skipped.
- Final hosted journey extension: 20 passed, including both bound and
unbound recovery with native sealing unavailable.
- Isolated mutations removing invite retention, pairing validation,
journal removal/re-entry cleanup or restoring the blanket platform gate
fail their intended regressions. Restored controls pass.
- iOS simulator build passed on the original integration base; final
native/device acceptance remains outstanding.
- Carl and three specialist reviewers found no source-review blockers at
`323cec6ea2`. `a55a246857` adds analyzer fixes, copy cleanup and the
recovered-unbound journey coverage. Reviewers did not run tests or the
app.

- Fixed a push-hook timeout in `4564b3562a`: idle serial queues now
release completed futures, avoiding scheduler retention across widget
tests while preserving in-flight ordering and failure isolation. The
existing dispatcher journey fails promptly before the fix and passes
afterward; Carl reviewed this delta with no blockers. Focused
dispatcher/invite/storage checks passed, and all 20 hosted journeys
passed with hosted auth enabled.

- Mandatory pre-push checks passed at `4564b3562a` (scope, branch skew,
file size, mobile analysis/tests and crypto package checks); GitHub head
verified at that commit.

### Remaining acceptance

Draft pending agent/device and human acceptance. Exercise interrupted
one-use invite claim and retry across restart, sign-out then reopen
without restoring the removed identity, rejected self-hosted pairing,
iOS 16 backed-up identity recovery (including binding), and iOS 16 new
setup blocked before key creation. Synthetic network/storage boundaries
do not prove native durability or live server behavior. Hosted CI
results are not claimed here.

Optional follow-ups remain outside this patch: pruning provably
unadmitted journals, malformed-record recovery, avoiding an iOS 16
dead-end device-key selection, and pre-existing removal/push-revocation
ordering.

This PR contains three existing-path fixes. The two organization-login
fixes depend on #8700 and remain separate: its stale parent conflicts
with main in unrelated relay code, and the required branch-skew hook
prevents publishing that stack unchanged. This PR does not refresh that
parent or change deployment policy.

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
2026-10-30 10:38:24 -06:00
Tom Brow
64312fa5be fix(mobile): address #8650 second-pass review (#8691)
Follow-up to #8650. This addresses the items from the [second-pass
review](https://github.com/block/buzz/pull/8650#pullrequestreview-5700660448),
which arrived after #8650 merged.

- **Setting menu semantics.** Options in the shared `BuzzSettingMenu`
(model, reasoning effort, and channel TTL) expose their checked state
again. The trigger is announced as a button with enabled and expanded
state, so a disabled "Reasoning effort" no longer reads as plain text.
The `PopupMenuButton` this menu replaced also provided a named route and
a "Dismiss menu" action. `MenuAnchor` does not provide them, so this PR
does not restore them.
- **Remote listing failures.** "Could not load agents" for a failed
Builderlab listing now appears only in communities that support remote
agents. For example, it no longer appears in a non-hosted community
while signed in.
- **Add agent during a community reload.** No change needed here. #8693
already disables Add while the page has no community.

### Validation

- Added tests: setting-menu checked, button, enabled and expanded
semantics; a failed remote listing stays hidden where remote agents are
off. Both fail without these changes.
- Mobile analysis is clean, and the Agents and shared widget tests pass
locally after rebasing onto #8693.
- Not covered: VoiceOver on a device.

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

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@sprout-oss.stage.blox.sqprod.co>
2026-10-29 15:36:21 -07:00
Tom Brow
a94ebece0e Revert "fix(mobile): push every message in your own Solo chats (#8615)" (#8690)
Reverts [#8615](https://github.com/block/buzz/pull/8615).

Mobile push for Solo chats returns to mention-only, matching buzz-app,
which alerts on every message only in DMs. This also removes the
lease-size guards #8615 added, since they existed to fit Solo chats into
the lease.

### Validation

- Clean revert of bad9d26142 with no conflicts.
- Mobile lint and the full mobile test suite pass.

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

Co-authored-by: Tom Brow <tomb@squareup.com>
2026-10-29 15:36:16 -07:00
Tom Brow
4b21a10c4b fix: don't notify workflow owners through their attribution tag (#8694)
A workflow message names its owner in a marked p tag, ["p", owner, "", "owner"], for attribution. #p live, history and push matching, operator listeners, desktop, mobile and the iOS NSE treat that tag as attribution rather than a mention. A workflow that mentions its owner adds a plain p, so the owner is notified only when actually mentioned. NOSTR.md records the #p exception.

Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <tomb@squareup.com>
2026-10-29 15:21:56 -07:00
Tom Brow
762adad54f fix(mobile): require lowercase hex in native verification (#8702)
Follows [#8657](https://github.com/block/buzz/pull/8657) with the three
optional notes from Carl's
[review](https://github.com/block/buzz/pull/8657#pullrequestreview-5703482477).

- **Lowercase hex only.** `verifySchnorrHex` now rejects uppercase
pubkey, message and sig hex, and `verifiedNostrEventFromJson` rejects an
uppercase sig, as the pure-Dart `bip340` decoder does and NIP-01
requires. (The `nostr` package already lowercases an event's pubkey when
it parses one.) Before this, a key holder could publish a second,
uppercase encoding of the same signature and it would verify.
`_decodeSecretKey` still accepts either case.
- **Bestie record.** `decodeDefaultAgent` now verifies with
`verifiedNostrEventFromJson`, like the other Solo `me_*` records.
- **Rejection tests.** The "other pubkey" row now recomputes the event
id, so it reaches the native Schnorr check instead of failing on the id.
Every row now asserts its `EventValidationReason`, a new row covers an
uppercase sig, and `verifySchnorrHex` is checked with uppercase pubkey,
message and sig.

## Testing

- Making either lowercase pattern accept uppercase again fails the
tests.
- `just mobile-check mobile-test` passed on this change before it moved
onto main. On main, the crypto and Solo/me tests pass.

buzz-review-completed

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

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:53:24 -07:00
Wes
fffd8ca957 fix(mobile): recover Solo sync silently instead of showing a retry banner (#8701)
## Problem

The mobile Solo section could display "Live updates stopped. Your saved
chats are still available." with a **Retry live updates** button. Any
failed Solo head refresh raised this banner. That includes the refresh
that runs on app resume before the relay has reconnected, and an 8s
history timeout. In those cases the live subscription itself was still
healthy. A later successful refresh did not clear the banner, so it
stayed up until someone tapped Retry.

## Change

- Remove the banner along with `MeSync.syncClosed` and
`meSyncWarningProvider`. Saved chats stay visible the whole time.
- `meGroupsProvider`: on app resume, an interrupted reader (terminal
CLOSED or a failed read) resubscribes on its own while the relay is
connected. Before the relay reconnects, the existing
rebuild-on-reconnect still owns recovery. A healthy reader keeps doing a
plain head refresh.
- Home's pull-to-refresh (`ChannelsPage` with the Solo sliver) now also
restarts the Solo reader. Before this change, that path refreshed
channels only, so in the shipped layout the banner was the only manual
recovery.
- The no-snapshot `_MeRetry` state and the offline-save warning are
unchanged.

## Validation

- `flutter analyze` is clean.
- The pre-push `mobile-checks` passed.
- The journey step in `offline_save_page_test.dart` closes the
subscription, cycles background and foreground, asserts a new
subscription, and checks that a newer event delivered on it is saved.
Removing the `subscriptionClosed` recovery branch fails it.
- The new `channels_page_test.dart` case drags to refresh with a Solo
sliver and asserts the Solo reader restarts. Removing the invalidation
fails it.
- Not yet exercised in the running app. That is checklist steps 2 and 3.

Reviewed by the Royal Court agents (provider lifecycle, recovery/UX,
independent challenge); the one blocker (Home pull-to-refresh) is fixed
here.

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

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
2026-10-29 14:33:35 -06:00
Tom Brow
ccfc1490e4 perf(mobile): verify signatures with native libsecp256k1 (#8657)
The app already verifies signatures in a few places: NIP-OA agent
profiles and owner attestations, Solo `me_*` records (agent inventory,
archived agents, assistants, sync), and pairing events. Each used
`nostr.Event.fromJson`'s pure-Dart BIP-340 verify, about 11.2 ms per
event on an iPhone SE (2nd gen, Profile). This routes them through
`verifiedNostrEventFromJson` and `verifySchnorrHex`, which keep the same
checks (timestamp range, canonical id, Schnorr signature) and the same
`nostr` exception types, with the signature check in libsecp256k1 (40
µs). NIP-OA owner authorizations are now signed with `signSchnorrHex`,
which removes the last pure-Dart Schnorr call in `lib`.

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

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

### Validation

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

buzz-review-completed

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

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

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

### Snapshots

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

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

---------

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

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

### Validation

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

buzz-review-completed

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

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

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

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

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

### Related issue

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

### Testing

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

Walkthrough recorded before the copy and recovery followups:


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

---------

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

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

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

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

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

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

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

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

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

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

buzz-review-completed

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

---------

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

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

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

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

### Validation

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

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

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

---------

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

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

### What changes

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

### Design notes

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

### Validation

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

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

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

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

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

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

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

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

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

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

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

buzz-review-completed

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

---------

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

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

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

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

### Validation

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

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

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

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

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

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

### Related issue

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

### Testing

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

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

Final Messages layout:

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

Switcher layering during iteration (before → after):

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

---------

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

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

**This PR makes two changes:**

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

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

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

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

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

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

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 17:35:12 -07:00
Tom Brow
bad9d26142 fix(mobile): push every message in your own Solo chats (#8615)
## Summary

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

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

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

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

### Related issue

None found.

### Validation

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

buzz-review-completed

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

---------

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

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

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

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

### Validation

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


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

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

---------

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

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

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

### Related issue

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

### Validation before the latest main integration

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


### Current review fixes and validation

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

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

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

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

### Staging testing and production configuration

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

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

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

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

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

Keep this PR draft while the listed validation gaps remain.

---------

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

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

### Behavior

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

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

### Validation

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

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

---------

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

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

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

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

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


buzz-review-completed

---------

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

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

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

### Validation

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

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

---------

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

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

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

### Related issue

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

### Testing

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

---------

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

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

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

### Validation

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

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

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

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


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

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


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

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

---------

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

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

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

## Validation

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

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

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

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

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

### Related issue
None found.

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

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

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

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

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

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

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

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

## Validation

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

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

---------

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

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

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

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

### Related issue

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

## Before and after

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

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

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

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

## Test

These extend the existing journeys, plus one new journey:

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

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

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

## Validation

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

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

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

---------

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

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

---------

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

### Related issue

Fixes #6124.

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

### Testing

Focused suites:

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

Result: 95 tests passed, 0 failed.

Additional checks:

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

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

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

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

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

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

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

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

---------

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

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

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

---------

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

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

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

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

---------

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

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

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

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

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

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

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

### Related issue

None found.

### Testing

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

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

---------

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

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

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

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

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

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

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

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

## Before and after

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

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

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

## Test

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

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

## Validation

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

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

buzz-review-completed

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

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

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


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

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

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

## Validation

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

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

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

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

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

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

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

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

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

## Review disposition

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

## Remaining draft gates

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



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


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

---------

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

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

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

### Validation

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

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

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

---------

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

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

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

### Related issue

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

### Validation

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



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

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

---------

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

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

Related issue: no matching open issue or PR found.

### Validation

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

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

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

### Create-channel compatibility

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

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

---------

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

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

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: fin <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-18 15:17:25 -04:00