mirror of
https://github.com/block/buzz.git
synced 2026-11-04 08:21:48 +00:00
76bc66acfcaebda487813b2495adf8fec82c0b8a
480 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
76bc66acfc |
mobile: hold a queued list read for the 10s gap (#8958)
## Summary Follow-up to [#8910](https://github.com/block/buzz/pull/8910) for the two optional notes from its review: - **A queued list read now waits out the 10s gap** ([note](https://github.com/block/buzz/pull/8910#discussion_r4447689472)). If a change comes due while a slow read is still running, the read is queued. When the slow read finished, the queued one used to start right away, even if the gap after the previous read hadn't passed. That broke the "reads start at least 10s apart" rule: a read could start 9s after the one before. The queue now drains through `_readListIfChanged`, which holds the read until the gap ends. If the finished read already covered the change, nothing is read. - **A test for a partly failed unread catch-up** ([note](https://github.com/block/buzz/pull/8910#discussion_r4447689485)). Previously, deleting the line that retries a catch-up when only some channels' fallback reads failed left the suite passing. ### Related issue N/A. Follow-up to [#8910](https://github.com/block/buzz/pull/8910). ### Testing - New step in the open-list journey: the relay answers each of a read's two queries in 7s. A change that comes due while that read runs is read once, after the gap, not as soon as the slow read ends. Restoring the old drain fails the step. - New step in the resume journey: after a reconnect, the batched catch-up fails over to per-channel reads, and one channel's read is refused. That channel turns unread on the retry, and nothing more is read over the next 10 minutes. Removing the partial-failure retry fails the step. - `FakeRelay.holdQuery()` holds HTTP queries, so a test can make a read slow. - The full mobile suite passes, and `dart format` and `flutter analyze` are clean. - This is a timing-only change to background list reads, with no UI change. The journeys above drive the real notifiers against a fake relay. I did no device run. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
ff6ab96e42 |
fix(mobile): drop the # from Solo chat names in push notifications (#8955)
## Summary
Push notifications for Solo chats were titled like channels (`#Trip
planning`). The iOS notification extension added `#` to every stream and
forum conversation name, but the app presents Solo chats as personal
chats, not channels.
- `BuzzPushCachedChannel` gains optional `isSolo` and `creatorPubkey`,
derived from relay kind 39000 metadata. `isSolo` uses the same rule as
the app's `Channel.isSolo`: a private stream whose NIP-CL labels (every
`t` after the leading type) include `buzz.solo`. `creatorPubkey` is the
relay-authored `P` tag.
- The resolver uses the bare channel name only for the viewer's own Solo
chats (matching `Channel.isPersonalChat`, which requires `createdBy ==
viewer`). Someone else's Solo chat, and every other channel, keeps
`#name`.
- `isSolo` is optional, so existing App Group caches still decode.
Entries cached before this change have no flag and keep the `#` until
their metadata is refreshed.
## Validation
- `swift test --package-path mobile/ios/BuzzPushKit`: 104 XCTest and 66
Swift Testing tests pass, including two new ones:
- `testOnlyOwnSoloChatConversationNameDropsChannelHash`: a fresh Solo
metadata fetch yields `Trip planning` when the viewer created the chat
and `#Trip planning` when someone else did. Removing the creator check
makes it fail.
- `soloClassification`: only private `stream` channels labelled
`buzz.solo` are marked Solo (open, forum, label-only and unlabelled
private streams are not), and the `P` creator is cached lowercased.
- Live relay check: a local relay with synthetic identities, a Solo chat
("Weekend plans") and a private channel ("design-review"), each with a
second member who posts. The production `BuzzPushNotificationResolver`
(the code the notification extension calls), run against relay-built
kind 39000 metadata, gives:
| | Solo chat | Channel |
|---|---|---|
| main | `#Weekend plans` | `#design-review` |
| this PR | `Weekend plans` | `#design-review` |
- Not covered: a banner rendered by the notification extension. `simctl
push` delivers to the simulator as a local notification and never
launches the extension, and a real APNs push needs credentials this
machine doesn't have.
## Follow-up after merge
Merging on the live relay check above. Still to do once a dogfood build
includes this change:
- [ ] On-device push check on a BLIMP: a Solo chat push shows the bare
name and a channel push keeps `#name`.
- [ ] Before/after notification 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: 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> |
||
|
|
09b21cbe63 |
fix(mobile): label community switching consistently (#8979)
## Summary Rename the mobile community menu action from **Switch workspace** to **Switch community**, matching the Switch Community page it opens and avoiding confusion with billing workspaces. ### Related issue N/A. No duplicate found. ### Testing The existing home-page widget journey opens the community menu and verifies the new label. Before/after captures show the production Flutter menu with disposable fixture data. ## Screenshots ### Before  ### After  Signed-off-by: John Tennant <jtennant@squareup.com> Co-authored-by: Aaron <f425b86c3b780d43f8d9cf1802429d04dd985bf92eac44fca9468b5ad3887c5e@buzz.block.builderlab.xyz> |
||
|
|
6684f7e2f0 |
fix(mobile): make the Join button readable in dark mode (#8969)
## Summary After scanning or pasting a community invite in dark mode, the native Join button can display white text on a white background. This fix passes the theme’s foreground color to native confirmation and applies it alongside the button’s background color. ### Related issue Related invite handoff: https://github.com/block/buzz/pull/8601. ### Testing A widget regression checks the foreground and background colors sent to native confirmation. The isolated PR build shows readable black Join text on the white native button in dark mode. ### Screenshots Before (consent-only build `3d566de`):  After (this PR):  Signed-off-by: John Tennant <jtennant@block.xyz> Signed-off-by: John Tennant <jtennant@squareup.com> Co-authored-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz> Co-authored-by: Aaron <f425b86c3b780d43f8d9cf1802429d04dd985bf92eac44fca9468b5ad3887c5e@buzz.block.builderlab.xyz> |
||
|
|
1c143a40b7 |
fix(mobile): show the Join prompt when an invite launches Buzz (#8970)
## Summary Opening an invite while Buzz is closed can leave the Join prompt invisible and block later attempts. Native confirmation can attach to a temporary system dialog that then closes, leaving the join result pending. This fix uses Flutter confirmation when the native presenter is occupied. If UIKit rejects presentation, it clears the pending session so the user can try again. ### Related issue Related invite handoff: https://github.com/block/buzz/pull/8601. ### Testing The native regression fails before the fix and passes afterward. It covers rejected presentation, a second attempt, and an existing temporary dialog. The isolated PR build passed a fresh-user journey from Safari consent through launching Buzz, visible confirmation, Join, starter channels and seeded history. ### Screenshots Visible Join prompt after Safari opens Buzz, from this PR build:  ### Remaining work Finish Dart validation and review before readiness. Signed-off-by: John Tennant <jtennant@block.xyz> Co-authored-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz> |
||
|
|
205ff47744 |
fix(mobile): retry failed channel searches after reconnecting (#8968)
## Summary Searching while disconnected leaves “Open channel search couldn't finish” visible after the connection returns. Users must change the query or tap Retry channels to recover. This fix automatically retries the open-channel directory search when the relay reconnects, using the current query. It does not rerun message search or explain the separate onboarding setup failure. ### Related issue None found. ### Testing The widget regression searches while disconnected, reconnects without typing, and checks that the error clears and an unjoined open channel appears. It fails on main and passes with this fix. On the iOS simulator at this PR head, restarting the local relay cleared the error within about five seconds without changing the query; the unjoined channel remained listed. ### Screenshots Relay down: the error shows under the cached channel.  Relay back, same query, no typing: the error is gone and the unjoined channel stays listed.  --------- Signed-off-by: John Matthew Tennant <jtennant@squareup.com> Co-authored-by: Simone <0828be588d5c9c4d092c5b1cf01761d5aef63d6b104807fd8e8284d517dd1a99@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
cdd2af1a35 |
fix(mobile): let starter setup retry after a connection failure (#8967)
## Summary
After joining a community, “Retry setup” can keep failing until Buzz
restarts because it reuses an earlier connection error. A previously
successful wait can also let a retry proceed while disconnected.
Each setup attempt now waits for the current relay connection, retaining
the community check and 15-second timeout. This fixes retry recovery;
the original reported setup failure remains unexplained.
### Related issue
None found.
### Testing
Two provider regressions fail on main and pass with this fix: retry
after an interrupted wait, and retry after a previously connected
session disconnects.
In an iOS simulator comparison with an injected first-wait error, the
old build failed every retry. The fixed build completed setup and
displayed both starter channels without restarting. This proves recovery
from the injected error, not the original failure. Reconnect timing
after a previously successful wait is covered by the provider regression
only.
## Screenshots
iOS simulator against a local relay, running head
|
||
|
|
0ec74838a5 |
fix(mobile): open consent for scanned and pasted invites (#8952)
## Summary Scanning or pasting a valid community invite can show “approval has expired” before the user has reviewed the community’s requirements. This fix opens browser consent, preserves the joining identity, and resumes joining when approval returns. If the browser cannot open, Review requirements remains available to retry. ### Related issue Related invite handoff: https://github.com/block/buzz/pull/8601. ### Testing Provider and widget regressions cover missing or expired approval, browser-launch failure and retry, identity preservation, and native dismissal without reporting a successful join. The isolated PR build passed actual clipboard entry and Allow Paste, browser consent, return to Join, membership, starter channels and seeded history. Camera scanning remains untested. ### Screenshots Browser requirements opened from the native Join prompt:  Joining resumes after approval:  Joined with starter-channel history:  Signed-off-by: John Tennant <jtennant@block.xyz> Co-authored-by: Zack <04f47b290b155b3b8b782b11501cdb15f1e21e82dfceefd01ff0ed405459d927@buzz.block.builderlab.xyz> |
||
|
|
009dc103f6 |
perf(mobile): read the channel list after live changes, not every minute (#8910)
## Summary While the app is open, a 60s timer reads the whole channel list again: every membership roster, the channel metadata, and the unread catch-up. On the test account that is **about 4MB of JSON every minute** on the 12 mini, about 240MB an hour, whether or not anything changed. The live subscriptions already deliver what the list is built from, so this PR removes the timer. The list is read again when a live event says it may have changed: - a relay notice that tags this reader: added to a channel or a new DM (kind 44100), removed from one (44101), or a DM hidden or shown on another device (30622) - a metadata edit on a listed channel (kind 9002, now part of the live filter) - a system notice on a listed channel that changes the channel itself: archived (including the relay auto-archiving an idle ephemeral channel), unarchived, deleted, visibility, topic, purpose, TTL and similar. Member joins and leaves don't count. The read starts 2s after the last such event, and no later than 10s after the first, so a burst costs one read. Reads start at least 10s apart however busy the relay is. A read that fails is retried with backoff (5s, doubling up to 5 minutes), including the read after a resume or reconnect. Nothing is read in the background: launch, resume, reconnect and pull to refresh read the list as before. Live subscriptions ask for no stored events (`limit: 0`), so nothing sent while one is down is replayed. So: - Each list read opens the notice subscription before it reads, and the read covers anything sent before. - If the relay closes the notice subscription or a channel chunk for a reason that may clear (busy, rate-limited, timed out), the relay session reopens it itself, as before. The channel list now hears about that through `subscribeWithStatus`. - If the relay refuses one as over its subscription limit, only that REQ is retried, after a jittered backoff (5s, doubling up to 5 minutes). The backoff starts over once a reopened subscription has stayed up for a minute. Any other refusal (`restricted:`, `invalid:` and so on) can't succeed unchanged, so it is left alone until the next connection, as on main. - A reopened subscription that is still up when its read comes due counts as a change, so the list is read once to catch up on what it missed. If the relay admits a REQ and then closes it again, it triggers no reads. Known gaps, accepted: deleting a channel or kicking a member sends this reader no notice, and archived channels have no live coverage, so an unarchive done on another device isn't seen. An edit to a newly added channel that lands in the round trip between reading its metadata and its live subscription starting is also missed. The list catches up on the next launch, resume, reconnect or pull to refresh. Archiving or unarchiving on this device re-reads the list at once, as before. ## Measurements iPhone 12 mini on Wi-Fi against the internal relay, app open and idle, instrumented profile build (HTTP response bodies after decompression, websocket text in). Measured on an earlier revision of this PR, which read on a 60s tick only after a change. Idle behavior is the same now (no change, no read). | 10 idle minutes | main | this PR | | --- | --- | --- | | Channel list reads | 10 | 1 | | HTTP queries | 40 | 5 | | HTTP response bytes | 39.9MB | 4.0MB | | Websocket bytes in | 4.4MB | 1.1MB | On main, every tick read memberships (348KB), another 310KB query and the unread catch-up (3.3MB). With this PR, the only read in the window came right after another device hid or showed a DM (kind 30622), which is exactly the kind of change it should pick up. Sizes are JSON after decompression; the relay gzips responses, so wire bytes are smaller (not measured). [#8901](https://github.com/block/buzz/pull/8901) separately shrinks each read's unread catch-up; the two are independent. ## Testing - New journey test: with the app open and nothing changing, nothing is read. Adding the reader to a channel, renaming a listed channel, archiving or auto-archiving one and removing the reader each cause one read about 2s later, and a member joining causes none. A change right after a read waits out the 10s gap. A steady stream of changes is read after 10s and then no more than every 10s. A burst costs one read. A failed read is retried with backoff and then stops. - New journey test, for the notice subscription and for a channel chunk: the relay refuses it twice as over its limit while the list changes. It is retried with backoff, and once it has stayed up the list is read once to catch up. Later the relay drops it, and having stayed up it is reopened within about 5s and catches up again. Then it delivers live. The relay then closes it as busy while the list changes, and the list catches up once it is back; closed again right after reopening, it triggers no read until it stays up. Refused as `restricted:`, it is not retried. - The resume journey: a reconnect read and a resume read each fail once while a channel is added, and each is retried and shows the channel. - Each guard was removed once to confirm a test fails: the debounce, the max wait, the minimum gap, the read retry (including after reconnect and resume), the catch-up reads after a retry or a relay-session reopen, the still-up checks, the backoff restart, the background check, retrying only capacity refusals, and clearing refusals on a new connection. - The terminal-rejection tests expect a chunk refused as `restricted:` never to be retried on that connection or to cause a list or history read, and a new connection to retry it at once. - `just mobile-check` and `just mobile-test` pass. - Device: on the 12 mini, 10 idle minutes with the app open, before and after (table above). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
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 [ |
||
|
|
61b8455a3c |
fix(mobile): open unjoined channels from links and search results (#8923)
Tapping a `#channel` link to an open channel you haven't joined showed
"Channel could not be opened", because a tap could only open a channel
already in the local list. A search hit had the same gap: it did nothing
when its channel wasn't in the loaded list, for example when the
open-channel directory failed to load or hadn't loaded yet.
Both taps now go through one resolver. It looks in the loaded list
first, then reads the channel's own metadata, as `buzz://` deep links
already do.
- An open channel you haven't joined opens in preview ("Join this
channel from Manage to participate").
- A private or withheld channel stays closed and shows "Channel not
found here".
- A failed lookup shows "Could not open the channel". So does a missing
channel when the channel list failed to load, since it may be one you
belong to.
The resolver also guards two cases:
- A second tap during a slow lookup doesn't open the channel twice.
- A lookup that finishes after you switch community or identity, or
after you moved to another page, opens nothing and doesn't block a new
tap.
Fixes AIDA-2324 and AIDA-2286.
[#8764](https://github.com/block/buzz/pull/8764) already made search
load the open directory, which fixed the common search case, so the
search part here covers the remaining fallback.
### Before: a link to an unjoined open channel fails
https://github.com/user-attachments/assets/c39cf0db-8098-4010-993e-cf664230c809
### After: the same link opens the channel in preview
https://github.com/user-attachments/assets/1422e70f-39c5-4598-8232-efad190b3413
### Validation
- Added tests:
- A table-driven channel-link journey with four rows: an unjoined open
channel opens, a withheld channel and a private channel show "Channel
not found here", and a failed lookup shows "Could not open the channel".
Each row double-taps during a gated lookup and asserts a single lookup
and a single push. Failure rows then tap again to show the lock is
released.
- A lookup that settles after the user opened another page pushes
nothing.
- Search journeys: a message hit opens its unlisted channel (including
after the channel list failed to load), or explains why it can't ("Could
not open the channel" when the list failed).
- Taps whose lookup will be discarded don't block the next tap: after a
community switch away and back, or from another page after the first
page closed. Each row fails if its part of the tap lock or the
connection fence is removed.
- I reverted the production files to `main` once and confirmed every new
test fails there.
- The full mobile suite and `mobile-check` pass.
- I ran it on an iPhone 18 Pro simulator (iOS 27), signed in to
Builderlab with an account that isn't a member of the open channel
`#speed-tests`.
- A link to that channel shows the error toast on `main` and opens a
preview with this change (videos above).
- A search hit in that channel opens it with this change. On `main` it
also opens, through [#8764](https://github.com/block/buzz/pull/8764)'s
directory load.
- Not covered by tests: waiting for a channel-list load still in flight,
a short guard I checked by reading the code.
- The human app-test step doesn't apply (Tom's standing exception for
mobile client changes).
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>
|
||
|
|
97ff4e19d1 |
docs(mobile): describe the role split in the media image keys (#8932)
Follow-up to [#8913](https://github.com/block/buzz/pull/8913). Its review [noted](https://github.com/block/buzz/pull/8913#discussion_r4445612714) that three doc comments in `media_image.dart` still describe the old keys after that PR split direct and thumbnail-fallback loads: - The class doc now says the provider keys on `isThumbnailFallback` as well as url, scale and auth scope. - The `_cooldownUntil` doc says it is keyed by (url, isThumbnailFallback), like `_inFlight` but without auth. - The `_SharedFetch` doc says a download is shared per URL, auth scope and role. Comments only, no behavior change. `flutter analyze` is clean and the full mobile suite passes. No app run, since nothing executes differently. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
35df866b33 |
fix(mobile): split escaped voice-note links into their own segment (#8940)
Follow-up to [#8925](https://github.com/block/buzz/pull/8925), from a [Codex finding](https://github.com/block/buzz/pull/8925#discussion_r4446048706) there. After #8925, message Markdown arrives with backslash escapes shielded, but imeta is keyed by plain URLs. Two lookups used the raw, still-escaped destination: - **Bubble segmentation** (`bubble_segments.dart`): an attachment link such as `[voice.mp4](https://…/voice\_note.mp4)` missed its imeta entry and stayed inside the text bubble, even though the link builder rendered it as an attachment. The fix unshields the URL before the lookup. - **Trailing image gallery** (`extractTrailingImageGallery` in `message_gallery.dart`): with two or more trailing images, an escaped destination kept its backslash, so the carousel loaded the wrong URL and lost the imeta alt text. The fix unescapes the destination the same way as the label beside it. Its callers in `message_media_avatar.dart` and `message_skeleton_body.dart` get the fix too. This gap was already on main. ### Validation - The existing `attachments stay outside bubbles and text order is preserved` journey now uses an escaped destination with a plain imeta URL. The existing carousel journey now has one escaped image destination and taps it by its plain URL. Each fails with its fix reverted. - `flutter analyze` is clean and the full mobile suite passes (4151 tests). - Limitation: no simulator run. Both changes are key lookups, and the widget journeys exercise the rendered layout. The full-app run for the surrounding escape handling is recorded in #8925. 🤖 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> |
||
|
|
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
|
||
|
|
2fd5c122e8 |
fix(mobile): load earlier edits to thread replies when a thread opens (#8870)
## Summary In the mobile thread view, if a reply was edited before you opened the thread, the reply showed its original text and no "(edited)" marker. An edit that arrived while the thread was open worked. The thread `/query` asked only for message kinds. The channel window carries edits, reactions and deletions (aux events) only for top-level messages, so an earlier edit to a reply was in neither result. The thread query now sends `include_aux: true`, as desktop does (`buzz-app` `src/features/relay/threads.ts`). The relay already supports this on the `depth_limit` thread route: it returns the aux events for the root and every reply, plus deletions of those (`crates/buzz-relay/src/api/bridge.rs`, `bridge_thread_aux`). A relay that ignores the flag gives the same result as before. The relay sends a page's rows (at most 200 reply-marked events, of any kind) before that page's aux events. It also repeats the root's aux on every page. So `fetchCompleteThread` treats the leading run of events reply-marked into this thread, other than reactions and NIP-09 deletions, as the page's rows. Only rows count toward a page, move the cursor and feed the reply cache. A reply-marked huddle event still fills a page, and a reply-marked reaction never becomes a reply. A reply-marked edit of the root in the repeated aux can follow a short final page's rows and look like a full page, so the scan also stops when the next cursor does not advance. The thread-summary scan (`fetchCompleteThreadReplies`) still asks for replies only and pages exactly as on `main`. ### Related issue Found during the emulator testing of https://github.com/block/buzz/pull/8863. ### Testing - `just mobile-check`, `just file-size-check` and `just mobile-test` pass. - `thread_replies_provider_test.dart` adds two tests. The first checks that the query sends `include_aux` and that an edit made before the thread opens folds into its reply. The second covers pagination: a reply-marked huddle row filling a page, a reply-marked edit that is a row on one page and aux on the others, a reply-marked reaction that stays aux, 200 reply-marked reactions after 199 replies (one query), 200 reply-marked root edits after a short final page (the progress guard ends the scan), an edit reply-marked into another thread that stays aux, and that the summary scan doesn't send `include_aux`. It fails if any of these is removed: the reaction/deletion exclusion, the root check, the 200 cap, the progress guard, or the separate row and aux id sets. - `channel_detail_page_test.dart` adds a journey: it opens `ThreadDetailPage` with the real thread query and a fake relay session that sends the reply's earlier edit only when `include_aux` is set. It checks the edited text, that the original text is gone, and the marker. It fails when the flag is removed or when the provider drops aux. - Android emulator (AVD, debug build) against a local relay. Morty posts "Who can review the release notes?", Demo User replies "I can, after lunhc" and then edits the reply to "I can, after lunch", all before the thread is opened. These shots come from the first revision of this branch. The pagination changes after it give the same result for a one-page thread like this one. | Before (unfixed: the #8863 build) | After (this branch) | Live edit while the thread is open | | --- | --- | --- | | <img src="https://raw.githubusercontent.com/block/buzz/4daefdf9a427c66dd80edcc59ee664c1da63824c/pr-8870--thread-before.png" width="300" alt="Before: original reply text, no marker"> | <img src="https://raw.githubusercontent.com/block/buzz/4daefdf9a427c66dd80edcc59ee664c1da63824c/pr-8870--thread-after.png" width="300" alt="After: edited reply text with (edited)"> | <img src="https://raw.githubusercontent.com/block/buzz/4daefdf9a427c66dd80edcc59ee664c1da63824c/pr-8870--thread-live.png" width="300" alt="Live edit while the thread is open"> | Before, the thread shows the original reply text with no marker. After, it shows the edited text and "(edited)". A live edit still lands. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
54ca2a3cc6 |
fix(mobile): render Markdown backslash escapes (#8925)
Mobile showed Markdown backslash escapes literally. A message linking a channel as `[\#buzz-mobile](buzz://channel/…)` rendered as `\#buzz-mobile`, and `\[text\]` rendered as LaTeX math. gpt_markdown has no backslash escapes. Outside code, each escaped ASCII punctuation character is now held as a stand-in (an invisible word joiner plus the character's fullwidth form) before parsing, so no other syntax (links, emphasis, mentions, channel tokens, math) matches it. A new inline component and the link and image builders restore the original character. The inbox/search preview builder already handled escapes this way; its helpers moved to a shared `markdown_escapes.dart`. Two edge cases found in review are covered: shielding runs after trailing-gallery extraction so carousel URLs and labels stay as authored, and a bare link no longer takes a trailing backslash into its destination. Codex review added: escaped backticks stay literal instead of opening code, authored word joiners are not mistaken for stand-ins, Buzz URL punctuation peeling keeps a trailing backslash, gallery semantic labels drop escapes, the trailing-backslash peel is linear and reruns after delimiter stripping, an escaped `\<url>` is not treated as an autolink, `~~~` fences are skipped like backtick fences, an autolink containing `\` keeps it in both label and destination, and the stand-ins use fullwidth characters so the one gpt_markdown fallback that shows raw text (multi-line strikethrough) still reads right. Later review: fence detection looks back only over blanks and `>` markers, so it stays linear on long lines (security review) and treats an unclosed fence inside a quote or list as code, like the renderer (Carl). Behavior change: `\(x\)` and `\[x\]` now show as `(x)` and `[x]` instead of math, per CommonMark. Desktop (buzz-app) doesn't render math either. `$…$` math is unchanged. ### Validation - Added tests: a `MessageContent` table covering escapes in prose, bold, link labels (HTTP and `buzz://channel`), fenced and inline code (left literal), a bare link ending in `\`, and escaped backticks, authored word joiners and fullwidth characters, a `~~~` fence, a multi-line strikethrough, `<https://example.com/a\>`, a Buzz URL ending in `.\`, an emphasized bare URL ending in `\*`, an escaped `\<url>`, and a trailing image gallery with escaped labels (including its semantics label), in plain and bubble layouts. Each row also asserts no stand-in reaches visible text or semantics. Reverting any one of these fixes fails it. - `flutter analyze` is clean and the full mobile suite passes, except for 11 `notification_toggle_test.dart` tests. Those fail locally on the Studio at the CI-green |
||
|
|
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> |
||
|
|
3ae2b6a377 |
fix(mobile): hide the Flutter debug banner (#8915)
Debug and simulator builds of the mobile app draw Flutter's red DEBUG ribbon over the top-right corner of every screen. It covers app UI in demo videos and screenshots, and nobody relies on it. This turns it off on the app's `MaterialApp`, matching the profile avatar sheet app, which already does. Release builds never showed it, so they're unchanged. ### Validation - `flutter analyze` and `dart format` pass. - Recorded the agent card flow demo on an iPhone 17 Pro simulator (iOS 27) from a debug build with this flag set: no DEBUG ribbon on any screen. 🤖 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> |
||
|
|
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> |
||
|
|
daeb463f37 |
fix(mobile): prompt Builderlab sign-in instead of labeling agents desktop-only (#8907)
Without a Builderlab session, mobile can't tell which agents run on Builderbot, so the Agents page labeled every agent "Manage on desktop", including remote ones. Now, when the community supports remote agents and there's no Builderlab session: - The Agents page shows "Sign in to Builderlab to manage remote agents" with a **Sign in** button (the same reconnect flow as Add). - Agent cards and the navigation subtitle drop the "Manage on desktop" label. - An agent's profile says to sign in to see whether it runs on Builderbot or desktop. ### Before / after iOS simulator, signed in to the remote-agent-pilot community with no Builderlab session. | Before (main) | After (this PR) | | --- | --- | | <img src="https://raw.githubusercontent.com/block/buzz/9be9cebedc8f5ff172e97a3df2cbf505a90e41c4/pr-8907--before-agents.png" width="300"> | <img src="https://raw.githubusercontent.com/block/buzz/9be9cebedc8f5ff172e97a3df2cbf505a90e41c4/pr-8907--after-agents.png" width="300"> | ### Validation - New widget test `agents_signed_out_prompt_test.dart` (runs with `BUZZ_AUTH_DOMAINS`, added to the CI list). - `just mobile-check` and the mobile test suite pass. - Checked the signed-out Agents page in the iOS simulator. buzz-review-completed (manual testing waived by Tom) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
b4509dee8b |
feat(mobile): add Buzzies and refresh agent avatar picker (#8921)
## Summary Adds Buzzies and refreshes the agent avatar picker with larger previews, rounded option strips, and clearer category choices. Also moves Agents above Appearance in the home menu and adds a custom agent icon. ### Related issue None found. ### Testing Analysis, 4,138 app tests, package tests, and 121 configured-auth tests pass. Earlier visual build exercised on Pixel and iOS simulator, with iPhone review; snapshots below show that build. | Android · Pixel | iOS · simulator preview | | --- | --- | |  |  | --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
d342cf141c |
fix(mobile): catch up unread from the newest message already seen (#8901)
## Summary
Every channel refresh path (app resume, reconnect, the backstop, joining
a channel) re-runs the unread catch-up. Each run asked for every
channel's messages since its read timestamp, up to 1000 full messages
per channel. For a reader with long-unread channels that is megabytes,
every time.
Measured on an iPhone 12 mini against the internal relay (gzip wire
bytes, instrumented profile build):
| | main | this PR |
| --- | --- | --- |
| Cold start unread catch-up | 3.5MB | 3.5MB (unchanged, first catch-up)
|
| Unread catch-up on app resume | 3.5MB | 23KB |
The 60s foreground backstop is one of those paths, so on main an open
app downloads the full catch-up again every minute (about 3.5MB a minute
on this account). With this PR each tick costs about 23KB.
On an Edge-class link (Network Link Conditioner "Edge", about 30KB/s) a
3.5MB batch needs about two minutes but has an 8s history timeout, so it
never completes and the next refresh starts it again.
A later catch-up in the same session now starts
`relayCrossDeviceSkewSeconds` (1860s) before the previous one was sent.
The relay rejects events whose `created_at` is more than 900s from its
clock, so a sender 900s slow and this phone 900s fast are at most 1800s
apart. Every message stored since the previous catch-up therefore falls
inside the window, including one that arrives late with an older
timestamp (for example, composed offline or on a phone with a slow
clock). Messages before that point were already recorded or skipped, so
unread and attention state is unchanged. The record is cleared with the
rest of the unread state when the channel list is rebuilt (community or
identity switch), and ignored if a channel's read timestamp ever moves
back. A channel whose websocket fallback filter failed (without a
deadline, so the batch still completes) is not recorded, so the next
catch-up asks for its whole window again. A deletion that removes an
unread event the app already recorded also drops the channel's record.
That event may have been the oldest of a full window of quiet agent
messages, standing in for older unread ones the window hides, and only a
whole-window catch-up finds them again. A catch-up that was already out
when that happened doesn't restore the record, since its response only
covers the narrower window. A channel muted or unmuted since its record
also catches up over its whole window, because muting changes which
messages count.
## Testing
- New test: a repeated catch-up starts 1860s before the previous one was
sent, and still records a message that arrived late with a timestamp
1500s before it. Confirmed it fails without the drift window (`since`
starts at the previous send time) and with the earlier 960s window.
- New test: a rebuilt channel list catches up over the whole window
again.
- New test: a channel whose batch and websocket fallback both failed
catches up over its whole window on the next refresh. Confirmed it fails
without the fix.
- Extended test: deleting the quiet-window marker while a catch-up is
out makes the next catch-up ask for the whole window, and the next quiet
message stands in. Confirmed it fails without each fix.
- New test: a channel unmuted since its last catch-up asks for its whole
window and records its ordinary backlog. Confirmed it fails without the
fix.
- The test fake now honors `since` and `until` like the relay. The
deletion cases now use recent timestamps, because a relay never stores a
1970 event late.
- `just mobile-check` and `just mobile-test` pass.
- Device: resumed Buzz on the 12 mini on Wi-Fi with the fix;
backgrounded for a minute, the catch-up requests were 22KB and 0.5KB
instead of 3.2MB and 287KB (
|
||
|
|
464448c7b2 |
fix(mobile): resync read state from the last fetch, not the whole week (#8903)
## Summary Every read-state resync (each reconnect and each resume from background) fetches every read-state blob (kind 30078, `#t: read-state`) from the past week, then opens a live subscription with no `since`, which replays the same blobs again. Merging keeps the newest mark per context, so blobs from before the previous fetch add nothing. Each blob is a whole device's read marks, about 100KB for a heavy reader. A later fetch and the live subscription now start `relayCrossDeviceSkewSeconds` (1860s) before the last successful fetch was sent. The relay rejects a `created_at` more than 900s from its clock, and blobs come from other devices, so a writer 900s slow and this phone 900s fast are at most 1800s apart. Every blob stored since that fetch falls inside the window. A failed fetch keeps the earlier start. The first fetch of a manager still covers the whole horizon. Companion to [#8901](https://github.com/block/buzz/pull/8901) and [#8902](https://github.com/block/buzz/pull/8902), which do the same for the unread catch-up and the latest-message query. Measured on an iPhone 12 mini against the internal relay, resuming after 60s in the background (kind 30078 websocket bytes, instrumented profile build): | | main | this PR | | --- | --- | --- | | Read-state resync on resume | about 1.8MB (91 blobs, fetch plus subscription replay) | 415KB (4 blobs changed since the last fetch) | ## Testing - New test: the first fetch covers the horizon, the live subscription and later fetches start 1860s before the last successful fetch was sent, and a failed fetch keeps that start. Confirmed it fails without the fix and with the earlier 960s window. - New test: after the clock moves back, a resync whose fetch fails isn't retried on every later read; the next reconnect fetches the whole horizon. Confirmed it fails without the fix. - `just mobile-check` and `just mobile-test` pass. - Device: resumed the app on the 12 mini on Wi-Fi; the resync fetched only the 4 blobs stored since the previous fetch, and the live subscription replayed none. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
9d1b684455 |
fix(mobile): show remote agent instructions from the registry (#8908)
After creating a remote (Builderbot) agent with instructions on mobile, its profile said "Instructions aren't available on mobile for this agent." Two causes: - Mobile called `list-agents` without `include_instructions: true`, so registry rows had no `agent_instructions`. Desktop already sends this flag. - The profile read instructions only from published desktop agent config, which remote agents don't have. Now mobile requests instructions in `list-agents` (both the listing and the post-create readback). For an agent in the active community owner's registry listing, the profile shows the row's `agent_instructions`. Like desktop, a missing field means "No instructions set." Desktop agents keep the published-config path. Because listings now carry instructions (up to 20,000 characters each), `list-agents` gets a 4 MB response bound instead of 64 KB. The delete-retry journal, which lives in plain preferences, drops instructions before it's written. ### Before / after iPhone SE, remote-agent-pilot community: profile of an agent just created on mobile with the instructions "Reply in one short sentence." | Before (main) | After (this PR) | | --- | --- | | <img src="https://raw.githubusercontent.com/block/buzz/5d7333ea429e6c8c8aae93846d23c3a2bb9bda25/pr-8908--before-instructions.png" width="300"> | <img src="https://raw.githubusercontent.com/block/buzz/5d7333ea429e6c8c8aae93846d23c3a2bb9bda25/pr-8908--after-instructions.png" width="300"> | ### Validation - Extended `agents_sheet_test.dart`: the remote-agent case asserts the `include_instructions` flag and that the profile shows the registry text. It fails without the fix. - New/extended tests for the larger `list-agents` bound and the retry journal; each fails with its fix reverted. - `just mobile-check` and the mobile test suite pass. - iPhone SE (release build, remote-agent-pilot community): created an agent with instructions on mobile, then opened its profile, which showed them. Deleted the test agent afterward. buzz-review-completed (manual testing waived by Tom) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
985de41f14 |
feat(mobile): show NIP-FI verified names (#8858)
Stacked on #8700. Review only the last commit until #8700 merges. NIP-FI relays can publish a relay-signed verified name for each member who signs in with an organization assertion (kind 30382, specified in #8745 and implemented in the relay in #8774). Desktop shows these names in block/buzz-app#2005. Mobile showed only self-declared kind 0 names, so a verified Block name looked no different from one anyone could set. This adds verified names to mobile, following the spec and the desktop client's validation rules. **What people see** - A verified member's name is the issuer-verified display name wherever mobile resolves names through identity names (message headers, threads, DM titles, member lists). - Message headers and the profile sheet show a verification mark. The profile sheet also shows the full name when present, "Verified by {issuer}", "Last verified {time ago}", and marks the record as cached when it hasn't been reconciled with the relay on this connection. Verification is described as historical, never as current access. - Nothing changes on relays that don't advertise `federated_identity.public_profile`, and marks never come from kind 0 or NIP-05. **How it works** - NIP-11 discovery decides whether marks may show: a successful response without `public_profile` hides them (cached records are kept), and a failed or malformed response keeps the last state. Discovery refreshes on connect and every 5 minutes while names are tracked. - Subjects are tracked as the profile cache resolves them, and fetched in batches of up to 128 with `{kinds:[30382], authors:[self], #d:[...]}`. Live updates and retractions apply on the same subscription. A full batch that omits subjects is not treated as proof they have no record. - Each event's id and signature are verified (in an isolate) before use, and it must be authored by the community's NIP-11 `self` key with exactly the specified tags (plus the optional `full_name` the relay emits). Names follow the spec's NFC and character rules; `unorm_dart` (MIT, pure Dart) is added for the NFC check because Dart has no built-in normalizer. - The cache is per community and is cleared when the relay's `self` key changes. **Not yet covered** - Some surfaces still read the kind 0 name directly: the members sheet initial, and search and inbox rows. The mark isn't shown in search rows, inbox rows, or member tiles. (The mention picker now matches, ranks and inserts by the verified name and shows the mark, and it also offers cached verified people outside the channel whose kind 0 name differs.) - NIP-FI says clients MUST show `issuer_label` beside every verified name. Here it appears only in the mark's tooltip and accessibility label and in the profile sheet, and verified names reaching DM titles and member lists show no mark or issuer. Follow-up: show the issuer inline wherever a verified name is shown. - Live updates are coalesced to the newest per subject before signature proof, ordered by unverified `created_at`/`id`. Only a misbehaving relay could exploit this (the subscription is `authors:[self]`), and it could already withhold events. Follow-up: prove first, then coalesce among valid events. - The verified `username` isn't shown beside the name in message headers (the NIP-05 handle still is). - Collision qualification relies on the existing name-disambiguation policy rather than the spec's exact self-declared-name rule. ### Validation Added tests: - Validation: discovery enabled, disabled and malformed; wrong author; mismatched or duplicate `d`/`p`; extra or duplicate tags; invalid usernames; non-NFC and whitespace-padded names; `verified_at` after `created_at`; bad signatures; retractions. - Directory: discovery gating, batching, truncation, live retraction, reconnect reconciliation, and disable hiding cached records. - Directory failures: a pre-EOSE flood, failed proof retried on refresh, oversized events (by total size, id or signature) dropped before proof, live events coalesced, a retry before or after the first EOSE never proving absence, a retry during proof not promoting records to checked, and a newer signed malformed projection clearing the older one. - Mention picker: a verified name finds, ranks and inserts the verified person; the self-declared name still finds them; a verified person outside the channel is found by verified name even when an impostor copies that name into kind 0. - Identity names: a verified name outranks the kind 0 name. - UI: the mark in message headers and the profile sheet details, no mark for a cached record, and a single accessible label. Full `mobile-check` and `mobile-test` pass. **Live relay (checked 2026-11-01):** not yet run end to end. - Staging now advertises the feature: the bb-block staging relay's NIP-11 (`buzz.block.builderlabstaging.xyz`) has `federated_identity.public_profile` with `kind: 30382` and `multiple_issuers: false`, bound to `self` `f7f4df94…dcf1`. So #8774 is deployed and enabled there. The other staging relays (`communities.public.builderlabstaging.xyz` and its `team.` host) advertise no `federated_identity`, and the client shows nothing on them. - I couldn't confirm that real 30382 events are served or render them. The relay requires NIP-42 auth plus a client-attached NIP-FI assertion, which needs an interactive Builderlab/Okta sign-in on a physical device. SSO doesn't work in the iOS simulator. The remaining step is a device run signed in to the staging Block community, with screenshots of a verified name, its mark and its details. **Shipping behind discovery gating:** this is safe. The client reads and shows verified names only when a successful NIP-11 discovery advertises `public_profile` bound to `self`. It accepts only kind 30382 events signed by that key that pass id and signature verification and strict shape checks. On every relay without the feature, which is every relay but bb-block staging today, it shows nothing new and changes no names. A failed or malformed discovery never turns the feature on, and it marks cached records as unchecked, which hides their mark. The subscription and reconciliation paths are covered by the tests above, and Carl's review reran retry probes through the real `RelaySessionNotifier`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com> |
||
|
|
220cec677a |
fix(mobile): keep community icons on originals and record thumbnail 404s at fetch (#8896)
Follow-up to #8879, addressing its review. ## Changes - **Community icons load the original again.** `AvatarImageContent` gained a `preferThumbnail` flag, off by default. People and agent avatars turn it on (as #8879 did for every caller); `CommunityAvatar` leaves it off. Community logos are often transparent PNGs, and the relay's JPEG thumbnail renders transparency black. This matches desktop, which shows community icons from the original. - **A missing thumbnail is recorded where its 404 arrives.** Before, only the mounted `Image`'s error builder recorded it. If the avatar scrolled away before a slow 404 came back, the thumbnail URL kept a 30s failure cooldown instead, and showing that avatar again at any size during the cooldown rendered initials and never requested the original. The fetch now records a 404 on a relay `/media/<sha256>.thumb.jpg` URL in the missing-thumbnail set and puts no cooldown on it. ## Tests Two journey tests in `avatar_image_test.dart`, both failing on main and passing here: - An avatar unmounts before its slow thumbnail 404 arrives, then mounts again at another size: it requests the original once, renders it (valid image bytes, no initials), and leaves no cooldown. - A 36pt `CommunityAvatar` with a relay-hosted icon requests and shows only the original. `flutter analyze` is clean. In the full local `flutter test` run, only 12 push and notification tests fail, and they fail the same way on main in this environment. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
f6f6d9a673 |
chore(mobile): delete dead code with no remaining UI path (#8880)
## Summary Deletes mobile code that no longer has a user-reachable path and has no counterpart in buzz-app, so it is unlikely to come back for parity. Net: 104 files, about 5,500 lines removed. No intended behavior change. - **iOS native message menu and action surface:** `NativeMessageMenu.swift` and `NativeMessageActionSurface.swift`, the `message`/`supportsMessage` coordinator branches, and the Dart selection hook. Dart stopped creating this view in [#8280](https://github.com/block/buzz/pull/8280). The native reactions sheet in the same coordinator is kept. - **Old home quick-actions launcher and `ComposeMorphIcon`:** only instantiated in tests since the nav-bar creation actions replaced them. - **Retired local theme stack:** `ThemeNotifier`, `AccentNotifier`, `SchemeNotifier`, `adaptive_theme.dart`, `AccentPickerPage`, the appearance-mode sheet and their helpers. These predate per-community theme sync. The legacy prefs migration in `community_theme_preference.dart` is kept. - **Unused widgets:** `ProfileAvatar` and `MaskedAvatarBadge`. - **Push permission-status plumbing:** the iOS `notificationAuthorizationStatus`/`openNotificationSettings` handlers, plus the uncalled strict age-gate snapshot registration and its Swift `syncAgeGatePushSnapshot` path. - **Orphaned files** with no importer: `profile_text_edit_sheet`, `editor_option_button`, `retained_page_transition`, `tappable_flapping_bee`, `agent_model_info`, `community_bot_profiles`, `sheet_divider`, `success_haptic`. - **Uncalled helpers:** the `pickAndUpload*`/camera helpers in `media_upload.dart`, unused `NostrFilters` builders, `mediaGetHeadersFor*`, and about 20 other uncalled functions, providers and styles. - **Removed-UI leftovers:** the never-wired invite directory providers, `AuthNotifier.signOut` and `removeActiveCommunityForSignOut` (the Sign out row is gone), `renameCommunity`, `HomePage.profilePageBuilder`/`SettingsPage.embedded`, and the create-forum branches of the create-channel sheet. Forum viewing is unchanged. Tests that only covered deleted code are removed. Tests of surviving code that used deleted helpers as drivers were repointed to the surviving API. An audit of `mobile/android` found no dead Kotlin, resources, manifest entries or Gradle dependencies, so Android code is unchanged. ## Validation - `flutter analyze`: no issues. - After rebasing onto main ([ |
||
|
|
2e0abbcc0c |
fix(mobile): let slow image downloads finish (#8881)
On a slow link, a large image could never appear. Every image fetch was aborted 30s after it started, even while bytes were still arriving, and a failed fetch then sat on a 30s cooldown before it could be retried. At 240 kbps, anything over about 900 KB could not finish. On a shaped iPhone SE, a 1.45 MB avatar was fetched, aborted at 30s and never shown. - **Abort stalls, not slow downloads.** A fetch is now aborted when no headers or body bytes arrive for 20s, so a dead connection still frees up quickly. A download that keeps making progress can run for up to 5 minutes, so a server that trickles bytes cannot hold a connection forever. The 20 MiB response cap still bounds its size. - **Cancel previews scrolled past in the iOS carousel.** The carousel loads at most four previews at a time, and a preview scrolled past kept its slot until its download ended. With the longer limit, four slow previews could block the visible one for minutes. Now the carousel drops a cancelled preview's pending image-cache entry, and with no one left listening, the image loader aborts its HTTP request. Scrolling back starts a fresh download with no failure cooldown. A download that another consumer, such as the full-screen viewer or a gallery prefetch, is still listening to keeps going, and its finished image is still cached. Closing the carousel aborts its pending previews the same way. - **Cancel gallery prefetches that leave the window.** The message gallery prefetches a few pages around the current one with two workers. Each prefetch used to hold its download until it ended, which also kept the carousel's cancel from aborting it. Now a prefetch that leaves the window, or whose gallery goes away, lets go and aborts the same way. ### Validation Added tests (each fails without its fix): - A body that trickles in for longer than the stall timeout still loads, and one that never ends is aborted at the overall cap. - In the iOS carousel, previews scrolled past abort their stalled downloads (before headers and mid-body), the next preview loads, and scrolling back starts a new download. The test fails if the carousel doesn't evict, if the loader doesn't abort, or if the abort leaves a cooldown. - Closing the carousel aborts its stalled previews. - In a message gallery, jumping to the end and back aborts both in-flight prefetches and moves the workers to the new window. - Abandoning a load another consumer still waits for keeps the finished image cached with no second fetch; abandoning one nobody else waits for aborts it. - Rapid scrolling releases each cancelled carousel load at once, and cancelled loads never reach UIKit. The full mobile suite passes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
76fd0c6151 |
fix(mobile): show Bestie as a continuous conversation (#8890)
Opening Bestie from the mobile shortcut currently presents the agent DM as a threaded channel. Present that same DM as a continuous conversation, matching the top-level interaction in desktop Bestie. Read existing roots and replies in chronological order using the flat history provider, show typing inline, and keep new sends at the DM top level. Disable thread-opening taps, swipe replies, and the Reply menu entry in this view. Preserve the DM identity, history, membership, and recipient rules; Solo ownership routing remains separate. Regression coverage exercises the Bestie launcher, existing threaded answers, reply-target navigation, taps/swipes, and top-level DM sends. Validation: `just mobile-check` passed; `just mobile-test` passed (4,051 Flutter tests, 34 skipped; 4 unconfigured-push tests; 27 crypto-package tests). The mobile file-size check and `git diff --check` also passed. Manual app check before readiness: 1. Open the bottom-corner Bestie shortcut for an agent whose DM already contains threaded replies. 2. Confirm those answers appear inline with the original messages, without a separate thread screen or reply-count links. 3. Tap and swipe a message; confirm neither opens a thread. Long-press should retain reactions and other actions without Reply. 4. Send another message, then close and reopen Bestie. Confirm it remains in the same DM and conversation history. 5. Open an ordinary channel and confirm its existing thread behavior still works. Native app walkthrough and human confirmation are pending, so this PR remains draft under AGENTS.md's readiness requirements. Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
53ca047cd3 |
fix(mobile): keep mention rows still and cache directory searches (#8875)
Typing `@s` in a stream showed the matching channel members, then about half a second later added people from outside the channel. The chooser sits on the composer and grows upward, so each late row pushed every shown row up and a tap could land on the wrong person. It happened every time, even for a query typed moments earlier, because each new `@` started its directory search from nothing. ### Changes - **Search cache.** Finished directory searches are kept per community and signer, in a small LRU keyed by query (`MentionSearchCache`). A query searched before shows its people at once while a fresh search runs in the background. A query not searched yet starts from the longest searched query it extends (`@si` starts from `@s`), since a name-prefix search for a longer query finds no one the shorter one missed (up to the search's result limit). This replaces the per-opening "last search" that reset on every new `@`. - **Rows hold still.** While a query's directory search can still add people or a retry row (`mentionSearchPendingProvider`), the chooser reserves its full height with rows at the top, so late rows fill empty space instead of moving shown rows. This includes the background refresh of a cached query. The reservation lasts until the query or the opening changes, so the panel never shrinks under the finger either. - **Three rows, with progress.** The chooser has a fixed three-row height (fewer rows at larger text sizes) before it scrolls, and never taller than the space above the composer (so a short view, such as landscape with the keyboard up, keeps its rows on screen). While the search runs, a thin bar overlays its top edge without changing its height. The trade-off: while a query's search runs, the panel is full height even when it ends with one or two rows. ### Demo iPhone 18 Pro simulator, local relay. Sam and Sasha are channel members; Saba, Sierra and Skyler come from the directory search. https://github.com/user-attachments/assets/efae33ce-ee65-447b-9402-b0741c8dc2da ### Before merge - [x] Kenny Lopez reviews the visible changes ([approved in Buzz #buzz-mobile](buzz://message?channel=9f18f3c6-b5e5-453d-b36d-65487913be0b&id=c8b01eaa3760e44c3b6583f2e02bec576d78679adfb364e5fe718cc0926bdc2d)). ### Validation - New tests: a cached query reopens with its people at once, stays pending while it searches again, and clears when that search finishes; a longer query starts from its prefix's people; an uncached query is pending until its search finishes; and a compose bar journey step checks that a late directory person leaves the shown row's position and the panel height unchanged, both on a new query and on a cached query's refresh. Other new tests cover the three-row cap, the progress bar, and an 844×390 landscape view with the keyboard up, where the reserved chooser previously pushed the only row above the screen. Disabling the cache lookup fails the two cache tests, and disabling the reservation fails the layout tests (the cached-refresh case moved a row 59pt). - `just mobile-check` and `just mobile-test` pass. - Exercised on the simulator against a local relay as shown above. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Signed-off-by: Tom Brow <tomb@block.xyz> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: mbp <973be878edd6d4f79692a3827f20a6f7fe0ed0362f269ff6b6ba8867033d7d8e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
93b8b8febc |
Standardize mobile navigation edges (#8895)
Standardizes mobile navigation headers: Android uses Home’s shared Flutter blur; iOS uses native soft edges, with graduated material retained during large-title collapse and the iOS 26 fallback preserved. Preserves Home’s upward title motion with a soft boundary and keeps the avatar crop title visible. Validation: 4,112 Flutter tests and 111 native tests passed after merging main. Rendered partial-scroll regression verified on the iOS 27 simulator; removing either review fix fails its regression assertion. The earlier preview was installed on a physical iPhone with the existing profile. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
634f48a8bd |
Mobile: open Solo chats with Bestie and workspace controls (#8884)
Mobile New chat now opens directly with the synced Bestie selected. The first send creates and names the chat; leaving an empty draft creates nothing. New and existing Solo chats can select or clear a workspace from the header popover, with a checkmark for the active choice and a No Workspace option. Workspace and agent controls have separate iOS glass surfaces, Bestie stays at the far right, and Back uses a chevron. The agent sheet fits its list, with scrolling for long lists. Workspace creation stays in the Home New menu. The New menu groups Solo above Team. Android expands across the full control row; iOS keeps its native menu while Search and Bestie merge into New through Liquid Glass, with synchronized icon motion and the residual glass fading away. Empty workspaces use a compact, muted message. Validation: 4,055 mobile tests passed (34 skipped), static analysis passed, and native menu checks passed. Earlier physical-device previews and the iPhone New-menu animation were visually approved before this consolidation. This PR consolidates the controls previously developed in #8891 into the existing draft/first-send implementation. The New menu animations, attachment handling, and durable retries remain in this PR. The combined controls have widget interaction coverage; the combined build has not yet had a new physical-device walkthrough. Current combined controls below are Android Flutter widget renders with fixture data, not device screenshots. Images are GitHub attachments only. | New conversation | Workspace popover | Compact agent sheet | | --- | --- | --- | |  |  |  | Empty workspace widget preview (unchanged):  --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
21a9839826 | Fix mobile unread state retaining deleted messages (#8883) | ||
|
|
e18d422d72 |
fix(mobile): fill uploaded avatars on the Agents page (#8882)
Uploaded images on the mobile Agents page now crop to fill the avatar squircle, matching desktop. Animated avatars and bundled character posters retain their existing sizing. Validation: `just mobile-check mobile-test` passed (4,050 Flutter tests; 34 skipped), plus before/after rendering of the production card. Regression coverage checks uploaded, preset and animated images. Snapshots use a sample rectangular PNG in a local Flutter widget preview, not an installed build. | Before | After | | --- | --- | |  |  | Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
90772a43e9 |
fix(mobile): keep avatars and images loading on slow links (#8879)
On a slow link, avatars in a channel could fail to appear entirely. Two causes: - **Duplicate downloads.** Each decode size is its own image-cache entry, so an avatar shown at two sizes (a message row and its reply summary) started two downloads of the same bytes, which then competed for bandwidth. The in-flight download is now shared per URL and auth service. - **Full-size avatars.** Avatars a few dozen points wide downloaded the original upload, which can be megabytes. The relay generates a JPEG thumbnail of at most 320px for every uploaded image, and desktop already loads it for small avatars. Mobile now does the same for any image format when an avatar is at most 240 physical pixels on each side. About 88% of relay-hosted avatars on the internal relay are PNG, with a median size of 337 KB. As on desktop, the thumbnail is a still JPEG: transparent areas render black and animation is lost. Rendered side by side, mobile and desktop look the same. If the relay returns 404 for a thumbnail (older or externally populated media), the avatar loads the original instead, as the notification extension's avatar loader already does. ### Before merge - [x] Kenny Lopez reviews the visible changes ([approved in Buzz #buzz-mobile](buzz://message?channel=9f18f3c6-b5e5-453d-b36d-65487913be0b&id=c8b01eaa3760e44c3b6583f2e02bec576d78679adfb364e5fe718cc0926bdc2d)). ### Validation On an iPhone SE (iOS 27.0.1) running a profile build, relay traffic went through a Mac-side proxy shaped to 200ms one-way latency, 240 kbps down and 200 kbps up. I opened a busy channel and logged each relay image fetch. Every fetch is aborted 30s after it starts, which [#8881](https://github.com/block/buzz/pull/8881) changes separately; with thumbnails the avatars below no longer come close to that limit. | Avatar | Before | After | | --- | --- | --- | | A | 1.45 MB, fetched twice; both aborted at 30s, never shown | 14.5 KB thumbnail, 3.6s | | B | aborted at 30s, never shown | 14.5 KB thumbnail, 4.1s | | C | 125 KB, fetched twice, 26s each | 21.5 KB thumbnail, 4.3s | | D | 46 KB, 11.1s | 24.7 KB thumbnail, 4.6s | Added tests (each fails without its fix): - One URL resolved at two decode sizes downloads once. - Small relay avatars request the thumbnail and large ones the original. - A thumbnail 404 falls back to the original, and other thumbnail failures show the avatar fallback. - The thumbnail URL is built only for relay-hosted blobs (`/media/<sha256>` with an optional extension), the same pattern desktop's `mediaUrl` uses. The full mobile suite passes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
543ecf2b1f |
fix(mobile): list the same owned agents as desktop (#8857)
## Summary
Mobile's Agents modal listed agents that desktop does not show. Most
were agents created in the earlier Buzz app (this repository's
`desktop/`), which the owner had not imported into Buzz 1.0
([block/buzz-app](https://github.com/block/buzz-app)). @ completion
offered them as well.
Desktop lists an owned agent only when the owner's newest kind:30177
record for that agent has a `pluginId/typeId` `runtime` tag and a name.
Records written by the earlier app have no `runtime` tag. Importing an
agent on desktop republishes its record with one
([`listingsOf`](
|
||
|
|
b41caeb510 |
fix(mobile): time out relay connects that never finish the handshake (#8878)
When a phone's network silently stops working (crowded event Wi-Fi, a weak cell signal), Buzz Mobile can stay offline for minutes. In a device test, the app took 30s to notice the dead connection, then hid the channel's messages behind a loading skeleton. It tried to reconnect once, and that attempt hung for 5 minutes (303s). It never retried, because it only retries after an attempt fails. This PR gives every reconnect attempt a 10s limit. When an attempt runs out of time, the app closes it and tries again with its normal backoff. ### What was wrong Opening the relay connection has two code paths. The one used with hosted relay auth already had a 10s limit. The other path had none, so a stuck attempt waited for the operating system to give up, which takes minutes. Both paths now use the same limit. Each attempt also gets its own HTTP client, so a timed-out attempt is actually closed rather than left running in the background. ### Validation I tested on an iPhone SE (iOS 27.0.1) running a profile build, with its relay traffic routed through a Mac proxy that could silently drop everything. - **Before:** during a 5.6 minute outage, the app made one reconnect attempt, which hung for 303s. - **After:** during a 2.5 minute outage, each attempt gave up after 10s and the app kept retrying. It reconnected 36s after the network came back, a delay mostly set by the 30s cap on time between retries. Added tests: `relay_socket_liveness_test` now covers an attempt that never completes, with and without hosted relay auth headers. It checks that the attempt fails and that the abandoned connection is closed. The test fails without the fix. Two existing tests were opening real relay connections by accident or design: - `deep_link_dispatcher_test` now uses a fake channel list, like its neighboring tests. - `remote_agents_test` talks to a real local relay on purpose, so it now uses a real HTTP client instead of the test framework's mock. The full mobile test suite passes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> |
||
|
|
5f98a1fa04 |
mobile: quiet agent messages and caught-up channel highlights (#8876)
Mobile bolded a channel whenever any reply in one of your threads, or any mention, was unread. Catching up on the channel itself never cleared those, so a channel could stay bold with nothing new in its timeline. Agent progress messages (sent with the `agents` audience) also counted, so busy agent threads kept channels lit. This brings the mobile channel list in line with the desktop sidebar rules in buzz-app's `docs/unread.md`: - **Quiet agent messages** (`["audience","agents"]`) never count as unread or earn attention, even when they mention you. They're dropped where mobile records observed unread events, so the channel list, Solo chats and app badges all agree, and Activity drops them too (paging past a mention window they filled). If the bounded catch-up window comes back full of quiet messages, its oldest one still counts as plain backlog, so an older unread message hidden behind them keeps the channel unread. - **Bold means ordinary backlog.** A channel's label bolds only for unread top-level messages (what channel catch-up reads) or a manual unread mark. A manual mark doesn't add a dot, but it keeps the dot of any real attention. - **A dot means attention.** Unread replies in your threads, mentions and broadcasts show a small accent dot (the Activity row's unread dot) without bolding. Opening the thread clears it, as does long-press → Mark read, which the menu now offers for dot-only channels. - Unread DMs bold and show the dot, as on desktop. Solo chats keep their current rule. Not included: desktop's popover listing the unread threads behind the dot. ### Before merge - [x] Kenny Lopez reviews the visible changes ([reviewed in Buzz #buzz-mobile](buzz://message?channel=9f18f3c6-b5e5-453d-b36d-65487913be0b&id=c8b01eaa3760e44c3b6583f2e02bec576d78679adfb364e5fe718cc0926bdc2d); his overlap question about #8883 is answered: the two are complementary). ### Validation - Added tests: - A channel-list unit test covering backlog, reply, mention, DM, manual unread (with and without attention) and Solo. - Widget cases: a reply-only channel shows a dot, not bold, and clears on the thread mark or on long-press → Mark Read. A mention after catch-up is a dot. Backlog plus a mention gives bold plus a dot. - Provider tests: quiet agent messages, both from catch-up and live, are never recorded (fails without the fix). A full catch-up window of quiet messages still counts (fails without the fix). Quiet mentions never become Activity or Inbox items, even when 50 of them fill the mention window in front of a real one (fails without the fix). - Full mobile suite (`just mobile-test`) and `flutter analyze` pass locally. - Simulator recording against a local relay (iPhone 18 Pro, iOS 27). Sam replies in the sim user's thread, which shows a dot without bold. Sam then posts top-level, which bolds the label. Opening the channel reads the backlog, so it returns to regular with the dot. Long-press then offers Mark Read, which cleared the dot. That last step finished just after the clip ends. - Not exercised live: the agents-audience exemption, because the CLI can't set that tag. The provider test covers it. https://github.com/user-attachments/assets/55f74377-d7f3-4b31-a0c8-b456f4e05770 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
83ec9c0e2b |
fix(mobile): show hosted community names consistently (#8872)
When a hosted relay has no metadata name, the hosted add paths save its host (`remote-agent-pilot.communities.buzz.xyz`) as the community name. Onboarding shortens that fallback to `remote-agent-pilot`. After joining, though, the community switcher, the remove dialog, the theme preview and the identity export showed the full host, so the same community appeared under two names. This change shortens the name in the model instead of at each call site. `Community` and `AddCommunityDeepLink` keep the saved value private, and their public `name` getter returns the display form, using the existing `HostedEnvironment.communityDisplayName` rule. Existing and future readers get the short name without opting in. Serialization still writes the saved value, so stored data is unchanged and no migration is needed. Custom names and non-hosted hosts pass through unchanged, as before. Community identity is unaffected because it uses `id` and `relayUrl`, never the name. Beyond the captures below, the same getter shortens the name in the iOS push notification subtitle, onboarding's "Adding …" progress text, the "You joined …" invite sheet, and the VoiceOver labels for removing and loading a community. ### Before and after | | Before (`main`) | After | |---|---|---| | Community switcher |  |  | | Remove dialog |  |  | | Theme preview |  |  | | Identity export |  |  | ### Before merge - [x] Kenny Lopez reviews the visible changes ([approved in Buzz #buzz-mobile](buzz://message?channel=9f18f3c6-b5e5-453d-b36d-65487913be0b&id=c8b01eaa3760e44c3b6583f2e02bec576d78679adfb364e5fe718cc0926bdc2d)). ### Validation - Captures come from iPhone 17 simulators (iOS 27) running `main` and this branch. Both builds used the same scratch-only seed with two communities whose saved names are their hosted hosts. The seed is not part of this PR. The relays were unreachable from the simulator, so the screens show loading placeholders. - Changed tests: the community switcher journey now uses a hosted host-name fallback for one community. It asserts the grid label, the remove tooltip and the dialog text show the short name, and it fails when the getter returns the saved value. The onboarding label table now asserts the model's `name` itself is shortened. The hosted-flow fixture records relay hosts instead of names, and the auth-domain tests look up communities by relay URL. - The full mobile test suite and `flutter analyze` pass. The CI step that runs with configured authentication domains also passes locally (113 tests). - Not captured: the push notification subtitle, onboarding's "Adding …" text and the invite sheet. Reaching them needs hosted sign-in or push, which the simulator doesn't support. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
e71fbe23df |
fix(mobile): keep loaded timeline visible while reconnecting (#8864)
Returning to an already-open channel after a few minutes in the background could replace its messages with the loading skeleton. The relay session drops its socket five seconds into the background, so nearly every return to the app reconnects. When that reconnect took more than two seconds, the channel page covered the loaded timeline with the connection skeleton until the relay came back. The messages provider already keeps the cached timeline through a reconnect, so the overlay was the only thing hiding it. Now a loaded timeline stays visible while the relay reconnects. The skeleton still shows when nothing has loaded yet, and forums keep their existing behavior (skeleton only before any posts load). To keep a reconnect indicator, the iOS title capsule now shows a thin bar in the chat color (the Appearance accent) along its bottom edge while a page with loaded content is not current. For a channel timeline that covers both the reconnect and the catch-up after it (resubscribing and fetching the newest window). The wait has no known length, so the bar sweeps in, pulses until the channel is current, then fades. Reduce Motion shows a static bar. Forums show it only while reconnecting, because their posts provider doesn't refetch after a reconnect. The native navigation view owns the bar, so it survives title rebuilds. Android has no title capsule and is unchanged. ### Demo iPhone 18 Pro simulator on a local relay: background the app for more than 5s with the relay down, return, then let the relay come back. About 8s of relay startup is cut from the middle. https://github.com/user-attachments/assets/e88d0191-766b-4054-bc00-99ed306cd0cc ### Before merge - [x] Kenny Lopez reviews the visible changes ([approved in Buzz #buzz-mobile](buzz://message?channel=9f18f3c6-b5e5-453d-b36d-65487913be0b&id=c8b01eaa3760e44c3b6583f2e02bec576d78679adfb364e5fe718cc0926bdc2d)). ### Validation - Updated tests: the reconnect cases in `channel_detail_page_test.dart` and `loading_review_tests.dart` now assert that loaded messages and cached media stay visible and in place during a reconnect. - Added test: the iOS navigation payload sets `titleRefreshing` while a loaded timeline reconnects and clears it on connect. - Added test: `isCatchingUp` holds from reconnect until the newest window merges, and is already false when that state publishes. - Added test: a failed catch-up after reconnect still notifies listeners once, with `isCatchingUp` false, so the bar clears. - `flutter analyze` clean, iOS simulator build succeeds, and the full mobile suite passes on the final head. - Checked visually on an iOS 27 simulator (demo above): loaded messages stay visible on resume, and the bar pulses from foreground until the reconnect's catch-up finishes, then fades. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
2e4aacc9e0 |
fix(mobile): find open channels in search and stop the stuck spinner (#8764)
## Summary Mobile search could not find an open channel you had not joined. In the Channels tab it could also show an empty list with a spinner that never stopped. This change fixes both and makes mobile rank channel results the way desktop (buzz-app) does. **Open channels you have not joined.** Mobile ranked channels from the loaded channel list, which holds open channels only after Browse channels has loaded the directory. Desktop search also looks up public channels (`usePublicChannelSearch`). Mobile now loads the open-channel directory when a search starts (`ChannelsNotifier.ensureDirectoryLoaded(maxAge:)`). It reads the directory again when it is older than 30 s, as buzz-app's `PUBLIC_CHANNEL_PAGE_TTL` does. Results are ranked again whenever the channel list or the directory status changes. Unjoined channels already render with the "Open" badge. **Directory failure.** If the directory load fails, search shows "Open channel search couldn't finish." with a Retry channels button, as buzz-app does, instead of "No results". Screen readers announce the notice (live region). The next query also retries. **Stuck spinner.** The message, people and channel lookups shared one `isLoading` flag. When no messages and no channels matched and the people lookup finished last, it set `isLoading = messages.isEmpty && channels.isEmpty`. That turned the spinner back on, and nothing turned it off again. Each relay lookup now has its own pending flag. The spinner shows while any lookup is still running, or while a directory load is running: one this search started, until its future completes, or one another caller started in this community. The shared directory status alone is not enough, because a real refresh marks it loading only after memberships arrive, and loaded before the new list is installed. Lookups are fenced by a query generation, so a late reply to an earlier query, even one with the same text, cannot settle the current search. **Displaced directory loads.** A membership refresh that starts while the directory loads retires that load. It used to mark the directory as failed, so search showed "Open channel search couldn't finish." when nothing had failed. It now marks the load idle. A refresh that starts earlier, during the membership round trips, retires the load before it is marked loading and leaves no status at all. In both cases search loads the directory again once. If that load is retired too, search offers Retry instead of looping. A real relay error still shows Retry at once and is not retried automatically. When a directory refresh starts, it clears an earlier failure for the same community to idle, so a failure left from before (for example before a reconnect) cannot be read as the new attempt's result and skip its retry. The notice follows the live directory status, so it clears when another caller loads the directory. **Directory races.** A community switch drops the cached directory, but the directory status still said the earlier community was loaded, so switching away and back within 30 s skipped the reload. `ensureDirectoryLoaded` now also requires the cached directory to come from the current community. A search during the first channel load used to start a directory refresh that retired the pending load; if that load's reply came last, its stale failure replaced the list. `retryDirectory` now waits for a pending first load, as `fetchFresh` does. Both came from the Codex security review of this branch. **Only the current community's list.** While a community switch reloads the channel list, Riverpod keeps the previous community's list as the loading value. Search now ranks only installed data (`asData`), so the old community's channels cannot match. (The signing identity derives from the community config, so the existing config watch already resets search on an identity change.) The idle marking and the `asData` read come from Wes's https://github.com/block/buzz/pull/8869, which fixed the same bug in parallel. **Same ranking as desktop.** `name_match.dart` ports buzz-app's `matchName` (`src/features/search/match.ts`). Its ranks, best first: - exact - prefix - word start - substring - word runs (`tlp` finds `team-launch-planning`; `buzz community` finds `buzz-community`) - letters in order `SearchNotifier` orders results like buzz-app's `SearchResults.tsx`: - Joined and open channels share one ranking, and joined channels win ties. Open channels with the same rank are ordered by name, then id, like buzz-app's `searchPublic`. - An archived channel ranks half a step behind a live one and stays findable only by its members. - A bare `#` lists joined channels only. Like desktop, it does not load the open-channel directory, so it shows no directory spinner and no failure notice. - The list shows the top 8. A filter tab with none of its own results now shows "No results" instead of a blank list. Plain substring matching on descriptions is removed; buzz-app does not match descriptions. **Differences from desktop:** - Desktop also orders by the viewer's past search choices. Mobile has no such history yet. - Desktop shows Solo chats and DMs in their own groups. Mobile keeps Solo channels in its Channels list. - `from:`, `in:`, `since:` and `until:` operators and desktop's people ranking are not ported. Mobile still sends the raw query to the relay. These can be a separate PR. - Lowercasing differs for a few letters: JS lowercases `İ` to `i` plus U+0307 and Dart does not, so `İstanbul` / `istanbul` ranks exact on mobile and word runs on desktop. - Desktop ranks channels at each keystroke. Mobile still waits for the 300 ms debounce, so channel rows clear while typing. - Open channels that tie in rank are ordered with Dart's case-sensitive `compareTo`; buzz-app uses `localeCompare`. Only names with capital letters can order differently. - A directory refresh runs the full channel-list reload (`retryDirectory`), not only the directory read. With the 30 s TTL this happens at most once per 30 s of searching. ### Related issue Reported in Buzz #buzz-bugs: a search for `4things` on mobile found nothing. No GitHub issue. No duplicate open PRs found. ### Testing - `search_channels_journey_test.dart` drives the real `SearchNotifier` through `SearchPage` with held relay lookups. It covers: - an unjoined open channel that appears when the directory arrives, with the "Open" badge, ranked above a joined channel with a weaker match - the spinner stopping when the people lookup finishes last - a letters-in-order query - a query with no matches that shows "No results" - a clear and retype of the same text, where late replies do not stop the spinner - the Channels tab showing "No results" when only People matched - a bare `#` whose directory finishes last, then failed directory loads that show the notice instead of "No results", then Retry channels finding the open channel - a community switch during a directory load, where the retired load does not settle the new search - A third widget test drives the real `ensureDirectoryLoaded` → `retryDirectory` path, with the membership query held at the relay boundary. The spinner stays on while that query is held. A failure shows the notice, and another caller's directory load also keeps the spinner on. - The journey fails on main's `search_provider.dart`. It also fails in each of these mutations: - the old loading rule restored - the generation fence replaced by the old query-text check - membership sorted before match rank - `name_match_test.dart` checks 20 name/query pairs. Their expected ranks come from running buzz-app's `matchRank` on the same inputs. - `search_channel_rank_test.dart` is a table test of `rankSearchChannels`: joined-wins ties, open ties by name, archived ordering and membership, private and DM filtering, `#` handling, and the 8-row cutoff. - `channels_provider_test.dart` checks that `ensureDirectoryLoaded` reads again only once the directory is older than `maxAge`. - 17 mutations of the review fixes each fail a test: the archived filter, the archived penalty, the 8-row limit, joined ties, open ties, `#` handling (×2), the failure flag, the failure flag while loading, search-owned pending, the load fence, the rebuild reset, the scoped status spinner, the search TTL, the TTL check, filter-aware "No results", and Retry. - Displaced loads (`efc17a5d4`, `22e52d687`): the journey retires a load before it is marked loading, then displaces the retry after (one retry, then Retry channels with no "No results"). It also checks that the notice clears when another caller loads the directory, and that it is not shown during the first debounce. A displaced Retry recovers. A community switch whose held list reload must match neither the old community's `general` channel nor its name on a message hit. The production-path test holds the real refresh's membership query, runs `refresh()`, and asserts the one retry. `channels_provider_test.dart` expects a displaced load to end idle. Each of 8 mutations fails a test: no retry, retry only on idle, retry on a relay error, notice only on error, no attempted flag, `.value` in ranking, `.value` in message names, and `markError` on displacement. - Review fixes (`29c74e8c5`, `fe4276538`): both displaced-load journeys now start from an earlier same-scope failure (asserted) instead of an artificial idle reset. The notice's live region is asserted. A bare `#` after a failure, and while another caller loads the directory, makes no directory request, shows no spinner and no notice. Each of 5 mutations fails a test: no error reset in `beginRefresh`, no live region, a directory load for `#`, the notice for `#`, and the spinner for `#`. - Directory races (`c73b028ac`): `channels_provider_test.dart` switches A→B→A and expects a new directory read, and starts a directory load while the first load's member metadata is held, releases it last, and expects the list with the open channel and no error. Both tests fail on `fe4276538`, and each fails again when its fix is removed. - Review hardening (`85042b1a4`): `retryDirectory` clears a same-scope error before its first await. A test reads `idle` synchronously after the call; it failed before the fix. - `just mobile-check`, `just mobile-test` and `just file-size-check` pass at `85042b1a4` on `origin/main` `b80217de3` (4052 tests, 32 skipped). - Carl's re-review of `c73b028ac` (via Wes's account): no blockers. Its optional hardening note is done in `85042b1a4`. The 8-row cap is still Wes's product call. - Wes's review (Carl) of `22e52d687`: the blocker (a stale same-scope error skipped the retry) is fixed in `29c74e8c5`. The optional `#` and live-region items are done. The 8-row cap is left for the PR author's product call. The in-app run below is from `3b5521c51`. - Agent review of `efc17a5d4`: Cerveau found no must-fix or should-fix. Auto found one should-fix (a load retired before `markLoading` got no retry and no notice), fixed in `22e52d687` with its optional notice-liveness and A→B→A items. Cerveau's optional coverage items are also done there. - Not covered by a test yet (follow-up): the empty state on the Messages and People tabs when only channels matched. - `just mobile-check` and `just mobile-test` pass at `3b5521c51` on `origin/main` `0c82435ae` (4008 tests, 28 skipped). - Agent review: Auto and Cerveau reviewed `b8a268e2c` independently. Every must-fix and should-fix is fixed in `28a0990e5`. Cerveau confirmed both of its must-fix items at that head. It then found one regression in my fix, a spinner gap during the real refresh, and that is fixed in `3b5521c51`. Cerveau re-reviewed `28a0990e5..3b5521c51`, found nothing new, and confirmed that all three of its blockers are closed. Its own full mobile suite run at `3b5521c51`: 4008 passed, 28 skipped. Auto re-checked `b8a268e2c..3b5521c51` and confirmed that all four of its should-fix items are closed and nothing new is must-fix. Auto's optional findings on `İ`, per-keystroke ranking and reload cost are listed under differences above. #### In the app (Android emulator, debug build of `3b5521c51`) No Xcode was available, so this ran on the Android emulator against a local relay. The viewer has joined `general` and `team-4things`. Another user owns the open channels `4things` and `retro2108`. https://github.com/user-attachments/assets/f54700c6-c34c-4ded-b7c7-c7e6e07aa43b 1. Channels tab, `4things`: the spinner shows while the open-channel list loads, then `4things` appears with the Open badge, above the joined `team-4things`, and the spinner stops. 2. `#`: the joined channels are listed, and the spinner stops. 3. Channels tab, `morty` (a person, no channel name): "No results for 'morty'". The All tab shows Morty under People. 4. The phone is cut off from the relay, and `retro2108` is created meanwhile. Searching for `retro2108` more than 30 s after the last load shows "Open channel search couldn't finish." with Retry channels. After the relay is reachable again, Retry channels finds `retro2108` with the Open badge. The local `ws://` relay needed two demo-only edits to the add-community checks, which are not part of this PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Wes <wesbillman@users.noreply.github.com> |
||
|
|
db4478e8d2 |
feat(mobile): organization sign-in for NIP-FI relays (#8700)
Block is turning on NIP-FI enforcement on the Block relay. Desktop gets organization sign-in in block/buzz-app#1424. Mobile can already attach NIP-FI assertions, but only for Builderlab-hosted communities, through the build's single Builderlab account. The dogfood build needs that account for public hosted communities, so it can't simply be pointed at the Block tenant. This adds a separate **organization realm**, configured at build time: - `BUZZ_ORGANIZATION_URL`: the tenant's HTTPS origin (for example `https://block.builderlab.xyz`). - `BUZZ_ORGANIZATION_RELAYS`: the exact relay hosts that trust that tenant. The two must be set together. The app refuses to start if they're malformed, overlap a hosted community, or reuse the hosted origin. Without them, nothing changes. For a configured relay: - The app keeps its own organization session and signs in through the browser at the tenant's existing login (`/api/goose/v1/auth/login`). Block SSO happens there. - It requests assertions from the same `/v1/buzz/identity/assertions` route that hosted communities use, and attaches them to the relay socket, HTTP, media, huddle and remote-agent requests. - Unlike the hosted path, it doesn't require an identity backup before the first binding. As on desktop, the tenant binds the member's existing key on first issuance. A key already bound to a different one is refused before any request is sent. - When signed out, it connects without an assertion and lets the relay decide. A bottom banner offers **Sign in**. After sign-in, the relay reconnects with a fresh assertion. Hosted communities keep their existing banner and flow. The tenant accepts only the registered `xyz.block.buzz.mobile:/auth/callback` return URL, so both realms share it. Each `BrowserAuth` owner leaves callbacks whose `state` belongs to another owner's attempt to that owner. ### Before merge - [ ] Kenny Lopez reviews the visible changes. ### Validation Added tests (`organization_relay_auth_test.dart`): - Configuration parsing and exact host matching. - An assertion is issued for the exact relay and key, then cached. - A different bound key is refused. - Signed out connects without an assertion. - Other relays never receive the assertion. - The shared callback is routed between the hosted and organization owners. Full `mobile-check` and `mobile-test` pass. RunnerTests pass locally except `testNativeNavigationTitleExpandsAgainAtTop`, which this PR doesn't touch. Added tests (`organization_sign_in_test.dart`, `relay_auth_isolation_test.dart`, `builderlab_account_test.dart`), each failing without its fix: - Concurrent account restores at cold start share one adopted account. - An exchange from a cancelled attempt never changes a newer sign-in. - Storage failures while clearing the intent or cancelling leave the busy state. - An unreadable saved organization session is replaced by signing in again. - A stalled organization issuer never delays a hosted relay's assertion. This test needs `BUZZ_AUTH_DOMAINS` and skips without it. CI's configured-auth-domains step doesn't list it yet, because that workflow change needs a token with `workflow` scope. Added tests also cover a restore after an offline start (the banner clears once a relay retry restores the session) and the iOS bridge allowlist (`/auth/native` and tenant login on Builderlab subdomains; other hosts, paths, ports, userinfo and fragments refused). Simulator (iPhone 17, iOS 27) with the dogfood defines against the production Block relay `buzz.block.builderlab.xyz`, on a WARP Mac: - The app starts with the organization configuration and connects to the relay while signed out, showing the organization **Sign in** banner. - **Sign in** opens the system sign-in session for `block.builderlab.xyz`, and the banner shows **Finishing organization sign-in…**. Cancelling returns to **Sign in** with no error. Before the bridge fix, no browser opened and the banner reported that sign-in did not finish. - The bottom banners (organization sign-in, hosted reconnect, and the sign-in recovery row) now sit above Home's floating Search/New bar, which used to cover **Sign in**. A journey test checks the banner clears the bar, and fails without the fix.  - The simulator run stopped at the system consent prompt (SSO does not work in the simulator). The app calls were driven through the VM service (the same `signIn()`/`cancel()` the banner buttons call) because the simulator could not be tapped. Device (iPhone 12 mini) against bb-block staging in enforce mode, where every probe without an assertion gets 401: - Signed out, the organization banner shows and the app stays responsive. Cancel at the consent prompt returns to **Sign in**, and retry works. - Block SSO through Okta completes, and the relay admits the reconnect with the assertion. - After relaunch the app connects, loads channel history, and shows no banner for over 60 seconds. - Before the restore fix, 2 of 4 cold starts showed "cannot use this community with this key" although the session was valid. With it, 8 of 8 cold starts connected. Known limitation: adding an enforcing organization relay by community link is tracked in #8791. The dogfood build enables this in squareup/buzz-releases (`tomb/dogfood-organization-sign-in`). App Store builds stay unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
acf56fff6f |
fix(mobile): use the desktop agent squircle for every agent avatar (#8873)
On mobile, the Create agent form previewed emoji and image avatars as circles. Once the agent was created, its avatar showed as a rounded square, and other surfaces used yet other radii. None of them matched the agent squircle desktop uses. This PR makes `lib/shared/widgets/avatar_shape.dart` the single source of avatar silhouettes. Agents get desktop's squircle (traced from buzz-app's `avatar-squircle.svg`), and people keep circles. `avatarShape(isAgent:)`, `avatarPath()` and `AvatarClip` are the only ways to get either shape, and the squircle border class is private. Every agent avatar surface now goes through that bottleneck: - Create agent form and avatar picker previews (previously circles for emoji and photo avatars, and unclipped for catalog characters; `PresetAgentAvatar` now owns the clip) - The avatar picker's camera cutout (`ImageAvatarCapture` with `isAgent: true`); profile editing keeps the circle - Agents grid card portrait (previously a 22pt rounded rect) - `AvatarImage` (message authors, DM rows, member lists), the profile sheet, `SmallAvatar`, and the native avatar PNG used by iOS Add members (previously 30% rounded rects) - Huddle speaking halo, preparing placeholder, and spotlight frame - The iOS Add members fallback tile (Swift), shown while an avatar loads, now draws the same squircle instead of a 30% rounded rect To catch future drift, `avatar_shape_test.dart` adds a source guard. It fails when code under `lib/` picks an avatar shape inline from agent identity (for example `isAgent ? BoxShape.rectangle` or `if (!isAgent) return ClipOval`), hand-rolls the old 30%/60% agent radius, or uses a person shape (`ClipOval`, `CircleAvatar`, `BoxShape.circle`) under `lib/features/agents/`. Against main it flags 8 of the 10 old sites. It is best effort: the native PNG's inline ternary and the card's RRect didn't match those patterns and are fixed directly here. The Create agent form also gets 8pt above **Choose avatar**, so the button and everything below it move down from the preview, as Kenny requested. ### Before / after Captured on the iPhone SE (iOS 27) with a release build against a hosted Builderlab community, using a throwaway agent with an emoji avatar. In each pair, before (main) is on the left and after is on the right. **Create agent form** (also shows the 8pt spacing)  **Avatar picker**  **Agents card**  **Agent profile sheet**  **Message author avatar** (`AvatarImage`)  **DM list row** (`AvatarImage`)  **Add members** (native iOS list, `nativeAvatarImage`)  Not captured on device: `SmallAvatar` (thread reply and search avatars) and the Huddle halo, placeholder, and spotlight, which need a live agent thread or huddle. Widget tests cover these. ### Validation - Device: the before/after captures above. They predate the catalog preview clip ([ |
||
|
|
f59b326568 |
Fix mobile onboarding policy loading, browser cancellation and pairing feedback (#8874)
Mobile onboarding could reject valid large legal documents, stay busy after closing Android browser sign-in, and hide pairing-import errors behind the community-link form. This change bounds join-policy responses at 4 MiB while retaining 64 KiB for other responses, handles Android browser cancellation through the native lifecycle/channel, keeps Cancel sign-in usable, and shows friendly pairing errors on the landing screen with a live-region announcement. Identity mismatch checks remain intact. The browser-cancellation implementation incorporates the candidate from #8847 (`cb241b3f1`); coordinate landing to avoid duplicate work. This PR consolidates the three fixes exercised together during Android onboarding acceptance. ## Validation - Pixel 9 / Android 17, production hosted services, isolated debug installs. Human acceptance plus native UI, saved public-identity and restart checks as noted below. No iOS acceptance in this session. - Latest build and invite-first acceptance: base `a12ad1cdd29da8b2923eca5408e963a2659777ab` plus these patches. - Focused tests on that base plus patches: **88 passed** across hosted_journeys_test, hosted_admission_test, browser_auth_test, pairing_hosted_handoff_test and pairing_provider_test, with `BUZZ_AUTH_DOMAINS=communities.buzz.xyz`. - Earlier baseline: 81 focused cases passed plus one staging-configuration case passed separately (82 distinct cases across seven onboarding/recovery files); these are historical, not current-head certification. - Earlier policy change: 41 focused tests, changed-file analysis and isolated mutation check passed on the earlier base. Earlier browser candidate native instrumentation/mutation evidence was not rerun on this combined revision. - Android debug APK built and installed successfully. Mandatory commit/push hooks passed at `6442e5ff9`: analysis clean; mobile tests 4,047 passed / 34 skipped; additional package runs 4 and 27 passed; file-size checks passed. These broad tests ran through the required push hook, not a manually invoked suite. CI remains pending. ## Live testing report — October 31 | Journey | Result and evidence boundary | |---|---| | Fresh email signup / identity / hosted invite | Reproduced policy response exceeding 64 KiB; after fix, admission and restart passed. Profile/message visible locally; cross-account message receipt remains unproven. Tested at `cfcaef4e9f` plus policy patch. | | Browser close → retry → login → existing identity recovery → restart | Passed at `d14f33a170` plus patches; recovered the expected public identity and community. | | Pairing a different identity into an existing install | Rejected without replacing the original key. Initially hidden error became visible with friendly copy; Back and restart preserved the community at `b80217de3` plus patches. | | Fresh pairing → wait for desktop approval → sign-in → community → restart | Passed at `20742a236` plus patches; human confirmed approval gate, saved public identity matched. | | First-time desktop cancellation → welcome → fresh QR retry → community → restart | Passed at `20742a236` plus patches. Cancellation error remained raw `user_denied`. | | Android system Back while awaiting pairing approval | Human-confirmed in already-onboarded Add Community; first-time phone-side Back variation remains untested. | | Wrong pairing code → rejection → correction → completion | Human-confirmed at `a2540fbe3` plus patches. Exact error and same-session versus fresh-QR retry were not independently captured. | | Interrupt account onboarding at community picker → process restart → recovery → admission | Passed at `a2540fbe3` plus patches. Restart opened Add a community; Find my communities restored choices without another login. Human completed admission. No destination had yet been selected. | | Invite-first into fresh signed-out app → intended community → restart | Passed at `a12ad1cdd` plus patches; saved identity/destination matched. Existing member and existing browser session. Initial Android VIEW intent explicitly targeted the test package; ordinary HTTPS default app-link resolution and new-member admission remain untested. | | Malformed community input → error → Back → restart | Passed for `not a community`; original community preserved. `not-a-community` is a valid single-label hostname and was not counted as malformed-input evidence. | ## Remaining gaps and findings Not release certification. Pairing landing-error regression/mutation evidence is now recorded below. Current combined-revision native instrumentation has not been rerun; its limited scope is described below. Deferred behavior, not implemented here: - Wrong browser account after fresh pairing is correctly rejected, but Retry reuses that app account session; in-app account switching must preserve the transferred identity and destination. The live happy path used a manual browser-account switch and fresh install. - Pairing wait has no visible Cancel; tested system Back works. - Desktop cancellation exposes raw error wording. - Interrupted community selection requires an extra Find my communities step. Master checklist remains partial: seven categories exercised this session, signup/community creation has historical coverage, seven categories unexercised. New-member link-first admission, deeper interruption/server-commit recovery, expiry/revocation/network failures, key import, organization/self-hosted paths, phone-to-desktop transfer, eligibility gates, accessibility/app-link variants and iOS require separate coverage. Channel-discovery work is separate (#8869). ## Review checkpoint [Carl’s review](https://github.com/block/buzz/pull/8874#pullrequestreview-5724095754) found no code blockers at `6442e5ff9`. Carl also reviewed test-only follow-up `d43958653` and found no issues, accepting both validation gaps as closed. A subsequent review identified stale callback → resume cancelling a newer Android login. A narrow native fix and regression validation are in progress; prior readiness attestation is withdrawn pending this behavior change. - **Android stack check passed (Pixel 9, Android 17):** after fresh account sign-in, the human stopped at the community picker without closing/restarting. `dumpsys activity activities` showed exactly one MainActivity in Invite First task 567, with no CustomTab in that task or the full component list. ChromeTabbedActivity remained in a separate pre-existing browser task. This rules out a lingering duplicate activity in this run; it does not independently trace `onNewIntent` or establish all Android/browser variants. Binary production sources match `6442e5ff9`. - **Pairing regression/mutation passed:** extended the existing QR journey using the real encrypted NIP-AB import and hosted coordinator. Asserts visible friendly mismatch feedback, live-region wrapper, unchanged stored community/account identity, and Back to home. Reused the existing socket fake. Hosted journey + pairing provider files: 70 passed with configured auth domain; opt-out hosted journey file: 3 passed / 21 skipped; changed-file analysis clean. Isolated clone: control passed, hiding the landing error failed the visible text assertion, restoration passed. No production source change in this follow-up. - **Duplicate landing:** #8847 is superseded by this combined PR; its Android slice is included here. No merge performed. BrowserAuthPluginTest manually drives plugin hooks using an unmanaged Activity and fake browser launcher. It proves the plugin state machine only, not MainActivity wiring, real lifecycle order, process death, or duplicate app_links callback delivery. Current CI does not run Android instrumentation. Android ignores the debug ephemeral-login flag and shares browser cookies. Readiness checklist: agent review completed on production changes and test follow-up; agent exercised the changed behavior; human completed the documented Android journeys and final sign-in check. ## Stale callback follow-up (`b7aaaa788`) Addresses [the later Android lifecycle review](https://github.com/block/buzz/pull/8874#pullrequestreview-5724197312): a recognized callback now clears browser-dismissal tracking before rejecting a mismatched state. A stale callback cannot complete or cancel the newer login. The existing native journey now includes the intervening resume, successful current callback, and genuine browser dismissal after a stale callback. Only two native files changed; policy and pairing fixes are unchanged. Validation: 88 focused Dart tests passed. Android app and test APK build passed. On Pixel 9, the native plugin journey passed; in an isolated source/Git/build clone, removing the reset failed at the newer attempt's expected-zero-replies assertion; exact restoration passed. This proves the plugin state machine on Android, not a real late browser callback through OS lifecycle delivery. Carl reviewed `b7aaaa788` and found the fix correct and minimal with no blockers. Human browser-close/retry check remains pending; PR stays draft until that passes. Current-head CI is also required for merge. Accepted limitation: if a recognized stale callback arrives after the current browser tab has already gone, automatic dismissal is suppressed and the user may need the visible Cancel button. The callback cannot complete sign-in without matching state; Dart validation remains authoritative. --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> |
||
|
|
a12ad1cdd2 |
feat(mobile): refine agent profiles and model selection (#8773)
## Summary - Simplify agent profiles with Message/Bestie actions, relay-authored published instructions (unavailable without a config channel), management details, public-key copying, and a separate final Delete section. - Sort the Agents directory by name and mark the selected Bestie with a muted filled star. - Place Model and Thinking together below Name. Use native iOS Liquid Glass menus and preserve selection callbacks as controls become enabled. Related: #8685. ## Validation Full mobile checks passed. Regression coverage includes relay-author filtering, unavailable instructions, metadata retry, resizing native pickers, accurate deletion state, selection after loading, and Bestie switching. Real iOS simulator taps verified both pickers; the user confirmed the earlier iPhone build works. Review fixes were checked with widget journeys and the native UIKit resize/selection probe. <details> <summary>Previews</summary> Synthetic fixture renders: | Profile | Create agent | Bestie card | | --- | --- | --- | |  |  |  | Native iOS simulator interaction with a synthetic catalog:  </details> --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
a2540fbe3d |
Align Android profile setup with iOS steps (#8871)
Android profile setup now matches iOS: choose a display name, then an optional avatar using the existing dotted-circle placeholder and Image / Emoji / Animated editor. Back preserves drafts; Skip saves the name without an avatar, and failed saves retain retry/exit options. Validated on Pixel 10 and approved by Kenny; analysis clean, 4,064 mobile tests passed (8 skipped). Device preview uses no-write callbacks; preview-only files are excluded. | Display name | Avatar | | --- | --- | |  |  | buzz-review-completed Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
20742a236d |
fix(mobile): only render known channels as #channel chips (#8862)
Follows [#8860](https://github.com/block/buzz/pull/8860) (merged). On mobile, any `#token` rendered as a channel chip, so GitHub-style references like `#8700` turned into inert chips for channels that don't exist. Desktop already avoids this by only linking names in the known channel list. This makes mobile's message body and one-line preview do the same. With no known names (channels still loading, or none visible), nothing becomes a chip. An open channel the reader hasn't joined stays plain text until the directory has loaded; before this change it was an inert, untappable chip. messages rebuild with chips once channel names arrive. Unlike desktop, there's no "any `#token` while empty" fallback, because an empty map can't distinguish loading from an account with no visible channels. The known names come from the existing `channelIdsByNameProvider` (the reader's channels, plus open directory channels once Browse channels or invite recovery has loaded the directory), so there is no new fetch, subscription or sync change. When the list is non-empty, the regex has one alternative fewer than before. ### Validation Added tests: - Preview: `#8700`, `#8774's`, a known-name prefix (`#gen` vs `general`) and `#eng-ios` with only `eng` known stay text, while `#general` and a longer known `#eng-ios` are chips. With no channel names, nothing chips. - Message body: `Took over #8700 from #general` renders one chip (`general`) and keeps `#8700` as text. - Message body: with no channel names, `#unknown` stays text. The new assertions fail with the previous always-generic pattern. The full mobile test suite passed in pre-push. Simulator render on an iPhone 18 Pro (iOS 27): real message-body and preview widgets with channels `general` and `eng` loaded, rendered with both stacked PRs applied. | Before (`main`) | After ([#8860](https://github.com/block/buzz/pull/8860) + [#8862](https://github.com/block/buzz/pull/8862)) | | --- | --- | | <img src="https://raw.githubusercontent.com/block/buzz/5afead6a162d5c8554ad17175fc2dccff93bf2af/pr-8862--01-before-main.png" width="300"> | <img src="https://raw.githubusercontent.com/block/buzz/5afead6a162d5c8554ad17175fc2dccff93bf2af/pr-8862--02-after-both-prs.png" width="300"> | Human app test: not applicable under the mobile-client testing exception; agent review and simulator validation above. 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> |
||
|
|
e3b1ef0d84 |
docs(agents): mark desktop/ as legacy, not a reference for mobile (#8868)
Agents working on the mobile app have been looking at `desktop/` for implementation and design cues. `desktop/` will soon be superseded by [block/buzz-app](https://github.com/block/buzz-app), while mobile is current, so it should never serve as that reference. - Root `AGENTS.md`: labels `desktop/` as legacy in the repo layout, adds a callout at the top of the Desktop App section, adds the same guidance to the Mobile App section, and drops "matches desktop" from the mobile theme note. Mobile kind constants now sync with `crates/buzz-core/src/kind.rs` (also in the `nostr_models.dart` comment) instead of desktop. - `VISION_MOBILE.md`: replaces the line that allowed the desktop implementation to inform shared mobile rules, so the vision and agent guidance agree. Written intent still takes priority. ### Validation Documentation-only. Reviewed the diff and links. Codex review's one finding (the vision conflict) is addressed. buzz-review-completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
b80217de32 |
fix(mobile): end #channel tokens at apostrophes, quotes and dashes (#8860)
Mobile renders `#channel` tokens as chips, but a token followed by an apostrophe, quote, ellipsis or dash stayed plain text: `#general's`, `"#general"`, `#general…` and `#general—` all lost their chip because the token only ended at whitespace or `, ; . ! ? : ) ] }`. This ends channel tokens at those characters too (`' " ‘ ’ “ ” » … – —`). The message body and the one-line preview each had their own copy of the channel regex; they now share one `channelTokenPattern`, so they can't drift apart again. `@mention` boundaries are unchanged on purpose: on mobile and desktop the same boundary also decides which mentions are bound and tagged when sending, so changing it is a separate, cross-surface decision. ### Validation Added tests: - Preview table covering each new trailing character (and matching opening quotes). - Message body: `#general's` keeps the chip and renders the `'s` suffix. Both fail with the previous boundary. The full mobile test suite passed in pre-push. Simulator render on an iPhone 18 Pro (iOS 27): real message-body and preview widgets with channels `general` and `eng` loaded, rendered with both stacked PRs applied. | Before (`main`) | After ([#8860](https://github.com/block/buzz/pull/8860) + [#8862](https://github.com/block/buzz/pull/8862)) | | --- | --- | | <img src="https://raw.githubusercontent.com/block/buzz/5afead6a162d5c8554ad17175fc2dccff93bf2af/pr-8862--01-before-main.png" width="300"> | <img src="https://raw.githubusercontent.com/block/buzz/5afead6a162d5c8554ad17175fc2dccff93bf2af/pr-8862--02-after-both-prs.png" width="300"> | Human app test: not applicable under the mobile-client testing exception; agent review and simulator validation above. 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> |