mirror of
https://github.com/block/buzz.git
synced 2026-11-10 11:21:05 +00:00
e8a4bc15917e61dc0b8066e24c026dc6f3e7ce36
219 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2bbaa061d0 |
Enable mobile huddles with Codex agents (#9269)
Show the Huddle action in owned Codex agent DMs and invite the agent before connecting audio. Revalidate eligibility on starts/recovery, ask before inviting into an existing call, and distinguish pending invitations from connected audio peers with actionable timeout feedback. DM hangup leaves shared room cleanup to the relay so another owner device stays connected. Pairs with https://github.com/block/buzz-app/pull/3048. Desktop and the Codex agent must remain running. Validated: 4,559 mobile tests, analysis, formatting, push hooks, and independent source review. Live phone-to-desktop audio and human trial remain pending. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> Co-authored-by: Tom Brow <tomb@block.xyz> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
52cb6818fa |
perf(mobile): keep remote images on disk and show small ones as thumbnails (#9158)
## Summary The mobile app now saves remote images on disk. After a relaunch, avatars, custom emoji, message attachments and agent artwork show immediately. The app does not download them again. Small images on a relay now load only the relay's thumbnail. The app never downloads the full-size upload to show a small image. ## Why Before this change, the app kept remote images only in memory. Each cold launch downloaded every image again. On a slow connection, attachments showed a "Loading image" placeholder for a long time. Also, when a relay had no thumbnail for an image, the app downloaded the full-size upload. That upload can be up to 20 MiB, for an avatar that is a few dozen points wide. ## What changes ### Images on disk - `MediaImageProvider` reads and writes a `MediaDiskCache`. Each account in each community has its own cache, at `<root>/communities/<community id>/media/<signer pubkey>`. The cache is built on `flutter_cache_manager`, with a JSON index. - On iOS, the root is the app group's `Library/Caches` (from a new `buzz/app_group` method channel). App extensions can then read the same cache. On other platforms, the root is the app cache directory. - Each cache holds a maximum of 250 MB. When it is full, the least recently used images go first. `MediaCacheIndex` measures each file when it writes it, because `flutter_cache_manager` limits only the number of files. Every disk read marks the file used, and every write schedules eviction, including a refresh that replaces bytes the package already holds in memory (by default the package does neither for those). - The cache does not save an image that is larger than 5 MiB. The app still shows that image from memory. Thus, one large image cannot push out all the others. - Relay media URLs contain a content hash, so their bytes never change. The cache keeps them until it evicts them. For other URLs, the cache shows the saved image, and then gets a new copy in the background after one week. A maximum of two of these refreshes run at the same time. The app decodes a refreshed image before it replaces the saved copy. - When you remove a community, the app deletes its cache. When a community gets a new signing key, the app deletes the media of the old account. At launch, the app also deletes caches of communities and accounts that are no longer present. - Startup does not wait for the cache. The cache directory resolves in the background. Image reads wait for it for a maximum of 3 seconds. ### Thumbnails for small images - A small relay image loads the relay's WebP thumbnail. If the relay has no WebP thumbnail, the image loads the JPEG thumbnail. If the relay has neither, the widget shows its placeholder (initials for an avatar, an empty tile for artwork). - Native avatars drawn by UIKit (the navigation bar, the community icon on the create button, and the New DM and Add Members pickers) use the same choice, through a new `smallMediaImageProviders`. They try the same thumbnails, fall back the same way, and show initials when neither thumbnail exists. Before, they downloaded the full-size upload and only decoded it small. - Larger layouts and the full-screen viewer load the full-size upload, as before. ### Agent artwork - Preset agent artwork (the tray, flight, video placeholder, avatar hero and the avatar editor's blurred backdrop) now loads through `MediaImage`, so it uses the disk cache too. - The create-agent and empty-bestie pages warm the same `MediaImageProvider` that their widgets read, through a new `mediaImageProviderFor(ref, url)`. - No code in `mobile/lib` uses `Image.network` or `NetworkImage` now. ## Demo A cold relaunch (the app is stopped, then started) into a synthetic "QA demo" Solo chat that holds a few generated images. Shown at half speed. Before: the attachment shows a "Loading image" placeholder while it downloads. https://github.com/user-attachments/assets/107f43a3-63d0-40c3-bd80-4ea9d7897a97 After: the attachment shows with the chat, from disk. https://github.com/user-attachments/assets/d3985bb2-838c-4efa-93cb-44cef51f677d ## Tests - `test/shared/relay/media_disk_cache_test.dart` follows one journey through the real provider and a real cache in a temporary directory. The first launch downloads and saves. An offline relaunch shows the full-size image, its thumbnail and an external image from disk, and the oversized image goes back to the network. Eight days later, only the external image refreshes. Removing the community deletes its directory. - A second test checks the order of eviction when the cache is full, that a file read from disk is evicted after files that were not, and that replacing a file's bytes after eviction last ran evicts again when the cache goes over budget. - `community_provider_test` checks that launch deletes orphan media directories, that a new signing key deletes only the old account's media, and that removing a community deletes its media. - `avatar_image_test` checks that a missing WebP thumbnail falls back to the JPEG thumbnail, and that an avatar with no thumbnail shows its initials. - `native_avatar_image_test` checks the same for native avatars: a WebP 404 paints the JPEG thumbnail, the full-size upload is never requested, a relay with no thumbnail paints initials and is not asked again, and any other failure stops at the initials. - `flutter analyze` and the full mobile test suite pass. - Automated app runs on an iPhone 17 Pro simulator (iOS 27): the channel list, a DM, Home, the Agents grid and an agent details sheet show their images. After a relaunch, the images came from disk and the app wrote no new files. In the "QA demo" chat, a new image from the app showed immediately. - Automated run of the native-thumbnail fix on an iPhone 17 Pro simulator (iOS 27): the native New DM picker shows photo avatars, and the cache gained only relay `.thumb.webp` entries for them, no full-size uploads. After a cold relaunch, the picker showed the same avatars within about 1.5 seconds, and the cache directory gained no files. The avatar editor backdrop was not exercised in the app. The test community has no remote agents, and the backdrop tests cover only emoji and picked-image selections. ## Before merge - [x] Automated simulator run of this head: avatars and agent artwork show their thumbnails, and a cold relaunch shows images from disk. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost> |
||
|
|
40cfa72cd4 |
mobile: add follow and remind actions to inbox row menu (#9142)
Long-pressing an Inbox row opened a sheet with only **Mark as
read/unread** and **Open conversation**. To follow a thread or set a
reminder, you had to open the conversation and long-press the message
itself.
The row sheet now also offers:
- **Follow thread / Unfollow thread**: acts on the conversation's thread
root, or on the message itself for a top-level row (the same rule the
message actions sheet uses). It's hidden for Solo chats, which have no
threads, and for grouped DM conversations, which have no single thread
to follow.
- **Remind me**: opens the existing "Remind me later" sheet for the
row's latest message and publishes the same kind-30300 reminder. If that
message already has a pending reminder, the new time reschedules it: the
event reuses its `d` tag (an addressable replace) and keeps its note, so
you don't get a second reminder. It loads the reminder list first,
because otherwise the list loads only with the Reminders filter. It's
hidden when signed out, like the message sheet.
The sheet is now scrollable so the four rows fit on short screens and at
large text sizes (without it, the new test overflowed at the default
test surface).
### Demo
Long-press a channel mention row in the Inbox, follow the thread, reopen
the sheet (now **Unfollow thread**), then set an "In 1 hour" reminder.
The rows are agent test chatter from a pilot community.
https://github.com/user-attachments/assets/ad94e5f2-1048-4585-9663-145e814892e3
### Validation
- Added tests: one Activity page journey that follows and unfollows a
thread row, follows a top-level row, then sets a reminder through the
sheet and checks the published target (event, channel, preview). Further
tests cover rescheduling an existing pending reminder (same `d` tag,
note kept, "Reminder updated"), hiding Follow in a Solo chat, and hiding
Remind me when signed out. The `test/features/activity`,
`test/shared/reminders` and message-actions suites pass.
- Re-run on the branch head (
|
||
|
|
733d164e80 |
mobile: persist user profiles so names and avatars render on launch (#9116)
On a slow connection, mobile shows npubs and blank avatars on every cold
launch and community switch, because user profiles live only in memory
and are refetched from the relay each time.
This persists the verified profiles the app has already seen, per
identity and relay, so names and avatar URLs render on launch before the
relay answers.
### Behavior
- Profiles are stored as a disposable cache in the same shape as the
existing channel list snapshot: one SharedPreferences entry per identity
and relay, versioned, capped at 2000 profiles and 2 MB (oversized inline
avatars are skipped), deleted on sign-out and by orphan cleanup.
- On launch the cache restores the snapshot synchronously. Each restored
profile is still revalidated against the relay on its first lookup that
session. `get` and `preload` return the restored data right away instead
of waiting on the network, and a failed revalidation retries on the next
lookup.
- Identity-sensitive callers keep their guarantees: `resolve()` and
`refresh()` treat restored profiles as unconfirmed and query the relay,
including while a background revalidation is still in flight.
- Writes are debounced (2 s) and flushed when the community scope
changes.
Not in this PR: avatar image bytes still use only Flutter's in-memory
image cache, so avatar images still download after a cold launch (names
and URLs no longer wait). The NSE keeps its own app-group profile cache,
which this doesn't share yet.
### Demo
Cold relaunch into a synthetic Solo chat ("QA demo"), iPhone 17 Pro
simulator, same clip timing on both builds. To make the gap visible on a
fast connection, both builds had a local-only 5 s delay on profile
fetches (not part of this PR). The channel's system lines show the
member name once its profile is known and a shortened npub until then.
Before (main): "npub1...created this channel" for about 5 s until the
relay answers, and the agent avatar starts as a letter placeholder.
https://github.com/user-attachments/assets/de558521-a210-4ac6-b216-d77260036985
After (this branch): "tomb created this channel" and the agent avatar
render from the first frame.
https://github.com/user-attachments/assets/093b30fc-ec24-4f26-8535-7cc1791d2e44
### Validation
- Added tests: a journey across three simulated launches (relay
reachable, offline, reachable with a rename) covering restore,
non-blocking revalidation, retry after failure, a concurrent `resolve()`
during revalidation, and sign-out removal; plus storage scoping, bounds,
writer retirement and corrupt-data handling. The revalidation and
in-flight guards were each removed once to confirm the journey fails.
- Full mobile check and test suite pass locally.
- iOS simulator (iPhone 17 Pro, Remote Agents Pilot as Tom's Builderlab
account): after browsing, the snapshot held 16 verified profiles (6.4
KB), and cold relaunch rendered normally. Offline cold launch was not
exercised on the simulator.
- iOS simulator, synthetic Solo chat, main vs this branch (see Demo):
with profile fetches artificially delayed 5 s, main showed shortened
npubs in the channel's system lines until the relay answered, and this
branch showed the restored names on the first frame.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
65e864301d |
feat: send a User-Agent on relay WebSocket upgrades (#9246)
Relay WebSocket clients in this repo now send a `User-Agent` of the form `<name>/<version>` on the upgrade request. The version comes from the build. | Client | User-Agent | |---|---| | `buzz-acp` | `buzz-acp/<crate version>` | | `buzz-cli` | `buzz-cli/<crate version>` | | `buzz-ws-client` (default) | `buzz-ws-client/<crate version>` | | `buzz-pairing-cli` | `buzz-pairing-cli/<crate version>` | | `buzz-audio-client` (default) | `buzz-audio-client/<crate version>` | | mobile (relay and huddle sockets) | `Buzz-Mobile/<app version>` | - **`buzz-ws-client`**: every connect path sends the default. A `User-Agent` in the caller's `connect_with_headers` headers replaces it, so other consumers can identify themselves without any new API. `buzz-cli` uses this to send its own name. - **`buzz-audio-client`**: keeps a `User-Agent` already on the host-supplied request, and adds its default otherwise. - **Mobile**: `openRelayWebSocket` sets the HTTP client's `userAgent`, which also covers the huddle transport. Startup waits up to 500 ms for the app version from `package_info_plus` before the app runs, so the first socket carries it; if the lookup fails or times out, the value is `Buzz-Mobile`. Each client has a test asserting the header on the upgrade request. --------- Signed-off-by: Will Pfleger <wpfleger@block.xyz> Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> |
||
|
|
12f3d51001 |
feat(mobile): one Builderlab per build, internal and public distributions (#9224)
The mobile app can now be built as a public build or an internal build, set explicitly with `BUZZ_DISTRIBUTION=internal|public`. Each build trusts exactly one Builderlab (`BUZZ_BUILDERLAB_URL`), and sends that Builderlab's relay assertions only to the relays in `BUZZ_BUILDERLAB_RELAYS`. The three settings are set together or not at all; with none set, the app is a Nostr-only development build. The decision is recorded in `docs/adr/0001-internal-and-public-mobile-builds.md`, and the terms are defined in `CONTEXT.md`. Before this change, one build supported a public Builderlab and an organization Builderlab at the same time, with two sessions. An organization's members could reach public communities from the organization's app. ### Behavior - **Public build:** QR pairing, email sign-in and multiple communities are unchanged. It has no knowledge of any organization. An organization's relay already rejects clients that can't present its Builderlab's assertion, and the public build relies on that. - **Internal build:** the first screen has a single **Sign in** button that goes straight to the organization's single sign-on and then joins the organization's community. There is no QR pairing, community link or community creation, and the community switcher is hidden. Only the exact relay hosts listed in `BUZZ_BUILDERLAB_RELAYS` are reachable. - **Identity (internal):** a member whose account has no Nostr identity yet gets a new key on first sign-in; the first relay assertion binds it, and the app backs it up to the organization automatically. A member whose account is already bound uses that key from this device, or restores it from the organization's backup. If neither is possible, Sign in says it couldn't restore the identity and saves nothing. - **Sign-out (internal):** a destructive **Sign out** row at the bottom of Settings, the build's only way out. It removes the community, closes Settings and returns to Sign in. The Builderlab session is cleared from storage immediately and can never be reused to sign back in. The relay push-stop keeps the retired credential in memory only, for up to 10 minutes, and uses it only to get an assertion for a key that is already bound. It is a best-effort request. - **Upgrade to an internal build:** a saved public Builderlab session is cleared. Communities on other relays are hidden, not deleted, and their keys stay on the device. - `BUZZ_HOSTED_RELAY_AUTH` is unchanged here. ### Screenshots (internal build) | Sign in | Community menu | Settings | | --- | --- | --- | |  |  |  | Gateway-side push revocation, which needs no Builderlab session, is planned as a separate PR. ### Validation Added tests: - An internal-build journey: one Sign in creates and binds a key, and a retry after a cancelled join reuses it. The app uploads that key's backup itself. Sign out from Settings closes it and returns to the landing, which ends the session, and Sign in then restores the same identity from the backup. Communities on other relays stay stored but hidden. - Internal Sign in for a bound account: the key on this device, a key restored from backup, and no backup (clear error, nothing saved). Each guard was shown to fail when removed. - Organization relay assertions: assertions go only to exact listed hosts, not subdomains. After sign-out, only a push stop for a bound key gets an assertion, and nothing can sign back in. Proven to fail without the fix. - Builderlab build setting parsing. CI runs the internal and public journeys in their own lanes with those settings. The full suite passes in the default configuration. The full suite isn't meant to run under the internal configuration, because most fixtures use relays that an internal build hides. Automated full-app run on an iPhone 17 Pro simulator at the current head: - Public build, upgraded in place over a signed-in install: home loads, a channel opens and a message sends. - Internal build: a fresh install shows only Sign in. With a saved organization community, the community menu has no Switch community, and Settings → Sign out → confirm returns to Sign in. Real single sign-on can't run in the simulator. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost> |
||
|
|
794ee64183 |
fix(ios): mint hosted NIP-FI assertions in the notification extension (#9228)
Share the hosted Builderlab session with the iOS notification extension so it can mint hosted NIP-FI assertions. The extension queries a hosted relay without an assertion first and mints only after a NIP-FI challenge, for a key the account is already bound to. Credential headers never follow redirects, and flag-off builds clear a previously shared session. Signed-off-by: Tom Brow <tomb@squareup.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
b58ee08404 |
fix(mobile): request hosted relay assertions only when NIP-FI is required (#9225)
Port the desktop NIP-FI requirement rule to mobile: hosted relay connections request an account assertion only when the relay's NIP-11 document requires NIP-FI, so communities that have not enabled it keep connecting with BUZZ_HOSTED_RELAY_AUTH on. Signed-off-by: Tom Brow <tomb@squareup.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
597a8d1970 |
feat(mobile): rename Activity and add header filters (#9213)
The UI-only part of #9186 by @klopez4212, split out so it can land without the Activity sync changes. - The Inbox page becomes Activity, with header controls instead of menus: a type menu (All, Threads, Mentions, DMs, Reminders, Drafts), a sender menu (Anyone, People, Agents) and an Unread toggle. All of them filter the feed the app already loads. - Activity and Search get a top edge fade. Bulk mark-as-read and the "New" divider are gone. - DM thread replies group with their DM row, so the DMs filter includes them. Left out, for a separate sync PR: the Activity history cache, subscriptions to every joined channel, the refresh coalescer, thread-follow evidence, Reactions and Solo rows, and the read-state changes. The Activity provider and read-state writes are unchanged from main. One fix on top: the iOS filter labels pass an unscaled font size, since the native button already applies Dynamic Type. Before, large text was scaled twice. ### Validation - Kenny's added tests for the header controls (`tabs_journey`) and `TopEdgeFade`, adjusted for the removed Reactions option. Added tests for the DM filter, marking a DM row with replies under two thread roots read, and sender filtering per event. - Full mobile suite: see the latest CI run. Locally, 11 push and notification-toggle tests fail identically on an unchanged checkout. - Automated simulator run (iPhone 17 Pro, iOS 27) of a build combining this PR with its sibling split: Home rows measure 44 pt, the bell opens Activity and shows its dot after a row is marked unread, type/sender/Unread filters each narrow the feed, a row opens its conversation, Search results scroll under the fade, and filter labels fit at the largest text size. Screenshots are in Buzz rather than here because they show internal test data. Android was covered only by widget tests. ### Before merge - [x] Automated simulator run of Activity filters, row taps, read and unread, and Search, with screenshots. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
b894fb02ff |
fix(mobile): show transparent avatars without a black background (#9105)
## Summary Transparent PNG and GIF avatars render on a black background in mobile avatars and iOS notification avatars. Both load the relay's small thumbnail, which was always a JPEG with no alpha channel. [block/buzz#9003](https://github.com/block/buzz/pull/9003) makes relays write a `{sha}.thumb.webp` thumbnail that keeps transparency, alongside the `{sha}.thumb.jpg` that every relay writes. This PR loads the WebP first. - `MediaImage` with `preferThumbnail` tries `.thumb.webp`, then `.thumb.jpg`, then the original. Only a 404 moves on to the next source; any other failure still shows the error widget. Missing thumbnails are remembered as before, so later builds skip straight to the source that exists. - `BuzzNotificationAvatarLoader` follows the same order for notification avatars. Relays without 9003, and images uploaded before it, keep rendering from the JPEG as they do today. ### Screenshots A transparent PNG avatar rendered by `MediaImage` on the same relay, before and after (widget harness on an iOS simulator). | Before | After | | --- | --- | |  |  | ### Related issue AIDA-2308 (internal). The desktop counterpart is [block/buzz-app#2477](https://github.com/block/buzz-app/pull/2477). ### Testing - `missing thumbnails fall back to the JPEG, then the original` (`avatar_image_test.dart`): when the relay 404s every thumbnail, the avatar requests the WebP, then the JPEG, then the original. When the WebP fails with a 500, the avatar shows its fallback and requests neither the JPEG nor the original. - The relay thumbnail journey tests run against a relay that 404s `.thumb.webp`, so they cover the older-relay path end to end. - `BuzzNotificationAvatarLoaderTests` checks the WebP-then-JPEG thumbnail order for relay media, and that non-relay media gets no thumbnails. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: John Tennant <jtennant@squareup.com> Co-authored-by: Simone <0828be588d5c9c4d092c5b1cf01761d5aef63d6b104807fd8e8284d517dd1a99@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
fbfbf3d64f |
test(mobile): drop the video preview step removed with native frame loading (#9164)
Fixes red main. https://github.com/block/buzz/pull/9144 added a step to the expired-assertion journey in `native_media_headers_test` that loads a posterless video preview through `videoPreviewFrameLoaderProvider`. https://github.com/block/buzz/pull/9138 merged around the same time and replaced native first-frame loading with a static placeholder, removing that provider, so `flutter analyze` now fails on main with `Undefined name 'videoPreviewFrameLoaderProvider'`. Posterless previews no longer fetch anything natively, so the step has nothing left to guard. This deletes it along with its fake video player, restoring the test file to its state before #9144. ### Validation - `just mobile-check` and `just mobile-test` pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> |
||
|
|
fc85b0d154 |
test(mobile): check posterless video previews renew the relay assertion (#9144)
Follow-up to https://github.com/block/buzz/pull/9134, which made posterless video previews and voice notes renew an expired relay assertion before handing headers to the native player. The voice-note path got a test there, but nothing failed if the video preview went back to the cached-only headers. This extends the expired-assertion journey in `native_media_headers_test` with one step: the real `videoPreviewFrameLoaderProvider` loads a relay video through a fake native video player, which records the headers it is created with. The player must receive both the media `Authorization` proof and the renewed `Nostr-Federated-Identity`. ### Validation - Added tests: the video-preview step above. Switching the loader back to the synchronous cached headers fails it. - `flutter analyze` is clean and the full mobile suite passes (4508 tests). - Test-only change, so no app run. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> |
||
|
|
c39ee507b2 |
fix(mobile): find verified people who have no profile (#9086)
## What changes
Mobile now finds a person who has a relay-verified profile (NIP-FI, kind
30382) but never published a kind:0 profile. This happens, for example,
when someone skips profile setup. The relay's `POST /profiles/search`
already finds such people by their verified name. Each one comes back as
a kind:30382 head with no kind:0. Mobile kept only kind:0 results, so
these people did not appear.
- `RelaySessionNotifier.searchProfiles` keeps kind:30382 heads from the
endpoint.
- `VerifiedProfilesNotifier.searched` turns a search page into people:
- If the relay authority is not known yet, it first waits for NIP-11
discovery. Otherwise, a search that finished first would drop these
people for that query.
- It checks each head's signature with the directory's off-isolate proof
checker.
- Proven heads go into the verified directory, so the rows show verified
names right away.
- `searchedPeople` (in `verified_profile.dart`) builds one row per
person, in the relay's order:
- If the person has a kind:0, the newest kind:0 is used, as before.
- If not, it uses the proven head that the authority signed, provided it
parses as an active verified profile.
- These cases add no row:
- a head signed by another key;
- a head that names the relay key but has a bad signature;
- a retraction;
- a malformed subject.
- **Behavior change for all mobile mentions:** every verified person now
matches in mention ranking by their verified full name and username, as
well as by display name, including people who have a kind:0. The relay's
search matches these same three names. Without this, a person whose
display name is their username (`glilienfeld`, full name "Gabi
Lilienfeld") came back from the relay for "gabi", and then the local
filter dropped them.
- Callers:
- Mention search, which also serves Add members and New DM.
- The user rows in global search, through `directoryUsersFromSearch`.
The desktop app has the same fix for Manage members:
https://github.com/block/buzz-app/pull/2554
## Screenshots
These come from the real Add members sheet on an Android emulator. Only
the relay session, verified-profile discovery and the member actions are
faked. The names are made up. Jordan Park has a kind:0. jrivera has only
a verified profile, with the full name "Jordan Rivera". The search is
"jordan". The row shows the verified display name, which here is the
same as the username.
| Search finds jrivera | After tapping + on jrivera |
| --- | --- |
| <img width="300" alt="jrivera is found next to Jordan Park"
src="https://github.com/user-attachments/assets/d93b77b1-1bc7-4d09-afc1-f359616a192a"
/> | <img width="300" alt="jrivera is selected to add"
src="https://github.com/user-attachments/assets/ce6c7a71-b098-434e-8b0e-ed0de8d1a5a2"
/> |
## Tests
- `add_members_sheet_names_test.dart`: a journey in which Gabi has
`display_name` = `glilienfeld` and `full_name` = "Gabi Lilienfeld", and
NIP-11 discovery is slow.
- While discovery is pending, typing "gabi" shows nothing yet.
- When discovery finishes, Gabi appears with no further keystroke.
- A head from another key does not appear, and neither does a head that
names the relay key but has a bad signature.
- **Add member** adds Gabi's key.
- I removed each fix in turn: the full-name alias, the signature proof,
and the wait for discovery. The journey fails each time, for the
expected reason.
- `verified_profile_test.dart`: a table of `searchedPeople` cases.
- A kind:0 wins over a head, and the newest kind:0 counts.
- These find nobody: another signer, a retraction, an uppercase subject,
a non-profile kind, and any head when the authority is unknown.
- `relay_session_test.dart` and `channel_management_provider_test.dart`:
rows for the kept heads and the directory row.
- `flutter analyze` is clean, and the full `flutter test` passes on
6efef79cc: 4,462 tests.
**Note:** mobile on `main` does not compile at
|
||
|
|
7ac16b2917 |
perf(mobile): memoize npub labels (#9137)
Opening a big channel on mobile stalls while member labels resolve. On
an iPhone 12 mini (wired Profile build of main
|
||
|
|
1ce831645c |
mobile: stop re-parsing messages on every resume (#9140)
Resuming the app with a busy channel open stalled for several frames (149, 136, 109 and 102 ms on an iPhone 12 mini), because every visible message re-parsed its markdown, repeatedly. The app widget rebuilds on every lifecycle change, and each rebuild created new light and dark `ThemeData`. A new `ThemeData` never equals the previous one, so `MaterialApp` animated between two identical themes for its full theme transition. Each frame of that animation produced a new markdown theme, and every mounted message re-parsed its markdown in response (`_MdWidgetState.didChangeDependencies` and `didUpdateWidget`). In a 15 s resume on a large channel that re-parse took 373 ms of main-isolate time. The themes are now built only when the community accent changes, so a lifecycle rebuild keeps the same theme objects and starts no transition. ### Validation - Added test: `an app rebuild keeps its themes and markdown theme` rebuilds the app and checks that the theme and the markdown theme stay identical. It fails without the change. - Full mobile suite: only the known failures that also fail on main (notification toggle, push bootstrap, push bridge, push lease revocation outbox). - Automated simulator run (iPhone 17 Pro, debug build): opened a channel with markdown messages, backgrounded and resumed the app several times, and switched between light and dark appearance while the app was open. Messages render, resume works, and the app follows appearance changes live. A channel's long-press action sheet keeps its light background after a switch to dark mode on both this build and main, so that is unrelated to this change. - Profile evidence (iPhone 12 mini, Profile build, before the change): the stalls and re-parse samples above came from a resume with a large channel open. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> |
||
|
|
1b9b307176 |
fix(mobile): renew expired assertions for native media and drop retired-session avatars (#9134)
Follow-up to [#9069 media auth](https://github.com/block/buzz/pull/9069), which merged at its approved head. This PR carries the fixes for the last review round on that PR. ### Changes - **Native media reads renew an expired assertion** (Carl's [recommended blocker](https://github.com/block/buzz/pull/9069#discussion_r4485183400)). Voice notes and posterless video previews hand headers to native code. They now use `freshNativeMediaHeadersProvider`, which awaits `HostedRelayAuth.headersForUrl` and so asks for a new assertion once the cached one has expired. Before, while the socket stayed up they sent none and the relay refused the read. If the renewal fails, the read falls back to cached headers and the relay decides. The voice-note player's `headers` callback is now `FutureOr`. Both the iOS authenticated download and the Android native load await it, then recheck their generation, so a cancel during a slow renewal never starts the stale load. - **Notification avatars drop a retired organization session** ([review thread](https://github.com/block/buzz/pull/9069#discussion_r4485125609)). When an avatar download finishes after the organization session that issued its assertion was replaced, BuzzPushKit removes the cached response and refuses the avatar. - **Test:** cached reads never present a signed-out or replaced account's assertion ([optional note](https://github.com/block/buzz/pull/9069#discussion_r4485183412)). Not changed: native reaction-detail avatars still use the cached assertion. After it expires they fall back to initials. ### Validation - Each new test was checked to fail with the code it guards reverted: - `replacedOrganizationSessionRefusesAvatar` (BuzzPushKit): a session replaced mid-download must be refused. - `fresh native media headers renew an expired assertion`: covers renewal and the fallback when renewal fails. - `remote voice notes renew an expired relay assertion`: a journey through `VoiceNoteAttachment`; it fails when the attachment is switched back to the synchronous provider. - `Android defers remote auth until playback starts`: now cancels during a slow renewal and expects no native load. - `an issued assertion serves cached reads for the relay`: now signs out and restores a replacement account. It fails without the account check. - `swift test` (69 tests) passes. The full mobile suite (`just mobile-install mobile-check mobile-test`) passes with these commits on main as of [#9069 media auth](https://github.com/block/buzz/pull/9069). - The automated full-app simulator run from [#9069 media auth](https://github.com/block/buzz/pull/9069) covers the surrounding flows. This change is limited to the header source for voice notes and video previews, and none of the test communities enforce the hosted assertion, so a simulator run would not show the difference. No end-to-end run against a relay that enforces the assertion. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
97ede701c0 |
fix(mobile): send hosted relay assertion from native media and tombstone paths (#9069)
Follow-up to https://github.com/block/buzz/pull/9056, from an audit for other places that miss the hosted relay assertion. Hosted and organization relays require `Nostr-Federated-Identity` on every non-exempt HTTP request and WebSocket upgrade. The Dart `HostedRelayHttpClient` adds it, but anything that downloads or connects outside that client has to add it itself. The notification service extension and the iOS emoji picker both missed it, and these places do too: - **Voice notes** (`just_audio`, mainly Android) and **video preview first frames** fetch media natively with only the Blossom `Authorization` header. Both now use `nativeMediaHeadersProvider` from https://github.com/block/buzz/pull/9056. - **Push-lease tombstones**: removing a community, and the durable revocation outbox, open a one-off relay socket with no headers. A hosted relay refuses the upgrade, so the lease is never revoked and the outbox keeps retrying. Both now ask `HostedRelayAuth` for that community's own assertion, because the community being removed may not be the active session. - **Notification avatars** (iOS notification service extension): relay-hosted sender avatars were fetched with Blossom auth only. The loader now takes the same `BuzzRelayAssertionIssuer` the resolver uses for `/query`. Signed requests already refuse redirects. The iOS Me-chat agent picker also fetched avatars with no headers, but https://github.com/block/buzz/pull/9079 removed that native screen, so this PR no longer touches it. ### Validation - Added tests, each checked to fail when the code it guards is reverted: - A queued push-lease revocation and a community-removal tombstone both send `Nostr-Federated-Identity` on the WebSocket upgrade, as background requests. Runs against a local relay socket that handles NIP-42 auth. - A notification avatar on an organization relay is downloaded with the issued assertion (BuzzPushKit). - Issuing for a second community keeps the first one's cached assertion. A background request records no failure, never backs up the key, and its failure never reaches the active community's reconnect waiting on it. - Background issuance never binds an unbound account to its key, bypasses the shared assertion cache (each attempt gets a fresh assertion, and foreground use never inherits one that skipped the key backup), and its failure is never handed to a foreground caller waiting on it. When it can't get an assertion it sends none, so a non-enforcing relay still accepts the tombstone. - A notification avatar whose organization session is replaced mid-download is refused and dropped from the cache (BuzzPushKit). - Voice notes and posterless video previews renew an expired assertion before the native fetch, and fall back to cached headers if renewal fails. A voice-note attachment journey binds the production provider. - Cached reads never present a signed-out or replaced account's assertion. - `flutter analyze` is clean. `swift test` passes for the BuzzPushKit avatar loader tests, and `flutter build ios --simulator --debug` succeeds. - Full mobile suite (`just mobile-check` and `just mobile-test`) passes at 2c84940832. - Automated full-app run on an iOS simulator (debug build of this PR before it was rebuilt on main, signed in to the internal Builderlab community with two more communities saved). Passed: launch and channel list, image attachments and avatars in a channel, New chat sheet kept open for about 80 seconds before the agent list was opened (all agent avatars loaded, so headers are requested per download), dismiss and reopen, background and resume, switching to a second community (its icon and avatars loaded), and removing a community. The native New chat sheet is unreachable in the normal flow on this branch (a new chat opens the Flutter draft page; the native sheet only opens when resuming an unfinished chat), so that check ran with a temporary local bypass that was reverted. Limits: none of these communities requires the hosted relay assertion in this build, so the run shows the Swift-to-Dart header bridge, the per-relay cache and the tombstone path cause no regression, not that the assertion is sent or accepted. No voice note, video preview or deleted message was reachable. Screenshots are omitted because they show internal channel content. - Limitations: no end-to-end run against a hosted relay. Simulator sign-in to a hosted relay needs SSO, so a hosted-relay run needs a device build. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> |
||
|
|
9aa70124ba |
perf(mobile): validate each verified profile once, not on every publish (#9101)
Opening or resuming a big channel spent about 210 ms of UI-thread CPU validating verified profiles, measured in Dart CPU samples on an iPhone 12 mini (Profile build) across two separate channel-resume captures (213 ms and 211 ms in `VerifiedProfilesNotifier._snapshot`, nearly all of it in `parseVerifiedProfile`). Every publish rebuilt the profile map by validating every known head again, and a channel with many members publishes once per subscription batch as each lands, so the work grew with members times batches. Each subject's parse is now kept while its head and the authority stand. Only `checked` changes between publishes (rebatching unchecks and rechecks subjects), so it is applied to the kept parse without validating again. ### Validation - Added `a later batch reuses profiles already parsed`: after a second subject rebatches the first, each head is validated exactly once. It fails when the kept parse is ignored. - `flutter analyze` is clean and the full mobile suite passes. - Same phone and scenario after this change: `parseVerifiedProfile` no longer appears in the resume CPU samples, and main-isolate message handling over the 15 s after resume dropped from 604 ms to 491 ms (one run each). Frame build and layout time on resume is unchanged; that is separate work. - Automated app run on the same phone with this change: Home, search, the large channel and its resume after the reconnect grace period. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> |
||
|
|
c848b6dcad |
fix(mobile): offer a new identity beside saved keys on unclaimed accounts (#9107)
## Summary This fixes a dead end in mobile Builderlab sign-in. A new account (unbound, with no backups) on a device that already had saved identities could only pick one of those identities. Any saved key forced the picker, and creating a key required the device to have none. If the service then definitively rejected binding the chosen key, the app showed "Let's try that again" with no way forward: the chosen key stayed in the account's slot, so every retry and every launch re-sent the same rejected bind. For an unclaimed account, the picker now also offers **Create a new identity**: - Creation writes only this account's empty identity slot, so every saved and device key is kept. - Creation is offered only when every current device key was among the choices shown, so a new identity never silently passes over a key the user didn't see. - Bound accounts, accounts with backups, pairing and pending import never offer it. When binding a chosen key is definitively rejected, the blocked screen offers **Choose a different identity**: - It appears only for a 400, 404, 409 or 422 from `/verify` in the current attempt, for the current account and pubkey, and never during pairing, whose transferred key is the only choice. Evidence from an earlier attempt is cleared before each bind. A challenge rejection, a 403, a 503 or any other ambiguous failure offers nothing. - Before releasing the slot, it rechecks the account: still unbound (`/current` and status), no backups, the same subject, and a slot that still holds that pubkey without a receipt. - It releases the slot only when another durable copy of the same key exists (the device inventory or another account slot), then verifies the slot is empty. No key is deleted: only the account's pointer to the rejected choice is cleared. A failed or refused release keeps the slot and the choice, so it can be retried in place or after a restart. - After release, the picker runs again and offers the saved keys plus creation. The recovery code lives in `hosted_flow_identity_recovery.dart`, a part of `hosted_flow.dart`, to stay within the mobile file-size limit. ### Related issue None found. ### Testing **Physical iPhone, a signed release build of `1fb633d55` installed over the existing app (keychain and saved identities kept):** a saved identity was rejected, then Choose a different identity, the picker, Create a new identity and Join your community. Joining then completed. This head differs from `1fb633d55` only in the guards for leaving, pairing and a failed or refused release listed below, which this journey does not reach, and in no UI code. The status of that phone's original rejection was not captured, so it is not confirmed to be one of the rejections above. The 409 case is covered by tests. **Tests (red on `main`, green here):** - Identity service and store: creation beside offered keys; refusal for unseen or partly offered device keys; release when another copy exists; refusal for a sole copy, a binding, a backup, a receipt, another account or an unusable recheck; failed delete and retry; a cancelled attempt. - Onboarding journeys: create beside saved keys; select, rejected bind, restart, choose a different identity, create and bind; ambiguous 503 and 403 failures; challenge rejection and stale evidence. - Release boundaries: an account switch at `/current`, at status, or between the recheck and the delete; cancelling between the recheck and the delete; leaving the picker before a late creation; an unreadable device inventory; leaving while a release is in flight; a failed delete retried in place and after a restart; a release refused because the inventory is unreadable, then retried in place; a rejected paired key. Each keeps every key, leaves the new account's slot untouched and leaves a cancelled flow idle, and a retry converges. - Mutating the creation guard, the generation check, the binding recheck, the store's receipt check, the account and cancellation checks before the delete, or the device-inventory error handling fails a test. #### Screenshots Physical iPhone at `1fb633d55` (frames from a screen recording; saved public keys redacted): | Rejected bind | After Choose a different identity | After Create a new identity | | --- | --- | --- | |  |  |  | Widget-test renders of the same screens (the real `HostedFlow` against a mocked account service, with a fixture key; the square at the top left is the test renderer's placeholder for an asset): | Picker on an unclaimed account | Rejected bind | | --- | --- | |  |  | 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: John Tennant <jtennant@squareup.com> Co-authored-by: Xander <ff30c859b96bd5233ffaf5d071200458f1274bd0dab03589214cf1055198491d@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
cf8fbbba55 |
fix(mobile): keep verified names on screen in channels with over 1,024 members (#9111)
In a channel with more members than the verified-name interest limit (1,024), returning to the app made member names flip: a verified name changed to a profile name with a collision suffix (for example "Ankit · ykn9") or to an npub, then back once a refetch landed. Seen on an iPhone 12 mini in a 2,004-member channel; instrumented logging showed the verified-name map dropping from 956 entries to 0 on opening that channel. Tracking a large roster evicts the oldest-tracked subjects, and eviction dropped their records too. On resume, member and profile preloads track the evicted people again, which evicts the ones on screen, so their verified names disappear until the next fetch. Evicted records now stay as unchecked cache (up to 4,096, oldest untracked dropped first) and are rechecked when tracked again. Only tracked subjects accept new records, so the cache never grows from relay traffic. ### Validation - Added `a subject evicted by a large roster keeps its last known name`: it fails when eviction drops the record. - `flutter analyze` is clean. The full mobile suite passes except 10 push and notification-toggle tests that fail identically on unchanged main on this machine. - Same phone, 2,004-member channel, after this change: the verified-name map held steady (978 entries, growing to 1,828) through opening the channel and two resumes after 12 s and 20 s in the background, and the "ankit, lhoward" and "sfoo, Dani" rows never changed in the screen recording. Before, both flipped (to "Ankit · ykn9" and an npub) and back after each resume. - Automated app run on the same phone with this change: Home, search, the large channel, and its resume after the reconnect grace period. This touches the same `_snapshot` line as [#9101](https://github.com/block/buzz/pull/9101), so whichever lands second needs a one-line rebase. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> |
||
|
|
3ff4154ca6 |
fix(mobile): back off push bootstrap retries (#9092)
When push setup fails, the app retried every 5 seconds forever. On an iPhone 12 mini whose push gateway enrollment is refused (a CDN 403 on the installation challenge), each retry refetched the relay's NIP-11 document over a new TLS connection and logged the whole HTML refusal. That is about 720 attempts and roughly 14 MB of traffic an hour for as long as the app stays open, with no way to succeed. Push registration, lease publication and tombstone retries now back off: each consecutive failure of the same attempt doubles the delay from 5 seconds up to 15 minutes, with jitter between half and all of it so devices that failed together don't retry together. A success (including a renewal), a different attempt, or a channel-grammar retry (which keeps its 5-minute delay) ends the streak. The first retry still comes within 5 seconds, so a brief blip recovers as quickly as before. ### Validation - Added tests: `repeated failures back off exponentially up to a cap` checks every delay's bounds at both jitter extremes over 12 failures, `a success ends the failure streak`, and `a different attempt starts its own backoff`. The first fails when the gate goes back to a fixed delay. - Existing push suite passes. - On the 12 mini before this change: `Push lease bootstrap failed` with the 403 page and a fresh NIP-11 fetch every 5 seconds for the whole session. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> |
||
|
|
5e36d65c9e |
fix(mobile): inflate gzip read-state snapshot responses (#9089)
On relays behind a CDN that gzips responses (the Builderlab relay does), mobile read state never synced. Every read-state snapshot fetch, and therefore every read-mark publish, failed with `FormatException: Unexpected extension byte`, so read marks made on the phone never reached other devices and each reconnect retried and failed again. The relay query transport deliberately leaves gzip bodies compressed so they can be inflated off the UI isolate (see `RelayHttpQueryClient`), but the snapshot path decoded the raw bytes with `utf8.decode`. It now uses `relayResponseText`, like the other query callers, for both the discovery and the snapshot responses. Heads-up for reviewers: with the snapshot working, a resume also pays for the full snapshot download on top of the existing read-state replay on the live subscription. A follow-up PR removes that replay. ### Validation - Added test: `inflates gzip discovery and snapshot bodies` serves both responses gzipped. Without the fix it fails with the same `Unexpected extension byte` error seen on the device. - Full mobile suite: all pass except the known stale `fullscreen_onboarding_test` case, which fails on main too. - iPhone 12 mini (Profile build, Builderlab community): before, every resume logged `snapshot failed` and `publish failed (journal retained)`. After, the snapshot on resume and the snapshot inside a publish both completed with no errors. - Automated app run on the same phone with this change: Home backgrounded past the reconnect grace period and resumed, then search and a large channel opened. No read-state errors logged. Read marks were not compared across devices. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> |
||
|
|
6b8a72e835 |
fix(mobile): send hosted relay auth with iOS custom emoji downloads (#9056)
On iOS, custom emoji in the native emoji picker often render as the sparkle fallback icon instead of the image. The native picker downloads custom emoji with `URLSession` and asks Flutter for request headers over the `mediaHeaders` channel call. That call returned only the media read `Authorization` header. Hosted relays also require the `Nostr-Federated-Identity` assertion, which the Flutter image path gets from `HostedRelayHttpClient`, so relay-hosted emoji get a 401 and the picker falls back to sparkles. Emoji hosted elsewhere still load, which is why only some are missing. The `mediaHeaders` bridge now also includes the hosted relay assertion via a new `HostedRelayAuth.headersForUrl`, scoped to the relay's own origin like `HostedRelayHttpClient`. The native loader refuses redirects for requests that carry the assertion, matching the Dart client, so the token can't be forwarded to another host. The iOS reactions sheet had the same gap: it passes avatar and custom emoji URLs to native views with only the `Authorization` header. A new `nativeMediaHeadersProvider` combines both headers for any native media consumer, and the reactions sheet uses it. ### Validation - Added tests: the native picker's `mediaHeaders` call returns the relay assertion for relay media and no headers for an external emoji URL; `headersForUrl` returns the assertion only for the relay origin (not another host, subdomain or port). The reactions sheet passes the combined headers for avatars and custom emoji, and `nativeMediaHeadersProvider` merges both headers only for relay URLs. - Focused existing validation: the emoji picker and organization relay auth suites pass, and `flutter analyze` is clean. In the full mobile suite, the only failures are 10 in `notification_toggle_test`, `push_bootstrap_test` and `push_bridge_test`, and they fail the same way on `main`. - Automated full-app simulator run (iPhone 17 Pro, iOS 27, debug build of this branch with hosted auth enabled, signed in to two hosted communities through a cloned simulator keychain, driven with AXe). Covered: launch signed in, channel and DM lists, opening a channel and a DM, the native emoji picker (its Custom section shows the emoji as an image, not the sparkle fallback), sending a custom emoji message and seeing it render, reacting with the custom emoji (shown in the quick-reaction row and as a reaction pill), opening a thread, switching communities, avatars and community icons, background and resume, and a cold relaunch. Result: no crashes or regressions seen. Screenshots are omitted because they show real account and community names. - Limitations: this run does not reproduce the original bug. The hosted community's custom emoji also loaded in a temporary build with the relay assertion removed from the `mediaHeaders` bridge, so its emoji URL evidently doesn't need the assertion, and the run doesn't prove the fix. The reactions detail sheet could not be reached because simulated long presses on the pill opened the message menu, and inline image attachments were not exercised. A device build against a community whose emoji are relay-hosted is still needed to confirm the fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> |
||
|
|
920b1e64bd |
perf(mobile): keep covered pages still while the keyboard moves (#8941)
## Summary A keyboard raised by a route above (the Add members sheet, search, a native picker) changes the app's `MediaQuery` on every animation frame. Covered pages that read `MediaQuery` directly or through a `Scaffold` rebuilt and relaid out all their content for keyboard motion they cannot show. On #4things, typing in Add members rebuilt every visible message of the channel page underneath for each keyboard frame. `CoveredRouteKeyboardInsets` holds a page's `viewInsets` and `padding` at their last values from when its route was current, while the route is covered. Every other `MediaQueryData` field passes through. A covered page still follows the keyboard down, so when a covering route takes focus and the page's own keyboard closes, the page lowers behind the scrim. A size change (rotation) drops the held values so safe-area padding stays right. When the route becomes current again with the keyboard still above the held value, the page keeps the held values rather than jumping up and riding the keyboard back down. It switches to live values once the keyboard reaches the held value, or as soon as focus is inside the route (the page raised its own keyboard). Otherwise the live values return. It always returns the same widget shape, so covering a page never remounts it. Home, search, the channel page and channel details use it. ## Measurements iPhone 12 mini, Profile build, Builderlab #4things (1,361 members). Flow: open the channel, open its details, tap Add members, tap the search field, then type "chris" one character per second. The window is the 10 s of typing, keyboard included. Both builds also contain the fixes from [#8928](https://github.com/block/buzz/pull/8928), [#8929](https://github.com/block/buzz/pull/8929) and [#8931](https://github.com/block/buzz/pull/8931). | | Before | After | | --- | --- | --- | | UI frame time, total | 3,344 ms | 383 ms | | Frames over 16.7 ms | 101 of 232 | 2 of 297 | | Median / p90 frame | 14.1 / 26.1 ms | 0.6 / 2.0 ms | | UI isolate CPU samples (1 ms) | 2,793 | 868 | | Samples in `MessageContent.build` | 621 | 0 | Single runs per side, not repeated. ## Testing - New widget tests: a covered page doesn't rebuild for keyboard frames and gets the live insets back when it's current again. A current page follows the keyboard. Covering a page keeps its state. A page uncovered with the keyboard still up does not jump, and follows the keyboard once it reaches the held value. A page whose own field takes focus after being uncovered follows the keyboard immediately. A covered page follows its own keyboard closing and takes new padding when rotated. The last test fails (3 mounts instead of 1) against a version that returned a different widget tree when covered. - `just mobile-check mobile-test` passes. - On the phone: Add members search, typing and results behave as before. The channel page and search still move above the keyboard when they're current. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost> |
||
|
|
9c5032b63b |
perf(mobile): derive keys natively and hash NIP-98 bodies faster (#8965)
## Summary Two pure-Dart crypto paths that run on the UI isolate during normal use: - **NIP-98 body hashes.** Every relay HTTP query (`RelaySessionNotifier._postForEvents`) hashes its request body for the NIP-98 auth header with pointycastle's `SHA256Digest`. This switches that hash, the media upload hash and the NIP-OA preimage hash to `package:crypto`'s `sha256`, which is already a dependency. The output is identical. - **Public key derivation.** `AccountIdentityStore.publicKeyForNsec` runs on every relay auth assertion and derived the key with the `nostr` package's pure-Dart curve math. It now uses the native `derivePublicKeyHex` (libsecp256k1) with the same validation. The default-agent writer now signs with the native `signNostrEvent` instead of `nostr.Event.from`. Left as they were: `nostr.Keys` construction for signing keys (nostr 2.0.0 always derives in Dart, with no way to pass a known public key), and `nostr.Keys.generate()` on the rare key-creation paths. ## Measurements - **Device** (iPhone 12 mini, Profile build, #4things captures): NIP-98 hashing took 17 to 24 ms of UI CPU per 10 to 20 s capture, mostly from channel notices queries. `publicKeyForNsec` took about 7 ms per relay auth assertion. - **Synthetic** (Mac, AOT, same output): SHA-256 of a 1 KB body takes 39.9 µs with pointycastle and 6.6 µs with `package:crypto`. 50 KB: 1,518 µs vs 293 µs. 1 MB: 29.6 ms vs 6.1 ms. - **Device A/B after the change** (iPhone 12 mini, Profile/AOT, Tom's Block community, two app resumes per build from the home screen, 15 s windows; A is `origin/main` |
||
|
|
5d44038ea5 |
mobile: invite new users to create their first agent with a bold card (#9036)
## Summary
New users had no cue that the slot at the bottom of Home is where they
make an agent. A one-time, deliberately loud "Create your first agent"
card now sits above the empty Bestie slot and points at it.
- The card spans the screen above the bottom bar, with agent pictures
(the same ones the empty slot cycles through) scattered around its edges
and the message centered as plain text. It's allowed to cover part of
the chat list.
- It waits three seconds on Home, then pops up from the agent slot with
the popover entrance (a quick fade, rise and scale). Leaving Home
restarts the wait, so it never pops over another screen. With reduced
motion it appears without animation after the same wait.
- Tapping the card starts agent creation. The close button dismisses it
for good, as does a screen reader's dismiss action, activating the slot,
or ending up with a Bestie. Dismissal is remembered per account and
community. Other taps on Home go through to the page and leave the card
up.
- It shows when remote agents are on, the Bestie preference has loaded
with none set, and the account owns no agents yet. The last check keeps
existing users who never picked a Bestie from being told to create their
"first" agent.
- It only shows while Home is the current route, so sheets and pushed
screens hide it and it comes back afterwards if it was never dismissed.
The card is a new `BuzzCallout.card` variant of the reusable
`BuzzCallout` in the mobile design system (the plain tip variant stays
available). It uses the inverse surface in light mode and the raised
dark surface in dark mode (matching toasts), with an arrow, existing
radius, spacing and popover elevation tokens, and stays phone-sized on
wider screens. It grows to fit large text below its close button, and it
has live-region semantics, so screen readers announce it.
## Screenshots
These come from the widget test harness with the real agent pictures, at
small (320), standard (393) and landscape phone sizes, plus a tablet.
The harness can't render the native iOS glass bar, so these use the
Flutter bar; the card is the same on both platforms. Pictures look a
little soft because the harness renders at 1x and scales up. In
landscape and on tablets the card stops at large-phone width so the
pictures don't spread too thin.
| Standard, light | Standard, dark | Small, light |
| --- | --- | --- |
| 
| 
| 
|
| Landscape, light | Landscape, dark |
| --- | --- |
| 
| 
|
| Tablet, dark |
| --- |
| 
|
## Testing
- Launcher tests cover first show and placement, Home staying tappable
underneath without dismissing, the card tap starting creation, the close
button, slot activation without a touch, screen reader dismiss, a Bestie
appearing, no reshow after dismissal, a separate community getting its
own first run, staying hidden for people with agents or without remote
agents, hiding under a sheet, the three-second wait and entrance,
reduced motion, and leaving Home during the wait (each
mutation-checked).
- Component tests cover the tip beside an edge target (320 and 1024
wide, 3x text) and the card: full width at 320, capped on wide screens,
growing at 3x text so the message stays clear of the close button
(mutation-checked), and the close button staying its own screen reader
control.
- The full mobile suite passes locally with CI's dart-defines, and
pre-push mobile checks passed at
|
||
|
|
af54258f19 |
fix(mobile): streamline identity setup and community selection (#9070)
Selecting a saved community now connects only that destination, and **Find my communities** remains available through **Switcher → Add Community**. Failed destinations are named; completing nested Add/Find opens the selected destination without resuming the outer batch. Fresh hosted account setup now creates and backs up an identity automatically through email sign-in, invites, community links and community creation when the resolver confirms there are no account-bound, backed-up, saved or device identities. Welcome has one email sign-in entry. Authenticated import, existing-key recovery and selection retain their explicit paths; pending import survives restart and attaching a destination. Backup retry retains the same identity. Web binding retains explicit identity creation and its browser-return behavior. Self-hosted setup is separate and explicit. New-key creation still requires platform sealing support; passive account restoration never creates, binds or backs up an identity. **Account tradeoff:** setup cannot discover a key stored only elsewhere and never associated with this account. Such an account receives a new backed-up, bound identity automatically. Importing a different old key afterward fails with a binding mismatch; this flow does not undo that account binding. There is no separate pre-authentication import button. Pending import now survives retained community/web-bind link decoding: internal import metadata is stripped before strict public-link parsing. Once an identity is saved and resolved, the import request is retired before binding or backup, so restart resumes the existing key. Passive restoration also repairs stale import intents written by earlier builds without creating, binding or backing up a key. Validation (`f114c0e228`): - All 30 hosted onboarding journeys pass. Import coverage includes invite-to-community-link retention, restart before import, failed-backup restart, successful-backup restart, and an older stale import intent. - Three isolated production mutations (decode filtering, import completion persistence, passive stale-intent handling) each fail the relevant journey; restored control passes. Earlier auto-creation and nested-selection mutations also failed their relevant journeys. - Carl reviewed both P2 fixes and the final commit with no blockers. Carl and Pinky previously cleared the shared setup behavior and Android parity. - Wes confirmed Android sign-in and fresh account creation on the preceding build `be5690c684`. Android/iOS builds and earlier manual acceptance do not establish native acceptance of the new import/restart fixes. Installed devices remain on that preceding build. **Remaining acceptance:** current-head CI and native validation of the import/restart edge cases. Required publication hooks passed, including mobile analysis/tests, branch-skew and file-size checks. The existing iOS 16 sealing limitation remains: it cannot create a fresh hosted identity. No membership/ownership repair, denial-classification change or floating sign-in-control fix is included. --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> |
||
|
|
ec21753e1b |
Make mobile New DM and Add members pick recipients like mention autocomplete (#9065)
The mobile New DM picker listed the first 50 relay profiles alphabetically, mostly identities the viewer doesn't know. It ran its own debounced directory search with no cache, and its rows disappeared on every keystroke until the search returned. Mention autocomplete in the composer already solves the same problem well, so New DM and Add members now use its implementation. Add members was reusing the same New DM directory search. ### What changes - **Same candidates and order as mention autocomplete.** The chooser's candidate building, ranking, row stability, 200 ms debounced search and search cache now back both surfaces. New DM is treated like a stream with no members yet: the viewer's own agents and agents shared with them appear up front, people are found by name, and agents that couldn't answer the viewer are left out. Typing a name shows the same people in the same order as `@name` in a stream. - **Results stay on screen while a search runs.** A query searched before shows its people immediately, and a longer query starts from the shorter query's results. A failed search keeps its rows and adds a retry row below them. - **Presentation matches the mention rows.** Rows show the name, and agents show "agent · managed by …". Npub lines are gone. - **Sheet chrome.** The title is "New DM", like the menu item that opens it. Both pickers keep their grouped cards but drop the "People and agents" and "Selected" section titles and the empty header bands above them (selected recipients stay in their own card at the top with checkmarks on iOS, as chips on Android). The native iOS picker now focuses search once it appears, which iOS used to ignore. In New DM a pick clears the query, as an inserted mention ends its query. - **Add members keeps what is specific to it.** Existing members and members just added are never offered, names are still compared with the channel roster so a same-name candidate is told apart, the query stays after a pick so several matches can be added in a row, and agents that don't answer the viewer can still be added, since a channel can hold them. While the WebSocket reconnects, the relay agent directory and its owners keep their last read in the community instead of going empty, so shared agents already found stay eligible in both New DM and the composer chooser. The debug-only offline preview directory and the old directory listing and search providers are removed, since no picker reads them any more. ### Validation Added tests: the New DM widget journey now drives the real mention search through a fake relay session. It covers the empty-query state, the debounce, rows kept while a longer query searches, cached results shown again at once, selection, chips and submit. The layout journey covers the 50-row cap, and the native-bridge journey covers title, rows without key detail lines, the selection limit and failure handling. The full `mobile/test/features/channels` suite passes. Automated app run (iPhone 17 Pro simulator, iOS 27, debug build, Remote Agents Pilot community): opened New DM from the menu, confirmed the keyboard comes up with only the viewer's own agents listed, searched for people and an agent, selected both and confirmed the query cleared after each pick. Also exercised the composer mention chooser (same results and order for the same query) and the shared Add members sheet (plain style, autofocus, search, query kept after a pick). Captures show real community members, so they're shared internally rather than here. The DM was not actually created, to avoid a stray conversation; submit is covered by the widget tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
99873459e9 |
Keep sign-in toasts usable at large text sizes (#9057)
At large text sizes, organization sign-in prompts could clip their message and push recovery actions off-screen. Stack actions below the message above 1.3× text, use fixed card corners, and skip the Dynamic Island morph for that layout. Follow-up to #9050, addressing [Wes’s large-text blocker](https://github.com/block/buzz/pull/9050#discussion_r4469530660). Normal-size styling and recovery callbacks are unchanged. Validation: extended existing sign-in/cancel/error journeys at 1×, 2× and 3× using Inter on a 320×568 viewport; assert text lies inside the painted clip and the card stays on-screen. Removing the fix fails all six enlarged-text cases. Mobile checks and full tests run through the push hook. Human device testing of this follow-up is pending. Local Flutter previews at 3× text (320×568): | Before | After | | --- | --- | |  |  | Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
90de03aa63 |
feat(mobile): back up nsec to org builderlab account (#9029)
The Block tenant links a member's existing key to their account the first time it issues them a relay assertion ([#8700](https://github.com/block/buzz/pull/8700)). Mobile never backed that key up. Desktop FI builds back up to the tenant, but on mobile a Block key that lives on just one phone can't be recovered once Block turns backups on. Mobile now backs up the organization relay's key to the organization account: - After every organization assertion, it reads `/v1/buzz/nsec-backups/status`. If the key is already listed, there's nothing to do. - If the bound key isn't listed, it enrolls, seals with HPKE and uploads, using the existing `HostedBackupService`. That service now uses its account client's own origin, so organization and hosted backups never share a pending envelope. Hosted storage keys and receipts don't change. - If the upload's outcome is unknown, the saved envelope is replayed exactly at the next assertion. - A tenant without backups answers 404 (Block production today). That, and other definitive refusals, stop attempts for that account and key until the next launch. Any other failure is retried at a later assertion, after a delay that doubles from 1 minute to at most 1 hour. Assertions renew while the app runs, so retries continue until the tenant lists the key. - Concurrent assertions share one attempt. - iOS 16 can't seal, so it sends nothing. Backup failures never affect the relay connection. There's no UI. As in mobile's hosted onboarding, the backup happens automatically. Desktop asks for an explicit backup choice first, so whether mobile should too is a product call. ### Validation Added tests (`organization_key_backup_test.dart`), which run through real `HostedRelayAuth` assertion issuance with only HTTP faked: - An uncertain upload is replayed with the same envelope at the next assertion, then nothing more is sent in that launch. - A key that's already listed sends no upload and drops a leftover envelope. - A tenant without backups is asked once per launch. - A device that can't seal sends nothing. - A failed status read is retried after the delay, not at the immediate re-assertion. A key the tenant bound to someone else is never uploaded, and its checks keep going at growing intervals. - Two concurrent assertions make one attempt. - A failing seal-capability check sends nothing. Removing the assertion hook fails 4 of the 6 tests. Removing the shared attempt or the retry delay fails its test. Full `mobile-check` and `mobile-test` pass. Device (iPhone 12 mini, release build) against bb-block staging, where backups are configured and the relay enforces NIP-FI, with an existing Block SSO session: - On launch the app asserts, reads backup status and finds this key listed, so it uploads nothing. The relay connection and channel list are unaffected. - A test-only build that ignored the listed shortcut enrolled, sealed with the staging tenant's real KMS-backed key and uploaded, with a stored response in about 360 ms. That re-stored my own test key. - The final build at this head was then reinstalled and relaunched with no backup failure logged. The device's staging community was added in an earlier run, because adding an enforcing organization relay by link is still blocked ([#8791](https://github.com/block/buzz/issues/8791)). An independent agent review ran; its should-fix items are addressed in the second commit. Codex's retry finding is addressed in the third, which changes only failure-path timing; the device-tested success path is unchanged. Wes's optional review notes are addressed in the last three: an exact-timing retry test, backup queues per account origin (a stalled organization backup no longer delays hosted onboarding's backup, with a test), and a subject check on each enrollment and upload request. Full `mobile-check` and `mobile-test` pass at the head. Human testing is waived under the mobile automated-testing policy; the device run above covers the at-risk flows (organization assertion, relay connection, backup status, enrollment, sealing and upload). buzz-review-completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
0bdad8ea96 |
mobile: simplify verified profiles and prioritize full names (#9051)
Simplifies mobile identity presentation: removes verification checkmarks from messages, threads, mentions and profiles, plus issuer/time/disclaimer copy. Profiles show the community-verified full name as the title and the display name below, with fallback when no full name exists. Validation: mobile formatting/analysis, full Flutter suite (4,401 passed, 45 skipped), all 4 unconfigured-push checks, and pre-push hooks passed. Independent review found no blockers. Device and human visual checks pending. <details> <summary>Before / after snapshots</summary> Real Flutter profile widget with synthetic data, light/dark themes; widget previews, not device screenshots. Images are PR attachments only. | Theme | Before | After | | --- | --- | --- | | Light |  |  | | Dark |  |  | </details> <details> <summary>Inline messages — before / after</summary> Actual Flutter channel and thread widgets with synthetic verified-user data; local widget previews, not device screenshots. | Screen | Before | After | | --- | --- | --- | | Channel · light |  |  | | Channel · dark |  |  | | Thread · light |  |  | | Thread · dark |  |  | </details> Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
49fcab3324 | mobile: refine message avatars, metadata and reply gestures (#9046) | ||
|
|
1055f79d04 |
fix(mobile): show a taken channel name plainly and keep invites working (#8807)
## Summary
The relay is starting to refuse a new open channel when another channel
in the community already has that name
(https://github.com/block/buzz/pull/8782). Mobile handled that refusal
badly in two places:
- **Creating a channel with a taken name** showed raw error text:
`Exception: invalid: an open channel named "Launch" already exists`. The
create sheet now shows `An open channel named "Launch" already exists`
and stays open, so the user can pick another name.
- **Joining a community by invite could fail.** After an invite join,
the app joins two starter channels, `#general` and `#welcome-everyone`,
and creates either one if it can't find it. If an archived channel or a
forum already holds one of those names, the relay now refuses the
create, and the whole join failed. Now the app skips a starter whose
name is held by a channel it can't use, and the join succeeds. If the
holder is a live channel the app didn't see at first, the app joins that
channel.
Screenshots from an Android emulator against a local relay built from
https://github.com/block/buzz/pull/8782 are in the Testing section.
## Details
- **Error text.** A new `relayErrorMessage` helper
(`lib/shared/relay/relay_closed_policy.dart`) removes the `Exception: `
wrapper and the relay's machine prefix (`invalid:`, `blocked:`,
`duplicate:` and the other standard prefixes), then capitalizes the
first letter. Only the create sheet uses it. The other error paths are
unchanged.
- **Starter channels** (`ensureStarterChannels` in `lib/app.dart`).
After a taken-name refusal, the app reloads the channel list and looks
for the channel that holds the name:
- A live open stream with that name: join it.
- An archived channel or a forum: skip that starter. An archived channel
can't be joined, and a forum is the wrong type for a starter.
- No visible holder: fail as before. The setup stays retryable, because
the holder may not be visible yet.
- **Which channel counts as the holder.** Only a channel that can hold
the name under the relay's rule counts: open, not unlisted, a stream or
forum. Channels now carry the relay's "unlisted" flag for this. An
unlisted channel with the same name (for example, an unlisted forum the
user is in) doesn't hold the name, so the app doesn't skip the starter
because of it.
- **A starter that was archived.** Desktop and mobile create each
starter at the same fixed id. When the starter at that id is archived,
the relay refuses a new create there with `invalid: channel is
archived`. The app treats that as an archived starter and skips it,
unless a live channel with the name exists. When the relay answers
`duplicate` and the reload doesn't show the channel, the app reads the
channel at the fixed id and skips the starter only if that channel is
archived.
- If `#welcome-everyone` is skipped, the join opens no particular
channel. Both callers already handle that.
### Related issue
None found. Client side of https://github.com/block/buzz/pull/8782.
### Testing
Android emulator, debug build of
|
||
|
|
83d0bd3203 |
fix(mobile): stop QR pairing to organization relays from hanging (#9001)
## Problem Pairing a phone to an organization relay by QR (for example Block's `buzz.block.builderlab.xyz`) never finishes. After the codes match and the phone confirms, it sits on the loading surface. About 2 minutes later it returns to the welcome screen with "Lost connection to pairing relay." The desktop stays on "Finishing pairing on your phone…". This reproduces every time on Buzz 0.27.0, iOS 27.2, with desktop 0.7.7 (internal tracking: AIDA-2420). There are two causes: 1. **Validation can never pass for organization relays.** After the payload arrives, `_validateCredentials` opens a plain `RelaySocket` without a `Nostr-Federated-Identity` assertion. Organization relays refuse that upgrade with HTTP 401 until organization sign-in, which a freshly pairing phone hasn't done. 2. **The failure hangs instead of erroring.** `RelaySocket.connect()` catches the failed `ready` but keeps `_channel`. The validator's `finally` then awaits `disconnect()` → `sink.close()` on a channel that never opened, and that never completes. The phone waits until the pairing relay's 120 s connection cap closes the socket, and `_handleDisconnected` reports that as a lost connection. Standalone repro with `web_socket_channel` 3.0.3 against the real relay: ``` ready failed: ... was not upgraded to websocket, HTTP status code: 401 sink.close() DID NOT COMPLETE within 30s (hang) ``` ## Change - `RelaySocket.connect()` drops a channel whose upgrade failed. - `disconnect()` waits at most `RelaySocket.closeTimeout` (2 s). An upgrade that is still pending (connect timeout 10 s) can outlive pairing's 8 s validation deadline, and its close never completes either. - `PairingNotifier` skips the pre-sign-in check for `OrganizationEnvironment.protects` relays. Admission is left to the saved community's session and the existing organization sign-in banner, which matches how the hosted branch already defers admission. The predicate is injectable (`organizationRelay`) for tests. Other relays are still validated before saving. Trade-off: an identity that the organization relay would reject is now saved, and that rejection only surfaces after organization sign-in. The hosted path has the same property. ## Related issue No existing GitHub issue covers this failure; searched open issues and PRs for pairing relay, lost connection and `_validateCredentials`. Closest: - #8043 / #8060 cover the same `_validateCredentials` path from the other side: a failed connect currently returns normally. They're complementary. With #8060 merged, organization relays would fail pairing with an explicit 401 instead of hanging, and this PR keeps them out of that pre-check. Whichever merges second needs a small rebase in `_validateCredentials`. - #3181 is a different failure in the same flow. ## Testing - New `relay_socket_liveness_test` cases check that `disconnect()` completes in three situations: after a refused (401) upgrade, after a timed-out handshake, and while the upgrade is still pending. - New `pairing_provider_test` case: a malformed key is rejected locally before anything is saved, an organization relay is saved without the relay check, and a non-organization relay is still validated and rejected. - Each new test fails with its fix removed. The failures were "disconnect() hung on a channel that never opened", "disconnect() hung on a pending upgrade", and `validated` being true. - `just mobile-check` and `just mobile-test` pass: 4229 passed, 40 skipped. - **Simulator run (iOS 27.0 simulator, iPhone 18 Pro, Block organization dart-defines) against desktop 0.7.7 on `buzz.block.builderlab.xyz`:** pairing completed (desktop reported the phone paired; identity transferred; community saved) instead of hanging until the pairing relay closed. The app then showed the organization sign-in banner and opened browser sign-in for block.builderlab.xyz, as expected. Okta in the simulator requires FastPass, so the final relay connection after sign-in was not exercised there. - Human device testing is still pending. ## Follow-up - Test on a real phone with an organization build. The simulator couldn't complete Okta FastPass, so the relay connection after organization sign-in still needs checking. --------- Signed-off-by: Spencer Lewis <slewis@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
1bef1da77d |
fix(mobile): show newly created remote agents in the new-chat picker (#9030)
After creating a remote agent on mobile (for example in onboarding), the agent doesn't appear in the Solo **New chat** agent picker. Mentioning it in a channel, or restarting the app, works around this. The picker's agent list (`meAssistantsProvider`) is loaded once and kept for the app session. Creating a remote agent publishes its owner registration (kind 30177), and Beekeeper publishes the agent's attested profile, but nothing told the cached list to reload. So the new agent stayed missing until something unrelated (a relay reconnect or an app restart) rebuilt the list. With this change, the list reloads whenever this device publishes or removes a remote agent registration, using the controller's existing `registrationRevision`. Agents created on another device still show up only after the next reload, as before. ### Validation - Added test: `Solo agent choices reload after this device creates an agent` (hosted build) creates an agent through the real `RemoteAgentController.create` path and checks that the picker's list includes it. It fails without the fix (list stays empty) and passes with it. - Full mobile suite passes, and so do the hosted-build `shared`, `features/agents` and `features/channels/me` suites. Analyzer and format are clean. - Automated simulator pass on the fixed build (signed in to a Builderlab-hosted community): opened **New chat**, the picker listed all 7 agents, picked one, opened an existing Solo chat and the Agents page, then reopened the picker and it still loaded correctly. Human testing is waived per the repository's mobile testing exception. - Limitation: I didn't create a real remote agent on the simulator, because its Builderlab web sign-in stalled. The create → picker path is covered by the added test. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
dafe31bfe5 | feat(mobile): persist frontier-only NIP-RS read state across devices and restarts (#8998) | ||
|
|
bf13f7c83b |
fix(ios): mint NIP-FI assertions in the notification extension (#8982)
Organization relays that enforce NIP-FI (federated identity) require a `Nostr-Federated-Identity` assertion on `/query`. The iOS notification service extension signed its `/query` with NIP-98 only, so the relay answered `401` and every push from an enforcing organization relay showed the generic "You have a new message." body. The app already mints these assertions in Dart from its organization account session, but that session lives in the app's own keychain, and assertions last minutes, so a cached one is useless by the time a push arrives. This change lets the extension mint its own: - **App:** whenever the organization `BuilderlabAccountClient` adopts or clears its session, the app writes or deletes a device-only keychain item in the group it already shares with the extension. The item holds the organization origin, the session credential and the organization's exact relay hosts (from `BUZZ_ORGANIZATION_RELAYS`). Builds without an organization never write it. - **Extension:** for a community whose relay host is one of those exact hosts, `BuzzRelayAssertionIssuer` requests an assertion from the organization issuer (`/api/goose/v1/buzz/identity/assertions`, session credential plus NIP-98 with the community's account key, the same request the app makes), checks it was issued for that key, and caches it in-process until 30 seconds before expiry. Both `/query` paths (message lookup and sender/channel enrichment) send it. Other relays get no header and never see the session. - Signing out awaits removal of the shared copy and fails if removal fails; each restore that finds no session retries the removal. - If issuance fails or the user is signed out, the extension sends the query without the header and the relay refuses it, which keeps today's generic fallback. Not covered here: relay-hosted avatars on enforcing relays still load without the assertion (the notification text and title are unaffected), hosted Builderlab communities, and Android. ### Validation Added tests: - `testOrganizationRelayQueriesCarryMintedAssertion`: against a stub relay that answers `401` without the assertion, two organization pushes resolve with message body and sender name from a single issuance, a non-organization relay gets neither the header nor the session, and after sign-out the notification falls back. Removing the header from the query path makes this test fail. - `testOrganizationAssertionIssuanceFailuresSendNoHeader`: refused, wrong-key, expired and header-injection responses produce no header. Subdomains and other ports of the organization relay host are never sent the session. - `builderlab_account_test.dart`: the shared copy follows exchange, restore and clear; a failed removal fails sign-out, and the next restore retries it. Focused existing validation: the full BuzzPushKit suite, the hosted Dart tests, `flutter analyze`, and an iOS device build of the app and extension. Not yet validated: a live push from the Block relay on a device. ## Before merge Live device push test: waived by Tom (2026-11-03). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
40fe4b5d9a |
perf(mobile): evict verified-profile interest in one pass (#9007)
## Summary Follow-up to [#8931](https://github.com/block/buzz/pull/8931), from Wes's [review note](https://github.com/block/buzz/pull/8931#discussion_r4456891410). [#8931](https://github.com/block/buzz/pull/8931) replaced the per-eviction scan in `VerifiedProfilesNotifier.track` with `keys.first`. That was much cheaper than main, but not constant time: removing from a Dart map leaves deleted slots, and each new `keys` iterator walks past them. One `track` call that overflows the 1,024-subject cap by E subjects still costs about E²/2 slot checks. Wes measured ~3 ms at 4,300 subjects, 62 ms at 8,600 and 205 ms at 17,200. Eviction now takes the overflow from a single iteration (`keys.take(overflow).toList()`), so it's linear. Which subjects are kept doesn't change: still the 1,024 most recently inserted. The [#8931](https://github.com/block/buzz/pull/8931) eviction test counted keys yielded by the map, which `keys.first` keeps at one per eviction, so it couldn't fail on the quadratic path. It now counts iterations started over the interest map and expects exactly one per `track` call. Main's scan and the merged `keys.first` loop both start one per eviction. ### Testing - Falsification: with the merged `keys.first` loop restored, the new test fails (`Expected: <1>, Actual: <3276>`). With this change it passes. - `flutter test test/shared/verified_profiles/`: 31 passed. The existing "keeps the 1,024 most recently tracked subjects" journey still passes, so retention is unchanged. - `just mobile-check`: clean. - `just mobile-test`: 4,227 Flutter tests passed, 40 skipped, plus 4 push and 27 crypto tests. - Automated app run: installed this build on an iOS 27 simulator (iPhone 17 Pro) signed in to a Builderlab community, opened the inbox, a channel timeline with images and the image viewer, and the channel details member list, which all track verified-profile subjects. No errors. That community is small (30 members), so the overflow path itself is covered by the unit tests, not the app run. - Agent review: an independent agent review found no blockers. - Human test: waived under the mobile testing exception; the automated app run above replaces it. Not covered: Android, and no new device profile (Wes's AOT numbers above are the cost model; this removes the E² term). buzz-review-completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
33eaa73c83 |
fix(mobile): let users sign out after leaving all communities (#8993)
## Summary Fixes a dead-end in signed-in mobile onboarding: after leaving every community, Back kept returning to the same Add a community screen without a way to end the account session. Show “Signed in as [email]” on that screen. Back asks “Sign out of BuilderLab?” with the email; Cancel keeps the session, and Sign out returns to the welcome screen while retaining saved Buzz identities. Nested Back and Add community from an existing session keep their navigation behavior. ### Related issue None found in the existing issue and PR search for onboarding sign-out. ### Testing The new widget journey failed before the change and passes through the real onboarding widgets, account client, storage and flow with external HTTP/browser boundaries mocked. It covers nested Back, Cancel, system Back, durable session removal, retained identity/backup, and starting email sign-in again. The existing hosted onboarding journey also covers accounts without an email. On iPhone 18 Pro simulator (iOS 27), rendered the production onboarding screen and native confirmation using a disposable account fixture. Live browser account selection has not been verified; this clears the app session, not website cookies. To reproduce on an auth-enabled mobile build: sign in, leave all communities, check the displayed email, tap Back, and choose Cancel. Tap Back again and choose Sign out. The yellow welcome screen should appear, with email sign-in available. ## Screenshots Before, from the original reported phone screen: <img src="https://raw.githubusercontent.com/block/buzz/07440b3b10c3f48a6fd19c923f2ee5ba66d574f0/pr-8993--before.png" alt="Before: account state not shown" width="300"> After: Signed-in landing and native sign-out confirmation, using a disposable account: <img src="https://raw.githubusercontent.com/block/buzz/a4e18c85bc3a19d971b08ff595ba6b081c67e20f/pr-8993--landing.png" alt="Signed-in landing" width="300"> <img src="https://raw.githubusercontent.com/block/buzz/a4e18c85bc3a19d971b08ff595ba6b081c67e20f/pr-8993--confirmation.png" alt="Sign-out confirmation" width="300"> --------- Signed-off-by: Aaron <f425b86c3b780d43f8d9cf1802429d04dd985bf92eac44fca9468b5ad3887c5e@buzz.block.builderlab.xyz> Signed-off-by: John Tennant <jtennant@squareup.com> Co-authored-by: Aaron <f425b86c3b780d43f8d9cf1802429d04dd985bf92eac44fca9468b5ad3887c5e@buzz.block.builderlab.xyz> |
||
|
|
a66cb5f7ab |
Modernize mobile channel and DM creation sheets (#8942)
Modernizes New Channel and New DM with full-height forms and X/Create or X/checkmark actions. iOS uses UIKit sheets throughout; Android uses the Flutter fallback. Channel menus default to Ongoing/Public, and native menu previews lift only the trailing value while labels stay fixed. Channel names disable automatic capitalization, autocorrect, and spellcheck; both platforms use descriptive placeholders. Preserves recipient search and limits, creation error retries, and navigation after native dismissal. Validation: 4,154 mobile tests passed (38 skipped), auxiliary suites and static checks passed; iOS device/simulator and Android builds passed. Exercised menus, dismissal, DM search and selection in the simulator and Android form behavior on Pixel; iPhone UI tested and approved by the author. | iOS native form (Simulator) | Android Flutter form (Pixel) | | --- | --- | |  |  | Native menu with the left label fixed:  --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
344f007f36 |
Add mobile Bestie onboarding and fix remote agent presence (#8957)
An empty Bestie slot cycles still posters from Fuzzies, Gloopies and Figgies every five seconds, without creating video players. The next poster is precached; reduced motion and hidden-screen pauses are preserved. The launcher uses the neutral “Bestie” label until a selection is available, then “Chat with …”. For supported communities with no owned agents, tapping opens Create agent directly. A successful first-agent setup saves that agent as Bestie without replacing an existing preference; existing-agent and unsupported-community paths keep the Agents list. Verified Builderlab registrations can show sleeping remote agents as Online. Owner-scoped saved runtime availability now prevents a known absent or deleted runtime from returning Online after Builderlab sign-out or a fresh load, even when another community retains its registration. Validation: mobile static checks; 4,274 Flutter tests passed with hosted flows enabled; launcher → cancel → create → Home “Chat with Scout”, including encrypted persisted selection; sign-out, fresh-load, owner isolation and deletion regressions. Restoring the old carousel produced three video-player requests; removing the saved-availability guard reproduced incorrect Online after sign-out. The earlier iPhone recording below shows the original carousel and entry flow and stops before creation. The new poster-only behavior and completed creation are covered by widget tests, not a new device recording. https://github.com/user-attachments/assets/94fcff72-967a-44c2-99d5-27c715e196a2 --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
0e5a69962e |
perf(mobile): evict verified-profile interest in constant time (#8931)
## Summary `VerifiedProfilesNotifier.track` trims its interest map to 1,024 subjects, but it finds the oldest subject by scanning every entry, once per eviction. Opening #announcements (4,302 members) evicts about 3,300 subjects, which is roughly 3.4 million comparisons. On a 12 mini Profile build that was 316 ms of main thread, inside the channel's 134 to 167 ms opening frames. It runs whether or not the relay serves verified profiles, so it also affects the prod Block community. A subject's interest is set once, on insertion (`??=`), so the map's insertion order is interest order. The oldest subject is therefore `keys.first`, and eviction is constant time. Which subjects are kept does not change. ### Related issue None found. ### Testing - New `verified_profile_directory_test.dart` case: tracking 4,300 subjects keeps exactly the 1,024 most recently tracked ones, and tracking a subject again does not refresh its age. This test checks that behavior is preserved, and it passes on main too. The speedup comes from removing the per-eviction scan, which the device CPU profile attributed. - `just mobile-check` passes. `flutter test test/shared/verified_profiles/` passes (30 tests). The pre-push hook ran the mobile unit tests. - Device (12 mini, Profile build, opening #announcements, first 5 s; runs not matched): `VerifiedProfilesNotifier.track` inclusive CPU 316 ms before, 14 ms after. Opening frames of 134 to 141 ms remain, mostly the channel name scope (follow-up). Not covered: a human test (waived under the mobile testing exception), Android. Separately, the 1,024 cap still churns subscriptions for rosters larger than that. This PR doesn't change that design. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
58eb36dd57 |
perf(mobile): cheaper identity-name rebuilds for large rosters (#8936)
## Summary Every identity-name source change (a profile batch, a verified name) rebuilds a channel's naming scope over its whole member list. In a 4,000-member channel, each rebuild derived every key's naming fact more than once: owner loading and resolution each derived it, and so did each label for a key outside the scope. It also checked every key against a regular expression several times, and iOS AOT builds run regular expressions in an interpreter. - `IdentityNames` derives each key's naming fact once per scope. - `isHexPubkey`: a character-loop hex check, used by the identity-name code instead of the regular expressions. Labels are unchanged. ## Measurements These were taken with an earlier version of this PR that also cached npub encoding. That cache was removed to keep the change small, so the remaining gain is smaller than shown. I haven't re-measured it. - **Synthetic** (Mac, `flutter test`, 4,391 members, 60% with profiles; scope, `missingOwnerProfiles` and every label for each new sources object): a warm rebuild took 63 ms before and 12 ms with the cache. The first rebuild took 120 ms before and 91 ms with the cache. - **Device** (iPhone 12 mini, Profile build, first 5 s after opening #announcements with 4,391 members; runs not matched): `channelIdentityNamesProvider` plus `watchChannelIdentityLabel` inclusive CPU was 148 ms before and 81 ms with the cache. Of that, `fullNpub` (npub encoding, the cached part) went from 62 ms to 22 ms. The identity path no longer calls `RegExp.hasMatch`. ## Testing - A new test counts profile lookups. Owner loading, every label and an outside key read each member's profile once. Without the memo, each is read 3 times. - New `isHexPubkey` tests cover both cases, wrong lengths, characters just outside each range, and a bad character at every position. - `just mobile-check mobile-test` passes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
369e1ef322 |
perf(mobile): stop re-parsing message markdown on every rebuild (#8928)
## Context Each message in a channel shows its text as Markdown. The `GptMarkdown` widget parses that text. Parsing is slow for long messages. `GptMarkdownTheme` gives Markdown colours and styles to the widgets below it. When it gets a new theme data object, it tells all `GptMarkdown` widgets below it to parse their text again. It compares the old and new objects by identity, because `GptMarkdownThemeData` has no value equality. Thus a new object with the same values also causes a parse. ## Problem Two widgets made a new theme data object on each build: - `MessageContent` made one for each message bubble. - `AppMarkdownTheme` made one for the full app. Thus each rebuild parsed all messages on the screen again. Many events cause a rebuild: a batch of profiles, a read marker, a keyboard frame. In a large channel such as #4things (1,235 members), these events occur almost all the time. ## Change - `MessageContent` keeps its bubble theme until one of its inputs changes. The inputs are: the app Markdown theme, the link colour, own or other message, the ink colour and `onSurface`. - `AppMarkdownTheme` makes new theme data only when `ThemeData` changes or when an outer Markdown theme changes. A change of colour, of brightness, or of own or other message still makes new theme data. Then the Markdown parses again with the new colours. This is correct. ### Related issue None found. ## Testing - New regression tests fail on main. They pass on this branch. - `message_content_stability_test.dart`: When a bubble rebuilds with the same inputs, it keeps the same theme object and the same parsed `TextSpan`. The test does this for own messages and for other messages. When own or other changes, the bubble gets a new theme. - `app_markdown_theme_test.dart`: The app keeps one theme object when it rebuilds. It makes a new one when light or dark mode changes. - `just mobile-install mobile-check mobile-test` passes. - Device test: iPhone 12 mini, Profile build, Tom's Block community, open #4things. The two runs had different live traffic and different cached content. Thus they show the cause and the fix. They do not give a controlled speed measurement. - Before: In the first 2.4 s, parses from rebuilds (`performRebuild`) used approximately 90 ms. One frame used 116 ms. Approximately 48 ms of that frame parsed visible messages again. - After: In 30 s, rebuilds caused no parses. Only the first build of each message (`_firstBuild`) parsed. - An independent agent review found no defects. It found one small item: `ChatColors` has no value equality. Thus a rebuild of `MaterialApp` still makes new app theme data one time. This costs only a little performance. We did not change it. Not tested: A human test did not occur. The mobile testing exception waives it, and an agent did the device test. No test occurred on an Android device. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
eee75a1c7b |
perf(mobile): verify owned-agent registrations natively (#8938)
## Summary `loadOwnedAgentDirectory` checks the signature of each kind:30177 agent registration before it trusts the registration's `model`. It did this with `nostr.Event.fromJson`, which runs the `nostr` package's pure-Dart Schnorr code on the UI isolate. This change uses `verifiedNostrEventFromJson`, which the app's other verification sites already use. It makes the same timestamp, event id and BIP-340 signature checks with the bundled libsecp256k1. It also requires lowercase hex for the pubkey and signature, which relay events already use. A registration that fails verification still lists its agent without a model, as before. ## Measurements - **Synthetic** (Mac, `flutter test`, 20 signed registrations, warm): pure Dart 163 ms, native 2.6 ms. - **Device** (iPhone 12 mini, Profile build, first channel open after launch, first 5 s; runs not matched): `loadOwnedAgentDirectory` inclusive CPU was 71 ms before (69 ms in `Event._assertValid`) and 2 ms after. ## Testing - New test: a registration with a wrong signature still lists the agent, with no model. The correctly signed registration keeps its model. - `just mobile-check mobile-test` passes (pre-push hook). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
972e89ac89 |
fix(mobile): allow community removal without a cached push grant (#8978)
## Summary Fix community removal failing with “Expected exactly one endpoint grant for push lease revocation” when saved push state exists but the current build has no cached grant, including builds without a push gateway. When the community has a saved installation ID, journal its targeted revocation without requiring the grant. An unknown grant expiry stays unknown: retain cleanup credentials until the relay accepts the revocation. Existing known-expiry cleanup and legacy grant-based addressing remain unchanged. ### Related issue No matching open issue found. Follows [the durable revocation outbox](https://github.com/block/buzz/pull/8706). ### Testing The new regression fails with the reported error on the preceding implementation. It now verifies missing-grant enqueue, offline retry, persistence across restart, the exact lease address and eventual cleanup after acceptance. On a physical iPhone with the fix applied to the onboarding test build, the previously failing community removal succeeded. Unknown-expiry records retain their signing key in secure storage until successful revocation. Downgrading to a client that requires an integer expiry will reject those pending records. Signed-off-by: John Tennant <jtennant@squareup.com> Co-authored-by: Aaron <f425b86c3b780d43f8d9cf1802429d04dd985bf92eac44fca9468b5ad3887c5e@buzz.block.builderlab.xyz> |
||
|
|
fa395e9e80 |
feat(mobile): open a prefilled Create agent form from Builderlab agent cards (#8912)
Builderlab agent cards (printed for the event, and generated at the kiosk) carry a QR to a share page, `app.builderlab.xyz/a/<id>`. Its **Add to Buzz** button opens `buzz://agent/new?card=<id>`, which no Buzz client handled until now. This PR makes mobile handle it. ### Behavior - Opening the link shows the Agents page, then a Create agent form prefilled from the card. Everything stays editable, and nothing is created until the user taps Create. - The app fetches the card's `agent.json` from the avatars CDN for this build's Builderlab environment (production or staging). The link carries only the card id, never a host, so the QR stays small no matter how long a card's instructions get. - `agent.json` is validated against the card schema. `id` (matching the link), `name` and `avatarUrl` (an https URL) are required. `instructions`, `model` and `reasoningEffort` are optional (published cards don't have them yet) and left for the user when absent. A missing required field or a wrongly typed known field rejects the card; any other key is ignored whatever its type, including `v` and `description` (Buzz agents have no description to prefill), so Builderlab can add fields without breaking released apps. The model and effort are preselected only if the live catalog offers them. The card id is opaque: any non-empty id is accepted and percent-encoded into the URL. - The fetch uses the same limits as image downloads, now shared in `bounded_fetch.dart`: aborted after 20 s without a byte or 5 min overall, and rejected over 2 MB. `MediaImageProvider` now calls the same helper, with no behavior change. - The link waits for sign-in, then reloads the registry for the signed-in account before anything else. It's dropped with a message if the card is unavailable (403/404), invalid, can't be loaded (any other response), or an agent setup is unfinished, including a setup saved before a sign-in the link triggered. - A link that arrives while Agents shows only its list replaces that page instead of stacking a second one. If a form or sheet is open there, the link is dropped with "Finish or close what’s open in Agents, then reopen the link." and the open work is kept. ### Demo Simulator (iPhone 17 Pro, debug build of this branch plus the sign-in fix from [#8914](https://github.com/block/buzz/pull/8914) and the hidden debug banner from [#8915](https://github.com/block/buzz/pull/8915), signed in to Builderlab, remote-agent-pilot), recorded in one take with idle time trimmed: the card's share page in Safari, Add to Buzz, the prefilled Create agent form, a model, thinking level and instructions, Create, then a message and the agent's reply. https://github.com/user-attachments/assets/e66aae17-884a-4e68-a1b8-ecb40562be1d ### Validation Added tests: - Table tests for the link shape (only `?card=<id>` with an opaque id; the inline `#` form, extra parameters and dot segments are rejected) and for reading `agent.json` (a published card, a card with instructions and model defaults, unknown keys of any type ignored, including `v`, `description`, arrays, objects and null, and invalid cards: missing or mismatched required fields, wrong types, and non-https or relative avatars). - Table tests for fetching: the request goes to this build's CDN without following redirects, with the id percent-encoded as one path segment; 403/404, a redirect, a 500, a network error, declared and streamed oversize bodies, non-JSON and a JSON array each give a specific message. The image loader's existing stall, deadline, cap and cancel tests now cover the shared helper. - A journey test from link to Create against a fake CDN: prefill and edit, then submit; an unavailable card; a failed model catalog whose Retry then preselects the card's model; a Builderlab account change while the card loads, which drops the draft; a v1 card with no instructions and an unknown model; an unfinished setup; a link arriving while a Create form with edits is open, which keeps the form and its edits; and a link arriving while Agents is open. Disabling the https or id check makes the table test fail, and removing either the account recheck or the loaded-catalog check fails the journey. The journey runs through the production root scope (removing its link handler fails it) and runs in CI's configured-authentication-domains step. Full mobile check and test suite pass locally. Live data: a real staging card's `agent.json` (`av_MioTAG6dm7q3Zcm0UV2j8A`) has the v1 shape the table test uses, and an unknown id returns 403 from the CDN. Live run on an iPhone SE (build of 63ed54c749, Builderlab sign-in enabled, remote-agent-pilot, signed in as builderlab@tombrow.com) with a real production card, [ToastyMacbeth](https://app.builderlab.xyz/a/av_lrmRlJR6ylrQKvJkoLWCOQ) (a v1 card with no instructions and an extra `description` key): - Opening `buzz://agent/new?card=av_lrmRlJR6ylrQKvJkoLWCOQ` from a cold start opens Create agent prefilled with the card's name and avatar. - After choosing a model and adding instructions, Create adds the agent with the card avatar, and it replies in a DM. - An unknown card id shows "This agent card isn’t available." | Prefilled | Created | Reply | Unknown card | | --- | --- | --- | --- | | <img src="https://raw.githubusercontent.com/block/buzz/b9222db494758c3e536b8e2011032ba7cec86daa/pr-8912--01-prefilled.png" width="200"> | <img src="https://raw.githubusercontent.com/block/buzz/b9222db494758c3e536b8e2011032ba7cec86daa/pr-8912--02-created.png" width="200"> | <img src="https://raw.githubusercontent.com/block/buzz/b9222db494758c3e536b8e2011032ba7cec86daa/pr-8912--03-reply.png" width="200"> | <img src="https://raw.githubusercontent.com/block/buzz/b9222db494758c3e536b8e2011032ba7cec86daa/pr-8912--04-unavailable.png" width="200"> | Before this PR, the app had no handler for `buzz://agent/new`, so the link did nothing. A separate widget test covers a link that needs Builderlab sign-in first: once sign-in lands, a setup saved for that account blocks the card, which is never fetched. Removing the registry reload fails it, as does removing the busy-Agents guard for the journey step above. ### Before merge - [x] Human live check waived under the repository owner's standing testing exception. In its place, agent-driven full-app runs exercised the flow end to end: the iPhone SE device run and the simulator demo above (a real card's share page, **Add to Buzz**, the prefilled form, Create and a reply from the agent). Limitation: those runs predate [ |
||
|
|
b5b69d3de8 |
fix(mobile): let the issuer choose relay assertion lifetime (#8935)
## Why
Mobile rejects any NIP-FI relay assertion whose `expires_at` is more
than 300 seconds away (`hosted_relay_auth.dart`). Once an issuer returns
longer assertions, every fetch fails with `invalidResponse` and
organization sign-in can't connect to the relay. Builds with org sign-in
configured hit this on every attempt.
## What
Keep rejecting assertions that have already expired, and drop the
300-second cap so the issuer chooses the lifetime. Desktop made the same
change in block/buzz-app#2171. The relay still enforces its own
`maximum_assertion_age`.
The token cache needs no change. It already refreshes 30s before
`expires_at`.
## Testing
- Added a table case to `organization_relay_auth_test.dart`: 300s and
24h are accepted; 0 and -60 are rejected with `invalidResponse`. It
fails against the old cap and passes with the fix.
- `just mobile-install mobile-check mobile-test` passed locally at
|
||
|
|
da93ce3f62 |
fix(mobile): open the public Builderlab login the iOS bridge allows (#8914)
On main, "or sign in with email" on iOS fails immediately with "We could not finish connecting. You can try again." No Builderlab page opens. [#8700](https://github.com/block/buzz/pull/8700) changed the iOS browser bridge so the public account hosts (`app.builderlab.xyz`, `app.builderlabstaging.xyz`) may only open `/auth/native`. The Dart side still opens `/api/goose/v1/auth/login` for them, so the bridge rejects every public sign-in before presenting a browser. Every iOS build since #8700 is affected; organization sign-in on tenant hosts is not. The matching Dart change was Wes's [#8712](https://github.com/block/buzz/pull/8712). It was merged into the #8700 branch, but main's squash of #8700 kept only the bridge half. This PR restores #8712 on top of main: - The public account opens `/auth/native` (state, S256 challenge, callback and retained invite unchanged, without the CLI-only `type`/`product` parameters). Organization tenants keep `/api/goose/v1/auth/login`. - A saved session whose account still needs web setup goes through the same browser handoff, keeping its subject and destination. Android has no path allowlist in its browser plugin, so it needs no change. ### Validation - Reproduced on an iPhone 17 Pro (iOS 27) simulator with a main-based debug build: "or sign in with email" fails instantly. With this change applied, the same build shows the system "Buzz wants to use app.builderlab.xyz to sign in" prompt, then the login.builderlab.xyz "Welcome back to Buzz" page, which confirms production serves `/auth/native`. - Restored #8712's tests on top of main's current tests: the browser test checks the public login URL and its exact parameters, and the hosted invite journey runs for both a fresh account and a saved account that still needs setup, asserting `/auth/native` and that the invite is retained. Without the Dart fix, these fail. - Mobile format and analyze pass; the hosted onboarding journeys and `shared/hosted` tests pass with the CI auth-domain configuration. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> |
||
|
|
9f8913dd1b |
mobile: keep a thumbnail fallback apart from a direct load of the same URL (#8913)
## Summary Follow-up to [#8896](https://github.com/block/buzz/pull/8896), from its review threads. **A fallback and a direct load of the same thumbnail no longer share a download.** `MediaImageProvider.isThumbnailFallback` decides what a 404 means: for a thumbnail `MediaImage` picked in place of an original, it records a missing thumbnail so the next build loads the original; for any other URL it starts the 30s failure cooldown. It was left out of equality and the in-flight download key, so when a profile URL that is itself a thumbnail and a fallback to that same thumbnail were on screen together, both joined one download and only one of them ran its own 404 handling. Now each keeps its own provider and download, and each gets its own handling. The failure cooldown is keyed the same way. Before, a direct 404 of a thumbnail put its URL on cooldown (30s, up to 5 min with Retry-After), and an avatar that fell back to that thumbnail during the cooldown hit the cooldown error instead of a 404, so it showed initials instead of loading the original. Also: - Reworded the `preferThumbnail` doc so it no longer names community icons, which never set it. - Removed the community-icon avatar test, which fed a URL production never produces. ## Testing - New test: a direct thumbnail URL and a fallback to it, shown together, make two requests; after a 404 the direct URL is in cooldown and the fallback's original loads (not the initials). It fails with the fetch key change reverted. - New test: after a direct thumbnail 404, an avatar that falls back to the same thumbnail still loads the original. It fails with the cooldown key change reverted. - `just mobile-check` and `just mobile-test` pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |