mirror of
https://github.com/block/buzz.git
synced 2026-11-10 11:21:05 +00:00
58696ff577c96ff40e2cb155cf8595d03262c307
573 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
58696ff577 |
fix(mobile): show the channel name as the notification subtitle (#9173)
On Apple Watch, a Buzz notification showed the community name ("buzz")
under the sender, taking a line of the small display without saying
where the message came from. iPhone never showed it, because iOS draws
the banner from the communication intent and drops `content.subtitle`.
The Watch renders the plain fields.
The subtitle now carries the conversation name, the same one the intent
uses for its group name:
- Team channels and forums: `#channel`
- Your own Solo chats: the bare name, no `#`
- DMs, and channels not yet in the presentation cache: no subtitle
The name logic moves into one helper so the subtitle and the iPhone
group name can't drift apart. A channel that's known but isn't eligible
for the communication style (for example, an unverified roster) now gets
`#channel` as its plain-banner subtitle in place of the community name.
Losing the community name means the Watch no longer tells communities
apart. That seems like the right trade for most people, who are in one
community.
### Validation
- BuzzPushKit `swift test`: all 110 XCTest and 69 Swift Testing cases
pass. Updated the resolver assertions: `#General` for a cached stream,
both with and without communication styling, and no subtitle for DMs and
uncached channels.
- Not yet exercised on a device or Watch.
🤖 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>
|
||
|
|
e1e1cd50ab |
fix(mobile): keep Home's header clean under sheets (#9152)
On iOS, opening a sheet over Home (for example a channel's long-press actions) left a hard horizontal edge and a light glow band under the filter chips. Home's header keeps a native UIKit soft-edge view behind its Flutter controls, and the sheet's dim barrier doesn't composite cleanly over it. While another route covers Home, the header now switches to its Flutter backdrop through the existing `FrostedAppBar.nativeViewSuppressed` hook, the same mechanism other headers use while a backdrop covers them. Home already hides its native bottom controls under the same condition. | Before | After | | --- | --- | | <img alt="before" src="https://github.com/user-attachments/assets/a5bf01da-4f88-4055-809d-e347b35408a9" width="300"> | <img alt="after" src="https://github.com/user-attachments/assets/6813bb27-9f76-45d5-81c3-b46d8e0df647" width="300"> | ### Validation - iPhone 17 Pro simulator (iOS 27), Builderlab test community: reproduced the artifact on `main` with Home scrolled and the channel actions sheet open, then confirmed it is gone with this change. Also opened and dismissed the sheet repeatedly and checked the header before, during and after. - Transition timing, frame-stepped from simulator recordings of `main` (top row of each strip) and this PR (bottom row) on the same Home state: - The header and the bottom navigation controls switch on the same frame. A modal sheet doesn't drive Home's secondary animation (`MaterialPageRoute.canTransitionTo` is false for a sheet route), so the bottom controls' `secondaryAnimation == 0` check reduces to the same `isCurrent` gate the header uses. - Known limitation: while the native header view is swapped out, the header blanks for about 2 display frames as the sheet opens and about 3 as it closes (about 30 to 50 ms, seen as the missing chips below). `main` has no blank frames but shows the hard edge. Opening: <img alt="sheet open, main above PR" src="https://github.com/user-attachments/assets/df8f04f2-da72-4889-b9c8-5c47596fe7e3" width="900"> Closing: <img alt="sheet close, main above PR" src="https://github.com/user-attachments/assets/aa234a82-773d-4c9f-bba4-469afd158de8" width="900"> - Extended the iOS Home widget journey: the native header view is removed while the channel actions sheet is open and restored after it is dismissed. Without the fix, the journey fails. - Full mobile Flutter suite passes. 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> |
||
|
|
ac2c5831f3 |
fix(mobile): show already-loaded custom emoji in the iOS reactions sheet (#9150)
On iOS, the reactions sheet (long-press a reaction pill) could show a custom emoji as truncated `:shortcode:` text, for example `:eye…`, even though the same emoji rendered as an image on the message pill a moment before. The sheet is a native UIKit view that downloaded each custom emoji again with its own `URLSession` request and showed the shortcode label until that download finished, and for good when it failed. A shortcode is illegible in a chip that size. Now: - Flutter keeps the encoded bytes of custom emoji it has downloaded (a bounded LRU: 512 KiB per emoji, 8 MiB total, rebuilt when the media auth scope changes so accounts never share bytes) and hands them to the sheet, which decodes them without a fetch. Emoji the timeline never loaded still download natively as before. - A custom emoji in the sheet never shows its shortcode: it stays blank while loading and shows the sparkles icon, matching the native emoji picker, if the image cannot load. Reaction pills in Flutter get the same sparkles fallback. Inline emoji in message text keep the shortcode fallback, where it reads as text. Avatars in the sheet keep their initial. ### Before / after Reactions sheet header and chips right after opening, on a message with two custom-emoji reactions (synthetic test channel, captured from a 30 fps screen recording). | Before (main) | After (this branch) | | --- | --- | | <img width="300" alt="Before: custom emoji chips show truncated :qa-... shortcodes" src="https://github.com/user-attachments/assets/593e555b-9c82-4eec-9049-3d909f987c76"> | <img width="300" alt="After: custom emoji chips show images" src="https://github.com/user-attachments/assets/a6739997-b39f-4062-9e5c-d22a8a38b368"> | ### Validation - Added tests: the native sheet's payload carries the bytes the pill already downloaded; the combined reaction tray shows sparkles, not the shortcode, when a custom emoji fails to load (the existing alignment test previously asserted the shortcode). - Full mobile suite and `just mobile-check` pass; the iOS simulator build compiles. - Automated simulator run (iPhone 17 Pro, iOS 27, main vs this branch, signed in to a test community): channel list, opening a channel, the message action sheet, the emoji picker, and the reactions sheet on a message carrying two custom emoji I added plus a thumbs-up. On main the sheet's custom-emoji chips show truncated `:qa-...` shortcodes for a few frames before the images arrive; on this branch they render as images from the first frame (see Before / after). Limitations: the emoji images load fine over the network in this setup, so the failed-download sparkles fallback was covered by tests only, not seen in the app; a long press on the combined reaction tray opened the message menu in the simulator, a tap opened the sheet. 🤖 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> |
||
|
|
40cfa72cd4 |
mobile: add follow and remind actions to inbox row menu (#9142)
Long-pressing an Inbox row opened a sheet with only **Mark as
read/unread** and **Open conversation**. To follow a thread or set a
reminder, you had to open the conversation and long-press the message
itself.
The row sheet now also offers:
- **Follow thread / Unfollow thread**: acts on the conversation's thread
root, or on the message itself for a top-level row (the same rule the
message actions sheet uses). It's hidden for Solo chats, which have no
threads, and for grouped DM conversations, which have no single thread
to follow.
- **Remind me**: opens the existing "Remind me later" sheet for the
row's latest message and publishes the same kind-30300 reminder. If that
message already has a pending reminder, the new time reschedules it: the
event reuses its `d` tag (an addressable replace) and keeps its note, so
you don't get a second reminder. It loads the reminder list first,
because otherwise the list loads only with the Reminders filter. It's
hidden when signed out, like the message sheet.
The sheet is now scrollable so the four rows fit on short screens and at
large text sizes (without it, the new test overflowed at the default
test surface).
### Demo
Long-press a channel mention row in the Inbox, follow the thread, reopen
the sheet (now **Unfollow thread**), then set an "In 1 hour" reminder.
The rows are agent test chatter from a pilot community.
https://github.com/user-attachments/assets/ad94e5f2-1048-4585-9663-145e814892e3
### Validation
- Added tests: one Activity page journey that follows and unfollows a
thread row, follows a top-level row, then sets a reminder through the
sheet and checks the published target (event, channel, preview). Further
tests cover rescheduling an existing pending reminder (same `d` tag,
note kept, "Reminder updated"), hiding Follow in a Solo chat, and hiding
Remind me when signed out. The `test/features/activity`,
`test/shared/reminders` and message-actions suites pass.
- Re-run on the branch head (
|
||
|
|
ad5699d05a |
fix(mobile): start no threads in Solo chats (#9123)
Agents in a Solo chat always answer at the chat's top level. On mobile you can still start a thread in Solo (Reply action, swipe-to-reply, media reply) or reply inside an existing one, so the agent's answer lands outside the thread you wrote in. Desktop already avoids this: Solo shows existing threads but starts none and has no thread composer. This matches desktop on mobile: - No Reply quick action, swipe-to-reply or media reply in Solo chats. - Existing Solo threads still open from their reply summary, read-only (no composer). - Every other channel is unchanged. ### Demo Synthetic Solo chat "QA demo", iPhone 17 Pro simulator. Each clip: right swipe on an agent message, then long-press for the actions. Before (main): the swipe shows the reply circle and opens a thread with a composer; Reply is in the actions. https://github.com/user-attachments/assets/cc5025a3-b7ba-4ab2-bebb-06c7b54e7906 After (this branch): the swipe does nothing; the actions have no Reply. https://github.com/user-attachments/assets/b31f9983-8c52-45c2-ae88-1661aa71210e ### Validation Added tests: a Solo chat's message neither swipes to reply nor offers Reply in its actions, and a Solo thread opens without a composer. Existing swipe-reply and Solo channel tests pass, along with the full mobile suite and analyzer. Simulator run on an iPhone 17 Pro, signed in to a real community: in an existing Solo chat, a right swipe on an agent message no longer moves or opens a thread, and the long-press actions show no Reply (Copy link, Remind me and the rest are unchanged). Team-channel Reply is covered by the existing swipe-reply tests, not this run. A second simulator run on an iPhone 17 Pro, in a synthetic Solo chat, compared main and this branch: on main the swipe opens a thread with a composer and the actions list Reply; on this branch neither does (see Demo). The Demo's "Follow thread" action still appears in a Solo chat's actions on both builds; this PR does not change it. 🤖 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> |
||
|
|
733d164e80 |
mobile: persist user profiles so names and avatars render on launch (#9116)
On a slow connection, mobile shows npubs and blank avatars on every cold
launch and community switch, because user profiles live only in memory
and are refetched from the relay each time.
This persists the verified profiles the app has already seen, per
identity and relay, so names and avatar URLs render on launch before the
relay answers.
### Behavior
- Profiles are stored as a disposable cache in the same shape as the
existing channel list snapshot: one SharedPreferences entry per identity
and relay, versioned, capped at 2000 profiles and 2 MB (oversized inline
avatars are skipped), deleted on sign-out and by orphan cleanup.
- On launch the cache restores the snapshot synchronously. Each restored
profile is still revalidated against the relay on its first lookup that
session. `get` and `preload` return the restored data right away instead
of waiting on the network, and a failed revalidation retries on the next
lookup.
- Identity-sensitive callers keep their guarantees: `resolve()` and
`refresh()` treat restored profiles as unconfirmed and query the relay,
including while a background revalidation is still in flight.
- Writes are debounced (2 s) and flushed when the community scope
changes.
Not in this PR: avatar image bytes still use only Flutter's in-memory
image cache, so avatar images still download after a cold launch (names
and URLs no longer wait). The NSE keeps its own app-group profile cache,
which this doesn't share yet.
### Demo
Cold relaunch into a synthetic Solo chat ("QA demo"), iPhone 17 Pro
simulator, same clip timing on both builds. To make the gap visible on a
fast connection, both builds had a local-only 5 s delay on profile
fetches (not part of this PR). The channel's system lines show the
member name once its profile is known and a shortened npub until then.
Before (main): "npub1...created this channel" for about 5 s until the
relay answers, and the agent avatar starts as a letter placeholder.
https://github.com/user-attachments/assets/de558521-a210-4ac6-b216-d77260036985
After (this branch): "tomb created this channel" and the agent avatar
render from the first frame.
https://github.com/user-attachments/assets/093b30fc-ec24-4f26-8535-7cc1791d2e44
### Validation
- Added tests: a journey across three simulated launches (relay
reachable, offline, reachable with a rename) covering restore,
non-blocking revalidation, retry after failure, a concurrent `resolve()`
during revalidation, and sign-out removal; plus storage scoping, bounds,
writer retirement and corrupt-data handling. The revalidation and
in-flight guards were each removed once to confirm the journey fails.
- Full mobile check and test suite pass locally.
- iOS simulator (iPhone 17 Pro, Remote Agents Pilot as Tom's Builderlab
account): after browsing, the snapshot held 16 verified profiles (6.4
KB), and cold relaunch rendered normally. Offline cold launch was not
exercised on the simulator.
- iOS simulator, synthetic Solo chat, main vs this branch (see Demo):
with profile fetches artificially delayed 5 s, main showed shortened
npubs in the channel's system lines until the relay answered, and this
branch showed the restored names on the first frame.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
439d7bcc12 |
fix(mobile): keep a sent thread reply above the composer (#9026)
Fixes two thread symptoms that come from the same state: after sending a reply, the composer clears but the new reply sits hidden under the composer (or out of view entirely) until you leave and return, and the **Latest** button stays visible even though the list looks like it is at the bottom. ### Cause 1. A sent reply grows into place over its 500 ms entrance transition (`LocalSendRow`). The thread's one-shot tail correction can finish while the row is still growing. Once it has finished, nothing moves the list as the reply keeps growing, so the list stops short of the real end (about 40 pt on an iPhone 17 Pro simulator) and the reply ends up behind the composer. Because the list isn't actually at its end, **Latest** shows. It is visible even with the composer focused when an earlier drag detached tail-follow. 2. A thread opened at a specific reply (from Inbox, a push notification or a message link) skipped the new-reply follow logic for its whole lifetime, not just for the replies it opened with. If the reader scrolled at all before replying, their own sent reply was never brought into view: it landed below the viewport, and a quick swipe couldn't reach it before the next layout. ### Fix - While the thread follows its tail, a `ScrollMetricsNotification` listener keeps a position that was at its previous end on the new end whenever the extent grows. It only acts when the last reply is laid out, the reader isn't dragging or opted out, and no navigation to the tail is in progress, so programmatic jumps away from the tail are left alone. This also covers other growth at the tail, such as media or typing rows. - A targeted entry now absorbs the replies it hydrates with until its target jump has revealed the list. Hydration can come in two steps (the route's snapshot of the target, then the relay's full thread). Absorbing only the first let the second, when it held the reader's own earlier replies, schedule a tail correction that cancelled the one-shot target jump and left the list hidden behind its loading placeholder. Later arrivals use the ordinary rules: the reader's own reply comes into view, and other people's replies still don't move a reader who is away from the tail. ### Before and after Same flow on an iPhone 17 Pro simulator, in a synthetic QA channel thread: scroll the list away from the bottom, focus the composer, send a long reply that wraps onto many lines. Before (main) the sent reply ends with its bottom edge under the composer; after (this branch) it ends clear above it. Before (main): https://github.com/user-attachments/assets/5101b63d-e2df-46d1-bfd1-49038330ddf5 After (this branch): https://github.com/user-attachments/assets/1df81ad5-bb6b-4df6-b4e3-5c37fd8aab67 ### Validation - Test `a sent reply stays above the composer as it grows in` now runs for a plain entry, a targeted entry (`initialMessageId`), and a targeted entry whose relay hydration is delayed and includes the reader's own replies. The targeted row opens at an older reply, checks it is visible, drags a little, focuses the composer with the keyboard up, sends a multi-line reply, and checks that the reply ends above the composer and **Latest** is hidden. Each row fails without its fix (growth: reply bottom at 504, composer top at 402; targeted: the reply is never followed; delayed hydration: the target never becomes hit-testable because the list stays hidden). - Full `flutter analyze` and mobile test suite pass. - Before/after capture on an iPhone 17 Pro simulator (iOS 27, debug builds of this branch head and of current main), in a synthetic QA channel thread with enough replies to scroll. Flow: scroll away from the bottom, focus the composer, send a long wrapping reply. Over 7 runs on main, the reply's bottom ended 3 to 25 pt under the composer in 5; over 6 runs on this branch, it ended 17 pt above the composer every time. Limitation: debug simulator builds only, and the shortfall on main varies run to run. - Automated full-app run on an iPhone 17 Pro simulator (iOS 27) against the Remote Agents Pilot community. In a test DM thread, I sent short and wrapping replies with no prior scroll, after dragging up and returning, and after the follow-up narrowing. Each reply ended fully above the composer, and **Latest** stayed hidden at the bottom. I also dragged up with the keyboard dismissed and confirmed the list stays at the tail with no stray **Latest**. For the targeted entry, I opened a thread from a message link, scrolled, and sent replies: without the targeted-entry fix they stayed out of view under the keyboard; with it hot-reloaded they were followed (the combined build is covered by the widget test rows above). I then reopened that link right after an app restart, which hydrates in two steps: before the hydration fix the thread showed only its root with the replies hidden, and with it the list revealed at the target and a sent reply came into view. Human testing is waived per the owner's standing exception. Limitation: this run did not cover physical devices or media replies. The simulator recording was taken in an internal community, so it is shared internally rather than embedded here. 🤖 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@localhost> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> |
||
|
|
65e864301d |
feat: send a User-Agent on relay WebSocket upgrades (#9246)
Relay WebSocket clients in this repo now send a `User-Agent` of the form `<name>/<version>` on the upgrade request. The version comes from the build. | Client | User-Agent | |---|---| | `buzz-acp` | `buzz-acp/<crate version>` | | `buzz-cli` | `buzz-cli/<crate version>` | | `buzz-ws-client` (default) | `buzz-ws-client/<crate version>` | | `buzz-pairing-cli` | `buzz-pairing-cli/<crate version>` | | `buzz-audio-client` (default) | `buzz-audio-client/<crate version>` | | mobile (relay and huddle sockets) | `Buzz-Mobile/<app version>` | - **`buzz-ws-client`**: every connect path sends the default. A `User-Agent` in the caller's `connect_with_headers` headers replaces it, so other consumers can identify themselves without any new API. `buzz-cli` uses this to send its own name. - **`buzz-audio-client`**: keeps a `User-Agent` already on the host-supplied request, and adds its default otherwise. - **Mobile**: `openRelayWebSocket` sets the HTTP client's `userAgent`, which also covers the huddle transport. Startup waits up to 500 ms for the app version from `package_info_plus` before the app runs, so the first socket carries it; if the lookup fails or times out, the value is `Buzz-Mobile`. Each client has a test asserting the header on the upgrade request. --------- Signed-off-by: Will Pfleger <wpfleger@block.xyz> Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> |
||
|
|
8cce12f591 |
feat(mobile): consolidate Solo chat settings in one sheet (#9255)
## Summary Replace the Solo chat header controls with an ellipsis that opens Chat settings, showing the selected agent’s avatar and workspace. Agent and grouped Workspace pickers share one backdrop; closing either returns to settings without flickering. The sheet smoothly resizes to fit each page and respects Reduce Motion. ### Related issue No duplicate found. ### Testing Full pre-push checks pass without exclusions: analysis, formatting, file-size checks, all 4547 mobile tests (67 skipped), push-configuration tests, and secp256k1 tests. Navigation, backdrop continuity, smooth resizing, and Reduce Motion are covered. Updated four stale link-swipe tests to use the leftward reply gesture introduced in #9127. Installed on iPhone Air with saved state preserved. Flutter snapshots with sample data (attached only, not committed): | Chat settings | Workspace | | --- | --- | |  |  | --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
d839d3731a |
fix(mobile): swipe left to reply so swipe-back keeps rightward drags (#9127)
Swipe-to-reply used a rightward drag, the same direction as iOS swipe-back. A rightward drag that started on a message armed a reply instead of going back, and the reply recognizer claimed horizontal drags in either direction once they started on a bubble. Now a message slides **left** to reveal Reply on its trailing side, and the reply recognizer only wins the gesture arena for leftward drags. Rightward drags that start on a message are left to back navigation. The threshold, haptic, rubber-band resistance and thread destination are unchanged. On bubbles that reach the row's trailing edge (your own messages), the Reply feedback is kept on screen beneath the bubble, so the slide uncovers it. ### Demo Before (main): a rightward drag that starts on a message arms Reply and slides the bubble, instead of being left to back navigation. https://github.com/user-attachments/assets/f149cd11-c326-466c-a17b-3e4698708af8 After (this PR): a rightward drag on a bubble does nothing, a leftward swipe reveals Reply and opens the thread, and edge swipe-back returns to the channel. https://github.com/user-attachments/assets/539a5682-1032-452e-a57b-6f27abfe2d64 ### Validation - Focused existing validation: the swipe-reply widget tests were mirrored to the new direction for channel and thread rows, own and incoming messages. They now also check that a rightward drag starting on a bubble reaches an outer horizontal drag handler (a stand-in for swipe-back) without moving the row or opening a reply. The Bestie DM test's reply drag was flipped too. - The full mobile suite (`just mobile-check` and `just mobile-test`) passes. - Automated simulator run (iPhone 17 Pro, debug build): swiped left on outgoing and incoming bubbles (feedback shows on the trailing side, thread opens), edge swipe-back from a thread and channel, and a rightward drag starting on a bubble (no reply). The demo above was recorded on a synthetic private channel (own messages only; incoming-message rows were covered in the earlier run and by the widget tests). Human testing is waived under the owner's standing exception. 🤖 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> |
||
|
|
a7a534d476 |
mobile: long-press a message link for open, copy and share (#9135)
Holding a link in a message used to open the whole-message actions sheet, so there was no way to copy or share just the link. Now a hold on a link opens a small **Link** sheet showing the URL with **Open link**, **Copy link** and **Share link**. Web links share as URLs so the share sheet can preview them; other links (such as `buzz://`) share as text. Open link appears only for destinations the app can open (web and `buzz://` links), so a `mailto:` link offers Copy and Share. Repo, PR and issue chips, which have no mobile destination, also offer Copy and Share on hold. While a finger is down, the link gets a light tint behind it, the same kind of press highlight the message row paints. A hold anywhere else in the message still opens the message actions, and a tap on a link still opens it directly. The link gets its own long-press recognizer with the same hold deadline as the message row. Pointer events reach the deeper recognizer first, so at equal deadlines the link wins the arena before the message row, the same mechanism reaction pills already use to claim their own hold. Rendering is otherwise unchanged: the link widget tree only gains a `RawGestureDetector`, a `Semantics` wrapper and a `DecoratedBox` for the tint, press state lives in the link widget so only the link rebuilds, and the sheet is built only when opened. Open reuses the existing tap path (external browser, authenticated relay-media download, or Buzz deep link). ### Validation Added tests: a journey in `message_content_test.dart`, run with both iOS (200 ms) and Android (default) hold timing, that holds surrounding text (message menu opens, link sheet doesn't), holds the link (link sheet, not the message menu), copies it (clipboard gets the URL), opens it from the sheet and by a plain tap, and checks the press tint appears on touch and clears when the finger moves away. Removing the link recognizer or the tint makes it fail. Another test holds a `mailto:` link and checks the sheet offers Copy and Share but not Open. Swipe-to-reply tests in `swipe_reply_tests.dart` drag a message starting on its link and beside it, with both hold timings, and both open the reply. Full `just mobile-check mobile-test` passes (after rebasing onto main, two `hosted_backup_service_test.dart` tests failed once under load and pass on rerun; this PR doesn't touch that code). Automated app run on an iPhone 17 Pro simulator (iOS 27) signed in to a Builderlab community, in a private QA channel: held message text (message actions sheet, dismissed and composer focus restored as before), held a link (Link sheet), Copy link (toast, simulator pasteboard holds the URL), held again and Share link (system share sheet with the page preview). The simulator's share sheet then stopped responding to dismissal, a known simulator share-extension quirk unrelated to this change. After adding the tint, a second simulator run held the link and captured the pressed state (below). Not covered: Android device run, VoiceOver. https://github.com/user-attachments/assets/6f5bda14-14af-450e-b021-f98ebc072f9b | Held | Link sheet | Copied | | --- | --- | --- | | <img src="https://github.com/user-attachments/assets/86262d85-b745-41b4-8c40-96372d0cec61" width="300" alt="Link held, tinted"> | <img src="https://github.com/user-attachments/assets/7c9fe05a-17a0-47c4-851e-e89456bc4520" width="300" alt="Link sheet"> | <img src="https://github.com/user-attachments/assets/d97c417a-a79b-4dd2-a43e-35ace7ea5552" width="300" alt="Link copied"> | 🤖 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> |
||
|
|
e86d0e23a3 |
feat(mobile): long-press a Solo chat to open its details sheet (#9163)
Long-pressing a team channel in the Messages list opens a bottom sheet of channel actions. Long-pressing a Solo chat did nothing, so its settings (star, mute, members, move, copy, leave/archive/delete) were only reachable from inside the chat. Now a long-press on a Solo chat row opens that chat's details screen as a sheet, with the same presentation as the team channel sheet: drag handle, close button, 640 pt max width, 70% max height. The content is the existing channel details page rendered without its app bar (`ChannelDetailsPage(inSheet: true)`), so it stays in sync with the full page. Tapping a chat still opens it as before. Solo chats are placed in Solo workspaces, not channel sections, so their details (sheet and full page) no longer show "Move to section…". That row wrote channel sections, which left the chat where it was. Moving a Solo chat between workspaces stays on the chat header's workspace control. ### Demo Long-pressing a Solo chat in the Messages list (Solo filter) opens its details sheet; Star toggles, the sheet scrolls to the lifecycle actions, and the close button dismisses it. Synthetic chat named "QA demo". https://github.com/user-attachments/assets/e8b8a799-be1f-4d4b-b47d-f2209e2b1920 ### Validation - Added test: long-pressing a Solo chat on the Me page opens the details sheet with the chat's name, the sheet scrolls to its lifecycle actions, and the channel-section move is absent. - The full mobile test suite and `flutter analyze` pass. - Full automated simulator run on the final code (iPhone 17 Pro, debug build, signed-in Builderlab account, light and dark mode). In the Solo long-press sheet: Star and Mute toggle and revert; Edit, a member's profile and Add members open and close back to the sheet; Copy channel name and Copy channel ID copy the right values and show a toast; the sheet scrolls; the Leave, Archive and Delete confirmations appear and were cancelled; the close button, scrim tap and drag down all dismiss it; "Move to section…" is absent. Tapping the chat still opens it, and its in-chat details page also omits the section move. Regression check: the team channel long-press sheet and team details page are unchanged, including a working "Move to section…". The recording is in Buzz rather than here because it shows internal channel names. Limitation: destructive actions were cancelled, not confirmed. 🤖 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> |
||
|
|
1a0dae77bf |
fix(mobile): shimmer loading photos in the iOS carousel (#9167)
On iOS, a message with several photos renders them in a native UIKit carousel. While a thumbnail downloads, each cell showed the words "Loading image". Single images and the Flutter carousel already show a shimmering skeleton, so the native carousel stood out. Native carousel cells now show the same shimmer as Flutter's `SkeletonShimmer`: a fill in the theme's primary color at 10% opacity, with a lighter band sweeping across every two seconds. Flutter passes the primary color to the native view along with the brightness. With Reduce Motion on, the fill stays still. Once the thumbnail arrives the shimmer hides. If the thumbnail fails, the cell shows its existing "Image unavailable" message on the plain surface. ### Demo A synthetic private chat with a four-photo message, reopened on a cold launch. To make the loading state easy to see, both builds were patched locally to delay thumbnail delivery by 4 seconds (the patch is not part of this PR). Before (main): each cell shows the words "Loading image" until the thumbnail arrives. https://github.com/user-attachments/assets/3724873d-4f33-4db7-af16-634dce3371ac After (this PR): each cell shimmers in the theme's primary color until the thumbnail arrives. https://github.com/user-attachments/assets/20f65789-de3f-4e13-892f-3b2d9a0461cc ### Validation - `just mobile-check` and the full `just mobile-test` suite pass. - Automated simulator run (iPhone 17 Pro, debug build): posted four photos to a synthetic private chat from the app's photo picker, relaunched, and opened the chat on both main and this branch. Before shows "Loading image"; after shows the shimmer, which gives way to the photo when it arrives. Limitations: light mode only, and the 4 second thumbnail delay was a local patch to make the state observable. Dark mode, Reduce Motion and the failed-thumbnail state were not exercised. 🤖 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> |
||
|
|
f3b127e3af |
fix(mobile): keep Home bell tappable while scrolled (#9241)
## Summary Keep the mobile Home bell tappable while scrolling. Reserve both header buttons’ space as soon as the filters overlap the toolbar, and clip filter overflow. ### Related issue No matching open issue or PR found. ### Testing - `just mobile-check` and `just mobile-test` passed. - Android/iOS regression coverage checks the bell at five scroll positions and Activity navigation. iOS uses platform-view hit testing plus its callback; no physical-device verification. - Pre-push checks passed. The additional `just ci` run passed workspace Rust lint and desktop source checks; stopped during desktop native dependency compilation, so full local CI is incomplete. Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
12f3d51001 |
feat(mobile): one Builderlab per build, internal and public distributions (#9224)
The mobile app can now be built as a public build or an internal build, set explicitly with `BUZZ_DISTRIBUTION=internal|public`. Each build trusts exactly one Builderlab (`BUZZ_BUILDERLAB_URL`), and sends that Builderlab's relay assertions only to the relays in `BUZZ_BUILDERLAB_RELAYS`. The three settings are set together or not at all; with none set, the app is a Nostr-only development build. The decision is recorded in `docs/adr/0001-internal-and-public-mobile-builds.md`, and the terms are defined in `CONTEXT.md`. Before this change, one build supported a public Builderlab and an organization Builderlab at the same time, with two sessions. An organization's members could reach public communities from the organization's app. ### Behavior - **Public build:** QR pairing, email sign-in and multiple communities are unchanged. It has no knowledge of any organization. An organization's relay already rejects clients that can't present its Builderlab's assertion, and the public build relies on that. - **Internal build:** the first screen has a single **Sign in** button that goes straight to the organization's single sign-on and then joins the organization's community. There is no QR pairing, community link or community creation, and the community switcher is hidden. Only the exact relay hosts listed in `BUZZ_BUILDERLAB_RELAYS` are reachable. - **Identity (internal):** a member whose account has no Nostr identity yet gets a new key on first sign-in; the first relay assertion binds it, and the app backs it up to the organization automatically. A member whose account is already bound uses that key from this device, or restores it from the organization's backup. If neither is possible, Sign in says it couldn't restore the identity and saves nothing. - **Sign-out (internal):** a destructive **Sign out** row at the bottom of Settings, the build's only way out. It removes the community, closes Settings and returns to Sign in. The Builderlab session is cleared from storage immediately and can never be reused to sign back in. The relay push-stop keeps the retired credential in memory only, for up to 10 minutes, and uses it only to get an assertion for a key that is already bound. It is a best-effort request. - **Upgrade to an internal build:** a saved public Builderlab session is cleared. Communities on other relays are hidden, not deleted, and their keys stay on the device. - `BUZZ_HOSTED_RELAY_AUTH` is unchanged here. ### Screenshots (internal build) | Sign in | Community menu | Settings | | --- | --- | --- | |  |  |  | Gateway-side push revocation, which needs no Builderlab session, is planned as a separate PR. ### Validation Added tests: - An internal-build journey: one Sign in creates and binds a key, and a retry after a cancelled join reuses it. The app uploads that key's backup itself. Sign out from Settings closes it and returns to the landing, which ends the session, and Sign in then restores the same identity from the backup. Communities on other relays stay stored but hidden. - Internal Sign in for a bound account: the key on this device, a key restored from backup, and no backup (clear error, nothing saved). Each guard was shown to fail when removed. - Organization relay assertions: assertions go only to exact listed hosts, not subdomains. After sign-out, only a push stop for a bound key gets an assertion, and nothing can sign back in. Proven to fail without the fix. - Builderlab build setting parsing. CI runs the internal and public journeys in their own lanes with those settings. The full suite passes in the default configuration. The full suite isn't meant to run under the internal configuration, because most fixtures use relays that an internal build hides. Automated full-app run on an iPhone 17 Pro simulator at the current head: - Public build, upgraded in place over a signed-in install: home loads, a channel opens and a message sends. - Internal build: a fresh install shows only Sign in. With a saved organization community, the community menu has no Switch community, and Settings → Sign out → confirm returns to Sign in. Real single sign-on can't run in the simulator. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost> |
||
|
|
794ee64183 |
fix(ios): mint hosted NIP-FI assertions in the notification extension (#9228)
Share the hosted Builderlab session with the iOS notification extension so it can mint hosted NIP-FI assertions. The extension queries a hosted relay without an assertion first and mints only after a NIP-FI challenge, for a key the account is already bound to. Credential headers never follow redirects, and flag-off builds clear a previously shared session. Signed-off-by: Tom Brow <tomb@squareup.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
b58ee08404 |
fix(mobile): request hosted relay assertions only when NIP-FI is required (#9225)
Port the desktop NIP-FI requirement rule to mobile: hosted relay connections request an account assertion only when the relay's NIP-11 document requires NIP-FI, so communities that have not enabled it keep connecting with BUZZ_HOSTED_RELAY_AUTH on. Signed-off-by: Tom Brow <tomb@squareup.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
597a8d1970 |
feat(mobile): rename Activity and add header filters (#9213)
The UI-only part of #9186 by @klopez4212, split out so it can land without the Activity sync changes. - The Inbox page becomes Activity, with header controls instead of menus: a type menu (All, Threads, Mentions, DMs, Reminders, Drafts), a sender menu (Anyone, People, Agents) and an Unread toggle. All of them filter the feed the app already loads. - Activity and Search get a top edge fade. Bulk mark-as-read and the "New" divider are gone. - DM thread replies group with their DM row, so the DMs filter includes them. Left out, for a separate sync PR: the Activity history cache, subscriptions to every joined channel, the refresh coalescer, thread-follow evidence, Reactions and Solo rows, and the read-state changes. The Activity provider and read-state writes are unchanged from main. One fix on top: the iOS filter labels pass an unscaled font size, since the native button already applies Dynamic Type. Before, large text was scaled twice. ### Validation - Kenny's added tests for the header controls (`tabs_journey`) and `TopEdgeFade`, adjusted for the removed Reactions option. Added tests for the DM filter, marking a DM row with replies under two thread roots read, and sender filtering per event. - Full mobile suite: see the latest CI run. Locally, 11 push and notification-toggle tests fail identically on an unchanged checkout. - Automated simulator run (iPhone 17 Pro, iOS 27) of a build combining this PR with its sibling split: Home rows measure 44 pt, the bell opens Activity and shows its dot after a row is marked unread, type/sender/Unread filters each narrow the feed, a row opens its conversation, Search results scroll under the fade, and filter labels fit at the largest text size. Screenshots are in Buzz rather than here because they show internal test data. Android was covered only by widget tests. ### Before merge - [x] Automated simulator run of Activity filters, row taps, read and unread, and Search, with screenshots. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
beec055300 |
feat(mobile): move Activity to a Home bell and align Home spacing (#9212)
The UI-only part of #9193 by @klopez4212, split out so it can land without the Activity sync changes. - Activity moves from a row in the Home list to a bell button in the Home header, with an unread dot driven by the existing Activity feed and read state. - Home filter capsules match native iOS and Android widths, with a subtle outline on Android when the header collapses. - Home section spacing is tighter. Left out: the reconnect rewrite in #9193's last commit (live events applied in place, no rebuild on reconnect, fatal fallback reads). The Activity provider is unchanged from main. Channel and Solo rows keep their bottom padding so they stay at the 44 pt minimum tap height. ### Validation - Kenny's added tests: `inbox_unread_provider_test` (unread dot) and the Home page bell and filter width tests. - Full mobile suite: all pass except 11 push and notification-toggle tests that fail identically on an unchanged checkout in this environment. - Automated simulator run (iPhone 17 Pro, iOS 27) of a build combining this PR with its sibling split: Home rows measure 44 pt, the bell opens Activity and shows its dot after a row is marked unread, type/sender/Unread filters each narrow the feed, a row opens its conversation, Search results scroll under the fade, and filter labels fit at the largest text size. Screenshots are in Buzz rather than here because they show internal test data. Android was covered only by widget tests. ### Before merge - [x] Automated simulator run of Home, the bell, Activity and channel navigation, with screenshots. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a25f3cd1d7 |
fix(mobile): match Agents sign-in to Home’s reconnect capsule (#9204)
Replaces Agents’ inline sign-in row with Home’s reconnect capsule. It appears at the top of Agents, scrolls with the agent cards, and remains available until leaving the page or signing in. Home is unchanged. Validated with mobile checks, the full test suite, scrolling and sign-in/re-entry regression journeys, and an approved iPhone Air visual preview. The device preview forces the capsule visible while signed in; that override is excluded from this PR. | Before (widget render) | After (widget render) | | --- | --- | |  |  | <details> <summary>iPhone Air and large-text snapshots</summary> Physical iPhone Air, with preview-only visibility override:  3x text, 320×568 widget render:  </details> --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
0a4d209046 |
Show mobile floating dates while scrolling (#9185)
Floating dates in conversations and threads now appear during reader scrolling, then fade to zero over 250 ms after one second idle. Automatic positioning stays quiet, and renewed scrolling reveals the date immediately. Reduced-motion behavior is preserved. Validation: mobile analysis, scroll/timing widget tests, and iPhone Air preview. Full mobile tests run through the push gate. Home and Inbox styling has moved to [#9187](https://github.com/block/buzz/pull/9187); this diff only contains scrolling-date behavior and its supporting tests/helper extraction. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
962b3fdd88 |
Add avatar color backdrop to mobile agent creation (#9184)
Adds a blurred, saturated backdrop to mobile’s **Choose avatar** screen that crossfades with the selected avatar or emoji color. Enlarged artwork keeps the effect abstract; the upper fade leaves the bottom controls clear. Supports reduced motion and high contrast. Validated with analysis, the full mobile suite (4,518 passed), light/dark widget previews, and an iPhone Air install with the existing account preserved. ### Snapshots Flutter widget captures: | | Before | After | |---|---|---| | Light |  |  | | Dark |  |  | buzz-review-completed Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
9a6cb3ffdf |
mobile: make the first-agent card stand out in dark mode (#9171)
Follow-up to #9036. In dark mode the "Create your first agent" card used the raised dark surface (#1A1A1A), which barely separates from the black Home page, and dark mode shows no shadow — feedback was that it's hard to see. The card now uses the inverse surface in both themes (light: dark card on white; dark: light gray card with black text), so it always contrasts the page. No new tokens. Tests: `flutter analyze` on the widget; `flutter test test/shared/widgets/buzz_callout_test.dart test/features/channels/` all pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) | | Before | After | |---|---|---| | Dark |  |  | | Light (unchanged) |  |  | _Rendered from the widget at phone width with the real fonts and artwork._ Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> Co-authored-by: Morgarita <6e967af659416160ab4f2fd56d74c8c6f273791ee7bfc9468a1be37c387947be@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
b894fb02ff |
fix(mobile): show transparent avatars without a black background (#9105)
## Summary Transparent PNG and GIF avatars render on a black background in mobile avatars and iOS notification avatars. Both load the relay's small thumbnail, which was always a JPEG with no alpha channel. [block/buzz#9003](https://github.com/block/buzz/pull/9003) makes relays write a `{sha}.thumb.webp` thumbnail that keeps transparency, alongside the `{sha}.thumb.jpg` that every relay writes. This PR loads the WebP first. - `MediaImage` with `preferThumbnail` tries `.thumb.webp`, then `.thumb.jpg`, then the original. Only a 404 moves on to the next source; any other failure still shows the error widget. Missing thumbnails are remembered as before, so later builds skip straight to the source that exists. - `BuzzNotificationAvatarLoader` follows the same order for notification avatars. Relays without 9003, and images uploaded before it, keep rendering from the JPEG as they do today. ### Screenshots A transparent PNG avatar rendered by `MediaImage` on the same relay, before and after (widget harness on an iOS simulator). | Before | After | | --- | --- | |  |  | ### Related issue AIDA-2308 (internal). The desktop counterpart is [block/buzz-app#2477](https://github.com/block/buzz-app/pull/2477). ### Testing - `missing thumbnails fall back to the JPEG, then the original` (`avatar_image_test.dart`): when the relay 404s every thumbnail, the avatar requests the WebP, then the JPEG, then the original. When the WebP fails with a 500, the avatar shows its fallback and requests neither the JPEG nor the original. - The relay thumbnail journey tests run against a relay that 404s `.thumb.webp`, so they cover the older-relay path end to end. - `BuzzNotificationAvatarLoaderTests` checks the WebP-then-JPEG thumbnail order for relay media, and that non-relay media gets no thumbnails. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: John Tennant <jtennant@squareup.com> Co-authored-by: Simone <0828be588d5c9c4d092c5b1cf01761d5aef63d6b104807fd8e8284d517dd1a99@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a1efbf63bd |
fix(mobile): delete composer chips as one unit (#9133)
In the mobile composer, Buzz links (channel, message, repo, PR, issue) and selected agent `@mentions` render as chips, with the source text kept as invisible characters behind the chip. Backspace right after a chip removed one of those hidden characters, so the chip turned into a broken raw URL or partial name with no visible change for most of the presses. This PR gives chips one consistent editing model: - **A link is a chip only once something follows it.** Its pattern runs to whitespace or the end of the text, so in practice that is any time the caret isn't at its end. Pasting exactly one link adds a space after it (unless whitespace already follows), so it becomes a chip straight away. - **A link shows its raw URL while the caret is at its end.** Backspacing the space after a chip, or moving the caret back past it, reveals the URL for editing, one character at a time. Moving the caret away turns it back into a chip. A caret at the link's start leaves it a chip. - **Agent `@mention` chips stay atomic.** Backspace right after one removes the whole mention. - **Chips are atomic for selection and forward delete.** A caret placed inside a chip moves to its nearer edge, arrow keys step over it, and a selection that covers part of a chip grows to cover all of it. Forward delete just before a chip removes the whole chip. Typing, pasting, deleting or cutting over a selection replaces exactly the (snapped) selection. The controller records each chip's source range while it builds the spans, so only what is actually drawn as a chip behaves as a unit. Text in code spans and IME compositions are never rewritten. Undo restores the previous text: edits at the end of a revealed link are plain text edits, so undo no longer looks like a backspace into a chip. Human `@mentions` aren't chips today and are unchanged. After this change, in the simulator: a link becomes a chip once a space follows it, backspace reveals the raw URL, the next backspace removes one character, and the arrow keys reveal the link and turn it back into a chip. https://github.com/user-attachments/assets/1d051dd7-92fa-47c9-b569-a7ef5e00ca6f <img src="https://github.com/user-attachments/assets/9980aa01-bcf2-4a1b-9c25-4349ce999e26" width="300" alt="Composer with a channel link chip after a space"> <img src="https://github.com/user-attachments/assets/5dfbaa44-a0f4-4917-b7ca-eeeb63e47dae" width="300" alt="After one backspace the raw URL shows"> <img src="https://github.com/user-attachments/assets/1a675d56-7310-4b13-9dd9-caa26ff99283" width="300" alt="The next backspace removes one character"> ### Validation - Added tests: the composer permalink-chip test now covers the boundary space and reveal (soft and hardware backspace, arrow keys), caret and selection snapping, forward delete, paste adding a space, IME composition, and undo after typing at the end of a link or after completing a link. The agent-chip test covers whole-mention backspace, selection snapping over a mention and a mention in a code span. Breaking each rule (reveal, snapping, paste space, whole-chip delete, widening only inside one chip) fails a test. - Focused existing validation: the full mobile suite (`just mobile-check mobile-test`) passes. - Automated app run: iPhone 17 Pro simulator, iOS 27, debug build of this branch, in a private test channel. Typed a Buzz channel link and a space (chip), backspaced twice (raw URL, then one character removed), retyped the character and space (chip again), then moved the caret left past the space (raw URL) and right again (chip). Nothing was sent. The run covered the composer only; the change touches nothing outside it. buzz-review-completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
fbfbf3d64f |
test(mobile): drop the video preview step removed with native frame loading (#9164)
Fixes red main. https://github.com/block/buzz/pull/9144 added a step to the expired-assertion journey in `native_media_headers_test` that loads a posterless video preview through `videoPreviewFrameLoaderProvider`. https://github.com/block/buzz/pull/9138 merged around the same time and replaced native first-frame loading with a static placeholder, removing that provider, so `flutter analyze` now fails on main with `Undefined name 'videoPreviewFrameLoaderProvider'`. Posterless previews no longer fetch anything natively, so the step has nothing left to guard. This deletes it along with its fake video player, restoring the test file to its state before #9144. ### Validation - `just mobile-check` and `just mobile-test` pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> |
||
|
|
fd0ec10505 |
perf(mobile): rank search channels once per query (#9136)
Search re-ranked every channel on each live channel event, even while it
sat covered in the back stack, and could move a result while you were
reaching for it. On an iPhone 12 mini in a large community, each re-rank
cost 4 to 20 ms at about 2.4 channel events per second: 454 ms of 2,575
ms UI CPU in a 25 s scroll of a big channel with search behind it, and
343 ms in the 15 s after a resume.
Channel results are now ranked when the query runs and when the set of
channels changes: the first load and a directory install (including one
another caller started). Live channel events no longer reorder shown
results; they only update the shown rows' content (membership badge,
member count, name) in place. The next query ranks the current list.
### Accepted gap
A channel joined, left or renamed while results are shown updates its
row but keeps its position until the next query.
### Validation
- Added test: `live channel events never reorder shown results` adds a
channel and checks it is ranked in, moves it to the top of the list
through activity and checks the shown rows don't move, lets a live event
land between another caller's directory status turning loaded and its
install and checks the open channel shows, then joins that open channel
and checks its Open badge goes without the row moving. It fails when
activity re-ranks again, when a change to the channel set is not ranked,
and when shown rows keep stale content.
- Search suite and full mobile suite pass except the known
notification/push failures that also fail on main. Analyze clean.
- Automated simulator run of this branch (debug build, Builderlab
community): searched "studio", channel and people results listed, opened
a channel result, went back with the results still shown.
- Device numbers are from a wired Profile build of main
|
||
|
|
1a30e51689 |
mobile: start new agents on the server-recommended model (#9151)
## Summary Builderlab's model catalog can now recommend a model and thinking effort for new agents (`recommended_selection` on `/v3/beekeeper/get-model-catalog`). The mobile create-agent screen now starts on that recommendation, so the default can change on the server without an app release. New agents start on the first of these that applies: 1. The model an agent link suggests, when the catalog offers it (unchanged). 2. The server's recommended model and effort. 3. The first Sonnet model at medium effort, as added in #9034. Used while a deployment sends no recommendation. 4. Blank. A setup resumed after a failure still keeps its saved choice. A recommendation that isn't in the returned catalog, or that is malformed, is ignored. Existing agents' saved models are never changed. The server only sends a recommendation when the deployment's catalog config sets `recommended_selection`. Until then, the app keeps falling back to Sonnet. ## Testing - Unit tests cover the recommendation beating the Sonnet fallback, a draft model beating the recommendation, and recommendations that should be ignored (unsupported effort, unknown model, missing effort, malformed, absent). - A new widget test checks that the form opens on the recommended model and effort instead of Sonnet. - `just mobile-check` and the full mobile suite (`flutter test --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example`) pass locally. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
fc85b0d154 |
test(mobile): check posterless video previews renew the relay assertion (#9144)
Follow-up to https://github.com/block/buzz/pull/9134, which made posterless video previews and voice notes renew an expired relay assertion before handing headers to the native player. The voice-note path got a test there, but nothing failed if the video preview went back to the cached-only headers. This extends the expired-assertion journey in `native_media_headers_test` with one step: the real `videoPreviewFrameLoaderProvider` loads a relay video through a fake native video player, which records the headers it is created with. The player must receive both the media `Authorization` proof and the renewed `Nostr-Federated-Identity`. ### Validation - Added tests: the video-preview step above. Switching the loader back to the synchronous cached headers fails it. - `flutter analyze` is clean and the full mobile suite passes (4508 tests). - Test-only change, so no app run. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> |
||
|
|
133c883b8c |
fix(mobile): confirm before starting a Huddle from the header (#9155)
Tapping the headphones button in a channel header used to start a Huddle straight away, which turned on the microphone and posted "Huddle started" to the channel. The button is easy to hit by accident. Now, when no Huddle is running there, the button asks "Start a Huddle?" first, with Cancel and Start Huddle. - `showConfirmation` in `shared/widgets/confirmation_dialog.dart` adds a non-destructive mode to the shared confirm helper. `showDestructiveConfirmation` now calls it with `destructive: true`, so existing callers behave the same. On iOS the native UIKit alert reads a new `destructive` argument and uses `.default` style instead of `.destructive` when it's false. On other platforms the confirm button keeps the default `FilledButton` style instead of the error color. - `_huddleNavigationAction` asks before calling `start`. That covers both the Material header button and the iOS native navigation action. Opening or joining a Huddle that's already running stays one tap, and so do the explicit "Start new" buttons inside the Huddle sheets. Companion to block/buzz-app#2687, which adds the same confirmation on desktop. buzz-app#2660 also moves the desktop button away from the members button, but there's no equivalent move on mobile: the headphones is the only action in the header. ### Screenshots Rendered by a scratch widget test from the real `ChannelDetailPage` (Android/Material path, Inter loaded) at 390×844. The scratch test has been removed. On iOS, the same confirmation shows as a native UIKit alert, which isn't captured here because this machine has no iOS Simulator. Before: tapping the headphones goes straight into a call. <img src="https://github.com/user-attachments/assets/adced280-2abd-44ad-a079-1c83749892a0" width="300" alt="Before"> After, light and dark, cropped to the new dialog: | Light | Dark | | --- | --- | |  |  | ### Validation - The existing journey "community transition cancels and awaits an in-flight Huddle start" now taps the button and checks that the dialog appears, that Cancel closes it without publishing anything, and that tapping again and choosing Start Huddle starts the call. With the confirm removed, this test fails. - `channel_member_profile_actions_test` checks that destructive confirmations still send `destructive: true` to the iOS bridge. - Full `flutter test`: 4,508 passed. `flutter analyze` and `dart format` are clean. - Not yet checked: the iOS native alert on a device or simulator, and a human trying it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Diem Nguyen <diem@squareup.com> Co-authored-by: Fizz <6be7be23ec5bba868f3e1db5ec86013d76bd1a293dce5b435a192158fec13641@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
c39ee507b2 |
fix(mobile): find verified people who have no profile (#9086)
## What changes
Mobile now finds a person who has a relay-verified profile (NIP-FI, kind
30382) but never published a kind:0 profile. This happens, for example,
when someone skips profile setup. The relay's `POST /profiles/search`
already finds such people by their verified name. Each one comes back as
a kind:30382 head with no kind:0. Mobile kept only kind:0 results, so
these people did not appear.
- `RelaySessionNotifier.searchProfiles` keeps kind:30382 heads from the
endpoint.
- `VerifiedProfilesNotifier.searched` turns a search page into people:
- If the relay authority is not known yet, it first waits for NIP-11
discovery. Otherwise, a search that finished first would drop these
people for that query.
- It checks each head's signature with the directory's off-isolate proof
checker.
- Proven heads go into the verified directory, so the rows show verified
names right away.
- `searchedPeople` (in `verified_profile.dart`) builds one row per
person, in the relay's order:
- If the person has a kind:0, the newest kind:0 is used, as before.
- If not, it uses the proven head that the authority signed, provided it
parses as an active verified profile.
- These cases add no row:
- a head signed by another key;
- a head that names the relay key but has a bad signature;
- a retraction;
- a malformed subject.
- **Behavior change for all mobile mentions:** every verified person now
matches in mention ranking by their verified full name and username, as
well as by display name, including people who have a kind:0. The relay's
search matches these same three names. Without this, a person whose
display name is their username (`glilienfeld`, full name "Gabi
Lilienfeld") came back from the relay for "gabi", and then the local
filter dropped them.
- Callers:
- Mention search, which also serves Add members and New DM.
- The user rows in global search, through `directoryUsersFromSearch`.
The desktop app has the same fix for Manage members:
https://github.com/block/buzz-app/pull/2554
## Screenshots
These come from the real Add members sheet on an Android emulator. Only
the relay session, verified-profile discovery and the member actions are
faked. The names are made up. Jordan Park has a kind:0. jrivera has only
a verified profile, with the full name "Jordan Rivera". The search is
"jordan". The row shows the verified display name, which here is the
same as the username.
| Search finds jrivera | After tapping + on jrivera |
| --- | --- |
| <img width="300" alt="jrivera is found next to Jordan Park"
src="https://github.com/user-attachments/assets/d93b77b1-1bc7-4d09-afc1-f359616a192a"
/> | <img width="300" alt="jrivera is selected to add"
src="https://github.com/user-attachments/assets/ce6c7a71-b098-434e-8b0e-ed0de8d1a5a2"
/> |
## Tests
- `add_members_sheet_names_test.dart`: a journey in which Gabi has
`display_name` = `glilienfeld` and `full_name` = "Gabi Lilienfeld", and
NIP-11 discovery is slow.
- While discovery is pending, typing "gabi" shows nothing yet.
- When discovery finishes, Gabi appears with no further keystroke.
- A head from another key does not appear, and neither does a head that
names the relay key but has a bad signature.
- **Add member** adds Gabi's key.
- I removed each fix in turn: the full-name alias, the signature proof,
and the wait for discovery. The journey fails each time, for the
expected reason.
- `verified_profile_test.dart`: a table of `searchedPeople` cases.
- A kind:0 wins over a head, and the newest kind:0 counts.
- These find nobody: another signer, a retraction, an uppercase subject,
a non-profile kind, and any head when the authority is unknown.
- `relay_session_test.dart` and `channel_management_provider_test.dart`:
rows for the kept heads and the directory row.
- `flutter analyze` is clean, and the full `flutter test` passes on
6efef79cc: 4,462 tests.
**Note:** mobile on `main` does not compile at
|
||
|
|
4aae6c22aa |
fix(mobile): delay iOS message actions until a deliberate hold (#9049)
## Summary Increase the iOS message and reaction hold delay from 200 ms to 500 ms, giving users more time to start scrolling without opening a menu. Movement detection and other platforms remain unchanged. Apple documents a [0.5-second default for UILongPressGestureRecognizer](https://developer.apple.com/documentation/uikit/uilongpressgesturerecognizer/minimumpressduration). ### Related issue Fixes #8500. ### Testing - Regression checks fail with the old timeout and pass with the change: no recognition at 499 ms, recognition by 501 ms, and scrolling after a 300 ms pause cancels the hold. - Device testing pending; the local iOS simulator SDK is unavailable. Before review, verify deliberate holds, slow scroll starts, and reaction holds on an iPhone. 🤖 Generated with Goose Signed-off-by: Matt Wilcox <mwilcox@squareup.com> Co-authored-by: Gen <8a28d770b293a0b223e7c671e5985a35fd9bc1a3c7e3fa92ff715bd2fe61b253@squarewebplatform.communities.buzz.xyz> |
||
|
|
2bd4917825 |
fix(mobile): answer push snapshot bridge calls at user-initiated QoS; report hop latency on callback timeout (#8916)
## Summary `BuzzPushSnapshotEnrichmentTests` (iOS RunnerTests) timed out on two unrelated PR branches on 2026-11-01 and passed on re-run both times. The timings point to a starved test host, not a missed callback. This PR makes one production change and two test changes, in two files: - **`mobile/ios/Runner/PushSnapshotBridge.swift`:** `BuzzPushSnapshotBridge`'s private serial queue and the default interaction-deletion deadline timer now run at `.userInitiated` instead of `.utility`. Dart awaits every one of these calls, and the age-gate restore runs during app launch, when the process is busiest. User-initiated is the class for work a caller is waiting on. - **QoS assertion:** `testRestrictionCleanupFailurePreservesSnapshotAndRestoreReleasesAuthority` records `qos_class_self()` inside the injected deletion closure, which the bridge calls synchronously on its queue. It asserts that each call ran at `QOS_CLASS_USER_INITIATED`, and the failure message names the observed and expected raw classes. - **`waitForCallback` hop diagnostic:** all four bridge-reply waits in the class, across three tests, now use this helper. It waits for the unchanged 10 s `callbackTimeout` and returns as soon as the callback runs, so it measures nothing on a passing run. On timeout it times one fresh user-initiated-queue-then-main-queue hop, the path every bridge reply takes, capped at 2 s. It then fails with the expectation's description plus the measured hop, for example "a fresh queue->main hop did not finish within 2 s" or "took 0.4 ms". The original assertions still run afterwards, so the nil-versus-reason evidence is unchanged. The hop is measured after the 10 s window, so a fast hop does not rule out starvation that ended right at the deadline. No timeout changed. ### Related issue None found. #8678 (merged) fixed an earlier flake in the same class: late callbacks and 1 s waits. It introduced the 10 s `callbackTimeout` this PR keeps. ### Failing runs Both runs were in "Mobile Swift (Native)", step "Test native controls and Runner unit tests", attempt 1. Both passed on attempt 2. - [Run 41327410782, job 124910378184](https://github.com/block/buzz/actions/runs/41327410782/job/124910378184), on `mobile-avatar-thumb-followup` (#8896): `testRestrictionCleanupFailurePreservesSnapshotAndRestoreReleasesAuthority` timed out its 10 s "allowed without restoring snapshot" wait (19.3 s total). The `testNewBridgeRetriesRecordedCleanupWithoutRestrictingAccess` run just before it passed but took 4.3 s. - [Run 41325606910, job 124905175903](https://github.com/block/buzz/actions/runs/41325606910/job/124905175903), on `mobile-http2` (#8897): `testNewBridgeRetriesRecordedCleanupWithoutRestrictingAccess` timed out its "retry failure stays allowed" wait, getting nil instead of `interaction_cleanup_retry_failed`. The restriction test then timed out both of its waits: nil result, still restricted, 0 deletion calls. ### Diagnosis Every reply from `BuzzPushSnapshotBridge` goes through a private serial queue, previously created at `.utility` QoS, and then `DispatchQueue.main`. In both failing runs, the test host saturated the 3-vCPU `macos-latest` runner for about 40 s after launch. The test host is the full Flutter app, loaded as `TEST_HOST`. - `BrowserAuthBridgeTests.testAllowsOnlyBuilderlabLoginPages`, a synchronous main-thread URL-parsing test, took 2.65 s instead of 0.013 s. - `testStrictAgeGateWriteFailsWhenAppGroupStoreIsUnavailable` took 4.9 s. That test has since been deleted from `main`. - A trivial hop took 1.3 s, then more than 10 s, during that window. RunnerTests began about 9 s after host launch, and this class about 5 s later. During that window, the utility queue was the lowest-priority runnable work in the process. It got no CPU for 10–20 s, while main-thread work kept moving slowly. In both passing re-runs, every test in this class took under 0.3 s. The test logic doesn't explain a callback that never arrives: - The deletion mocks are synchronous. - The deadline scheduler is a no-op. - Each test uses its own temp directory and bridge. - No state is shared between tests. That rules out both a state leak and an expectation-ordering race. This diagnosis is inferred from CI timings. The starvation did not reproduce on a 16-core local host (see Testing). ### Tried and dropped - **A class `setUp` settle barrier ( |
||
|
|
46cdc52d23 |
test(mobile): lock in invite-page and bare-host community link entry (#9047)
Both mobile community-link fields (Add community and the welcome/onboarding "Enter a community link" page) already accept a typed or pasted `https://<host>/invite/<code>` link and keep the code. They also read a bare host as `wss://`. This PR adds regression coverage for both. **It doesn't change any production code.** Companion: block/buzz-app#2412 fixes the same two inputs in buzz-app's Join a community. ## What's covered - **`community_link_input_test.dart`** - The address loop is now an input → relay table, with two new rows: `secondary.example` and `relay.example.com:8443` (the port is kept). - The invite case now uses `https://primary.example/invite/fixture`, and expects relay `wss://primary.example` with code `fixture`. - Two new rejected inputs: `ws://secondary.example`, and the invite path with a trailing slash, which the relay doesn't serve. - **`onboarding_entry_routes_test.dart`**: before the bare-host step, the journey pastes the invite link into the Add route's field. It expects the app-level `InviteJoinSheet` to show host `primary.example` and code `fixture`. ## Shown to catch a regression I removed the invite/add-community short-circuit in `parseCommunityLinkInput`. Both the invite unit case and the new journey step then failed. ## Not in this PR Two parser differences are left for a separate decision, along with the tests that depend on them: - `buzz://connect?relay=…`: mobile rejects it in the fields and silently drops it as an OS link, but buzz-app, desktop and the web repo browser's Connect button all use it. - An invite-page link with a query string: mobile accepts it and throws the query away, but buzz-app rejects it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
cec918ae7a |
perf(mobile): show a static placeholder for posterless videos (#9138)
Video messages without a NIP-71 poster (older events, or uploads where poster generation failed) used to start a network `VideoPlayerController` for each bubble, seek to 100 ms and show that paused frame. Nothing cached the frame, and the decoder was disposed whenever the bubble left the tree, so every scroll back opened a new stream and decoded again. Those bubbles now show the existing static "Video attachment" placeholder with the play button. Nothing loads until the user taps, and the full-screen viewer behaves as before. Videos that have a poster are unchanged. ### Validation - Full mobile Flutter test suite passes, and `flutter analyze` is clean. - The change is almost pure deletion: the frame loader, its provider and test seam, and the tests that covered it are removed. - Limitation: no simulator run. A cloned golden sim built from this branch signed in to the internal community, but its channel list never finished loading, so I couldn't open a channel with a posterless video. 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> |
||
|
|
7ac16b2917 |
perf(mobile): memoize npub labels (#9137)
Opening a big channel on mobile stalls while member labels resolve. On
an iPhone 12 mini (wired Profile build of main
|
||
|
|
1ce831645c |
mobile: stop re-parsing messages on every resume (#9140)
Resuming the app with a busy channel open stalled for several frames (149, 136, 109 and 102 ms on an iPhone 12 mini), because every visible message re-parsed its markdown, repeatedly. The app widget rebuilds on every lifecycle change, and each rebuild created new light and dark `ThemeData`. A new `ThemeData` never equals the previous one, so `MaterialApp` animated between two identical themes for its full theme transition. Each frame of that animation produced a new markdown theme, and every mounted message re-parsed its markdown in response (`_MdWidgetState.didChangeDependencies` and `didUpdateWidget`). In a 15 s resume on a large channel that re-parse took 373 ms of main-isolate time. The themes are now built only when the community accent changes, so a lifecycle rebuild keeps the same theme objects and starts no transition. ### Validation - Added test: `an app rebuild keeps its themes and markdown theme` rebuilds the app and checks that the theme and the markdown theme stay identical. It fails without the change. - Full mobile suite: only the known failures that also fail on main (notification toggle, push bootstrap, push bridge, push lease revocation outbox). - Automated simulator run (iPhone 17 Pro, debug build): opened a channel with markdown messages, backgrounded and resumed the app several times, and switched between light and dark appearance while the app was open. Messages render, resume works, and the app follows appearance changes live. A channel's long-press action sheet keeps its light background after a switch to dark mode on both this build and main, so that is unrelated to this change. - Profile evidence (iPhone 12 mini, Profile build, before the change): the stalls and re-parse samples above came from a resume with a large channel open. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> |
||
|
|
1b9b307176 |
fix(mobile): renew expired assertions for native media and drop retired-session avatars (#9134)
Follow-up to [#9069 media auth](https://github.com/block/buzz/pull/9069), which merged at its approved head. This PR carries the fixes for the last review round on that PR. ### Changes - **Native media reads renew an expired assertion** (Carl's [recommended blocker](https://github.com/block/buzz/pull/9069#discussion_r4485183400)). Voice notes and posterless video previews hand headers to native code. They now use `freshNativeMediaHeadersProvider`, which awaits `HostedRelayAuth.headersForUrl` and so asks for a new assertion once the cached one has expired. Before, while the socket stayed up they sent none and the relay refused the read. If the renewal fails, the read falls back to cached headers and the relay decides. The voice-note player's `headers` callback is now `FutureOr`. Both the iOS authenticated download and the Android native load await it, then recheck their generation, so a cancel during a slow renewal never starts the stale load. - **Notification avatars drop a retired organization session** ([review thread](https://github.com/block/buzz/pull/9069#discussion_r4485125609)). When an avatar download finishes after the organization session that issued its assertion was replaced, BuzzPushKit removes the cached response and refuses the avatar. - **Test:** cached reads never present a signed-out or replaced account's assertion ([optional note](https://github.com/block/buzz/pull/9069#discussion_r4485183412)). Not changed: native reaction-detail avatars still use the cached assertion. After it expires they fall back to initials. ### Validation - Each new test was checked to fail with the code it guards reverted: - `replacedOrganizationSessionRefusesAvatar` (BuzzPushKit): a session replaced mid-download must be refused. - `fresh native media headers renew an expired assertion`: covers renewal and the fallback when renewal fails. - `remote voice notes renew an expired relay assertion`: a journey through `VoiceNoteAttachment`; it fails when the attachment is switched back to the synchronous provider. - `Android defers remote auth until playback starts`: now cancels during a slow renewal and expects no native load. - `an issued assertion serves cached reads for the relay`: now signs out and restores a replacement account. It fails without the account check. - `swift test` (69 tests) passes. The full mobile suite (`just mobile-install mobile-check mobile-test`) passes with these commits on main as of [#9069 media auth](https://github.com/block/buzz/pull/9069). - The automated full-app simulator run from [#9069 media auth](https://github.com/block/buzz/pull/9069) covers the surrounding flows. This change is limited to the header source for voice notes and video previews, and none of the test communities enforce the hosted assertion, so a simulator run would not show the difference. No end-to-end run against a relay that enforces the assertion. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
97ede701c0 |
fix(mobile): send hosted relay assertion from native media and tombstone paths (#9069)
Follow-up to https://github.com/block/buzz/pull/9056, from an audit for other places that miss the hosted relay assertion. Hosted and organization relays require `Nostr-Federated-Identity` on every non-exempt HTTP request and WebSocket upgrade. The Dart `HostedRelayHttpClient` adds it, but anything that downloads or connects outside that client has to add it itself. The notification service extension and the iOS emoji picker both missed it, and these places do too: - **Voice notes** (`just_audio`, mainly Android) and **video preview first frames** fetch media natively with only the Blossom `Authorization` header. Both now use `nativeMediaHeadersProvider` from https://github.com/block/buzz/pull/9056. - **Push-lease tombstones**: removing a community, and the durable revocation outbox, open a one-off relay socket with no headers. A hosted relay refuses the upgrade, so the lease is never revoked and the outbox keeps retrying. Both now ask `HostedRelayAuth` for that community's own assertion, because the community being removed may not be the active session. - **Notification avatars** (iOS notification service extension): relay-hosted sender avatars were fetched with Blossom auth only. The loader now takes the same `BuzzRelayAssertionIssuer` the resolver uses for `/query`. Signed requests already refuse redirects. The iOS Me-chat agent picker also fetched avatars with no headers, but https://github.com/block/buzz/pull/9079 removed that native screen, so this PR no longer touches it. ### Validation - Added tests, each checked to fail when the code it guards is reverted: - A queued push-lease revocation and a community-removal tombstone both send `Nostr-Federated-Identity` on the WebSocket upgrade, as background requests. Runs against a local relay socket that handles NIP-42 auth. - A notification avatar on an organization relay is downloaded with the issued assertion (BuzzPushKit). - Issuing for a second community keeps the first one's cached assertion. A background request records no failure, never backs up the key, and its failure never reaches the active community's reconnect waiting on it. - Background issuance never binds an unbound account to its key, bypasses the shared assertion cache (each attempt gets a fresh assertion, and foreground use never inherits one that skipped the key backup), and its failure is never handed to a foreground caller waiting on it. When it can't get an assertion it sends none, so a non-enforcing relay still accepts the tombstone. - A notification avatar whose organization session is replaced mid-download is refused and dropped from the cache (BuzzPushKit). - Voice notes and posterless video previews renew an expired assertion before the native fetch, and fall back to cached headers if renewal fails. A voice-note attachment journey binds the production provider. - Cached reads never present a signed-out or replaced account's assertion. - `flutter analyze` is clean. `swift test` passes for the BuzzPushKit avatar loader tests, and `flutter build ios --simulator --debug` succeeds. - Full mobile suite (`just mobile-check` and `just mobile-test`) passes at 2c84940832. - Automated full-app run on an iOS simulator (debug build of this PR before it was rebuilt on main, signed in to the internal Builderlab community with two more communities saved). Passed: launch and channel list, image attachments and avatars in a channel, New chat sheet kept open for about 80 seconds before the agent list was opened (all agent avatars loaded, so headers are requested per download), dismiss and reopen, background and resume, switching to a second community (its icon and avatars loaded), and removing a community. The native New chat sheet is unreachable in the normal flow on this branch (a new chat opens the Flutter draft page; the native sheet only opens when resuming an unfinished chat), so that check ran with a temporary local bypass that was reverted. Limits: none of these communities requires the hosted relay assertion in this build, so the run shows the Swift-to-Dart header bridge, the per-relay cache and the tombstone path cause no regression, not that the assertion is sent or accepted. No voice note, video preview or deleted message was reachable. Screenshots are omitted because they show internal channel content. - Limitations: no end-to-end run against a hosted relay. Simulator sign-in to a hosted relay needs SSO, so a hosted-relay run needs a device build. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> |
||
|
|
6793cbd25b |
fix(mobile): focus the search field when search opens (#9120)
Opening search from Home now focuses the search field, so the keyboard comes up right away and you can start typing without tapping the field first. This applies when `SearchPage` opens as its own route; the embedded variant is unchanged. Closing with X and reopening works as before (the previous query is kept and the field is focused again). ### Validation - Extended the existing open-and-close journey in `search_page_test.dart`: it now opens search without tapping the field and asserts the field has focus and the keyboard is shown. The step fails with the production change reverted. - Full mobile test suite and checks passed in the pre-push hook. - Automated run on an iOS 27 simulator (iPhone 17 Pro) signed in to a real community: opened search from Home (keyboard up, caret in the field), typed a query and got channel and message results, closed with X, reopened (keyboard up again). The recording contains internal community content, so it was shared internally rather than here. - Human testing waived under the author's standing exception; the automated simulator run above covers it. 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> |
||
|
|
9aa70124ba |
perf(mobile): validate each verified profile once, not on every publish (#9101)
Opening or resuming a big channel spent about 210 ms of UI-thread CPU validating verified profiles, measured in Dart CPU samples on an iPhone 12 mini (Profile build) across two separate channel-resume captures (213 ms and 211 ms in `VerifiedProfilesNotifier._snapshot`, nearly all of it in `parseVerifiedProfile`). Every publish rebuilt the profile map by validating every known head again, and a channel with many members publishes once per subscription batch as each lands, so the work grew with members times batches. Each subject's parse is now kept while its head and the authority stand. Only `checked` changes between publishes (rebatching unchecks and rechecks subjects), so it is applied to the kept parse without validating again. ### Validation - Added `a later batch reuses profiles already parsed`: after a second subject rebatches the first, each head is validated exactly once. It fails when the kept parse is ignored. - `flutter analyze` is clean and the full mobile suite passes. - Same phone and scenario after this change: `parseVerifiedProfile` no longer appears in the resume CPU samples, and main-isolate message handling over the 15 s after resume dropped from 604 ms to 491 ms (one run each). Frame build and layout time on resume is unchanged; that is separate work. - Automated app run on the same phone with this change: Home, search, the large channel and its resume after the reconnect grace period. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> |
||
|
|
c848b6dcad |
fix(mobile): offer a new identity beside saved keys on unclaimed accounts (#9107)
## Summary This fixes a dead end in mobile Builderlab sign-in. A new account (unbound, with no backups) on a device that already had saved identities could only pick one of those identities. Any saved key forced the picker, and creating a key required the device to have none. If the service then definitively rejected binding the chosen key, the app showed "Let's try that again" with no way forward: the chosen key stayed in the account's slot, so every retry and every launch re-sent the same rejected bind. For an unclaimed account, the picker now also offers **Create a new identity**: - Creation writes only this account's empty identity slot, so every saved and device key is kept. - Creation is offered only when every current device key was among the choices shown, so a new identity never silently passes over a key the user didn't see. - Bound accounts, accounts with backups, pairing and pending import never offer it. When binding a chosen key is definitively rejected, the blocked screen offers **Choose a different identity**: - It appears only for a 400, 404, 409 or 422 from `/verify` in the current attempt, for the current account and pubkey, and never during pairing, whose transferred key is the only choice. Evidence from an earlier attempt is cleared before each bind. A challenge rejection, a 403, a 503 or any other ambiguous failure offers nothing. - Before releasing the slot, it rechecks the account: still unbound (`/current` and status), no backups, the same subject, and a slot that still holds that pubkey without a receipt. - It releases the slot only when another durable copy of the same key exists (the device inventory or another account slot), then verifies the slot is empty. No key is deleted: only the account's pointer to the rejected choice is cleared. A failed or refused release keeps the slot and the choice, so it can be retried in place or after a restart. - After release, the picker runs again and offers the saved keys plus creation. The recovery code lives in `hosted_flow_identity_recovery.dart`, a part of `hosted_flow.dart`, to stay within the mobile file-size limit. ### Related issue None found. ### Testing **Physical iPhone, a signed release build of `1fb633d55` installed over the existing app (keychain and saved identities kept):** a saved identity was rejected, then Choose a different identity, the picker, Create a new identity and Join your community. Joining then completed. This head differs from `1fb633d55` only in the guards for leaving, pairing and a failed or refused release listed below, which this journey does not reach, and in no UI code. The status of that phone's original rejection was not captured, so it is not confirmed to be one of the rejections above. The 409 case is covered by tests. **Tests (red on `main`, green here):** - Identity service and store: creation beside offered keys; refusal for unseen or partly offered device keys; release when another copy exists; refusal for a sole copy, a binding, a backup, a receipt, another account or an unusable recheck; failed delete and retry; a cancelled attempt. - Onboarding journeys: create beside saved keys; select, rejected bind, restart, choose a different identity, create and bind; ambiguous 503 and 403 failures; challenge rejection and stale evidence. - Release boundaries: an account switch at `/current`, at status, or between the recheck and the delete; cancelling between the recheck and the delete; leaving the picker before a late creation; an unreadable device inventory; leaving while a release is in flight; a failed delete retried in place and after a restart; a release refused because the inventory is unreadable, then retried in place; a rejected paired key. Each keeps every key, leaves the new account's slot untouched and leaves a cancelled flow idle, and a retry converges. - Mutating the creation guard, the generation check, the binding recheck, the store's receipt check, the account and cancellation checks before the delete, or the device-inventory error handling fails a test. #### Screenshots Physical iPhone at `1fb633d55` (frames from a screen recording; saved public keys redacted): | Rejected bind | After Choose a different identity | After Create a new identity | | --- | --- | --- | |  |  |  | Widget-test renders of the same screens (the real `HostedFlow` against a mocked account service, with a fixture key; the square at the top left is the test renderer's placeholder for an asset): | Picker on an unclaimed account | Rejected bind | | --- | --- | |  |  | 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: John Tennant <jtennant@squareup.com> Co-authored-by: Xander <ff30c859b96bd5233ffaf5d071200458f1274bd0dab03589214cf1055198491d@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
dce37f74d7 |
fix(mobile): skip the read-state snapshot when nothing needs publishing (#9093)
Every mobile resume re-syncs read state and then schedules a read-state publish, whether or not anything changed. Each publish starts by downloading the full read-state snapshot (about 1 MB compressed on a heavy account) and only then finds nothing to send. Together with the re-sync's own snapshot, that is two full downloads per resume, and a third when a publish from before backgrounding retries. The publish now checks first, using the same packing it would publish, and returns before the snapshot when nothing is pending and no slot differs from what was last published. When something did change it behaves exactly as before, snapshot included. Today the snapshot fails before downloading on gzip-serving relays, so this only pays off once [#9089 read-state gzip fix](https://github.com/block/buzz/pull/9089) lands; without this PR, [#9089 read-state gzip fix](https://github.com/block/buzz/pull/9089) roughly doubles resume download on heavy accounts. It has no merge-order dependency. ### Validation - Added test: `a publish with nothing new skips the snapshot`. It publishes a change (one snapshot), publishes again with nothing new (no snapshot, no event), then publishes another change (snapshot and an event carrying both marks). It fails without the early return. - Existing read-state suite passes. - iPhone 12 mini, Profile build, Home backgrounded 12 s and resumed, all socket bytes received in the 15 s after resume: | Build | Download per resume | Snapshot fetches | | --- | --- | --- | | main (snapshot broken) | 3.07 MB | 0 completed | | [#9089 read-state gzip fix](https://github.com/block/buzz/pull/9089) | 6.85 MB | 3 | | [#9089 read-state gzip fix](https://github.com/block/buzz/pull/9089) + [#9090 no read-state replay](https://github.com/block/buzz/pull/9090) | 6.08 MB | 3 | | [#9089 read-state gzip fix](https://github.com/block/buzz/pull/9089) + [#9090 no read-state replay](https://github.com/block/buzz/pull/9090) + this | 2.71 MB | 1 | These are single runs on one account with 28 read-state slots, so treat the numbers as indicative. 🤖 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> |
||
|
|
cf8fbbba55 |
fix(mobile): keep verified names on screen in channels with over 1,024 members (#9111)
In a channel with more members than the verified-name interest limit (1,024), returning to the app made member names flip: a verified name changed to a profile name with a collision suffix (for example "Ankit · ykn9") or to an npub, then back once a refetch landed. Seen on an iPhone 12 mini in a 2,004-member channel; instrumented logging showed the verified-name map dropping from 956 entries to 0 on opening that channel. Tracking a large roster evicts the oldest-tracked subjects, and eviction dropped their records too. On resume, member and profile preloads track the evicted people again, which evicts the ones on screen, so their verified names disappear until the next fetch. Evicted records now stay as unchecked cache (up to 4,096, oldest untracked dropped first) and are rechecked when tracked again. Only tracked subjects accept new records, so the cache never grows from relay traffic. ### Validation - Added `a subject evicted by a large roster keeps its last known name`: it fails when eviction drops the record. - `flutter analyze` is clean. The full mobile suite passes except 10 push and notification-toggle tests that fail identically on unchanged main on this machine. - Same phone, 2,004-member channel, after this change: the verified-name map held steady (978 entries, growing to 1,828) through opening the channel and two resumes after 12 s and 20 s in the background, and the "ankit, lhoward" and "sfoo, Dani" rows never changed in the screen recording. Before, both flipped (to "Ankit · ykn9" and an npub) and back after each resume. - Automated app run on the same phone with this change: Home, search, the large channel, and its resume after the reconnect grace period. This touches the same `_snapshot` line as [#9101](https://github.com/block/buzz/pull/9101), so whichever lands second needs a one-line rebase. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> |
||
|
|
244038b495 |
mobile: reuse channel message rows that did not change (#9115)
Opening the keyboard in a busy channel janked: the composer's bottom inset changes on every keyboard animation frame, and each change rebuilt the message list, which rebuilt every visible message row. On an iPhone 12 mini that came to 8 frames over 16 ms per keyboard open, with 290 to 360 ms spent in build. The same cascade runs on every page rebuild, such as the dozen or so while a large channel settles after returning to the app. Flutter skips a rebuild only when it gets the identical widget object back, so the message list now keeps each row it built and returns it while nothing the row is built from has changed. Shared inputs (the message list, membership, channel names, send animations, the focus callback, today's date and so on) clear the cache when they change. Each row also compares its neighbors, thread summary, typing and working state, and send-animation and handoff flags. Rows still rebuild through their own provider watches. The composer's focus callback is now stable so the page's rebuilds don't clear the cache. ### Validation - Added `a growing composer reuses the message rows on screen`: it types a four-line message and checks that the visible rows are the same widget objects. It fails without the cache. - `flutter analyze` is clean. The full mobile suite passes except the 10 push and notification-toggle tests that fail identically on unchanged main on this machine. - iPhone 12 mini, Profile build, a 2,000-member channel, one matched run each before and after (the after column is an earlier revision of this change with the same mechanism): | | Before | After | | --- | --- | --- | | Keyboard open, build time | 359 ms, 290 ms | 64 ms, 43 ms | | Keyboard open, frames over 16 ms | 8, 4 | 1, 0 | | Return to the app, build time | 953 ms | 881 ms | | Return to the app, frames over 16 ms | 21 | 18 | Most of what remains on return comes from rows' own watches (for example the agent display-name map is a new object on every agent directory reload), which this change doesn't address. - Automated app run on the same phone with this exact branch: Home, opening the large channel, opening the keyboard twice (58 and 69 ms of build, one frame over 16 ms each), scrolling back through several days of history, and returning to the app after 12 s in the background. Day dividers, reply counts, reactions and system rows rendered correctly throughout. Typing is covered by the widget test rather than the device run, to avoid drafting in a real channel. - Simulator QA of this branch (Debug build, a private test channel): typing a four-line message, sending, scrolling to the top and back, a reaction, an edit, a thread reply, a delete, sending again, and backgrounding and returning. Every row rendered and updated correctly, with no replayed send animations. The jump-to-latest button didn't respond to synthetic simulator taps before or after resume; it's a native iOS view this change doesn't touch. https://github.com/user-attachments/assets/04909261-81fe-4514-98aa-8d7fdfdcace5 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> |
||
|
|
3ff4154ca6 |
fix(mobile): back off push bootstrap retries (#9092)
When push setup fails, the app retried every 5 seconds forever. On an iPhone 12 mini whose push gateway enrollment is refused (a CDN 403 on the installation challenge), each retry refetched the relay's NIP-11 document over a new TLS connection and logged the whole HTML refusal. That is about 720 attempts and roughly 14 MB of traffic an hour for as long as the app stays open, with no way to succeed. Push registration, lease publication and tombstone retries now back off: each consecutive failure of the same attempt doubles the delay from 5 seconds up to 15 minutes, with jitter between half and all of it so devices that failed together don't retry together. A success (including a renewal), a different attempt, or a channel-grammar retry (which keeps its 5-minute delay) ends the streak. The first retry still comes within 5 seconds, so a brief blip recovers as quickly as before. ### Validation - Added tests: `repeated failures back off exponentially up to a cap` checks every delay's bounds at both jitter extremes over 12 failures, `a success ends the failure streak`, and `a different attempt starts its own backoff`. The first fails when the gate goes back to a fixed delay. - Existing push suite passes. - On the 12 mini before this change: `Push lease bootstrap failed` with the 403 page and a fresh NIP-11 fetch every 5 seconds for the whole session. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> |
||
|
|
e34acb0ac5 |
fix(mobile): keep reply counts on screen while a reconnect catches up (#9100)
Every time the app comes back from the background, the "N replies" links under messages in an open channel vanish and reappear a moment later. On an iPhone 12 mini in a busy channel this happened on nearly every resume, since the session drops its socket five seconds into the background and the channel reconnects. A reconnect discards the channel window and refetches its newest page. Until that page lands, thread summaries came only from replies already loaded, which in a windowed channel is usually none. The discarded window's summaries now stand, below every fresher source (live summaries, thread queries, overflow floors), until the replacement history lands, which then decides, including dropping a summary the relay no longer reports. ### Validation - Added `reply counts stay on screen while a reconnect catches up`, which fails when the retained summaries are left out. - `flutter analyze` is clean and the full mobile suite passes. - iPhone 12 mini, Profile build, a 2,000-member channel backgrounded 12 s and resumed (same scenario, one run each). Before: the "6 replies" link vanished for about a second and the timeline shifted up, then back. After: the link and the scroll position stay put through the catch-up. - Automated app run on the same phone with this change: Home, search, the large channel and its resume after the reconnect grace period. ### Accepted gap If you had scrolled back past the first page, those older rows still lose their reply counts once the reconnect catch-up finishes, as they do on main today. Their counts return when pagination refetches their page or the channel is reopened. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> |
||
|
|
5e36d65c9e |
fix(mobile): inflate gzip read-state snapshot responses (#9089)
On relays behind a CDN that gzips responses (the Builderlab relay does), mobile read state never synced. Every read-state snapshot fetch, and therefore every read-mark publish, failed with `FormatException: Unexpected extension byte`, so read marks made on the phone never reached other devices and each reconnect retried and failed again. The relay query transport deliberately leaves gzip bodies compressed so they can be inflated off the UI isolate (see `RelayHttpQueryClient`), but the snapshot path decoded the raw bytes with `utf8.decode`. It now uses `relayResponseText`, like the other query callers, for both the discovery and the snapshot responses. Heads-up for reviewers: with the snapshot working, a resume also pays for the full snapshot download on top of the existing read-state replay on the live subscription. A follow-up PR removes that replay. ### Validation - Added test: `inflates gzip discovery and snapshot bodies` serves both responses gzipped. Without the fix it fails with the same `Unexpected extension byte` error seen on the device. - Full mobile suite: all pass except the known stale `fullscreen_onboarding_test` case, which fails on main too. - iPhone 12 mini (Profile build, Builderlab community): before, every resume logged `snapshot failed` and `publish failed (journal retained)`. After, the snapshot on resume and the snapshot inside a publish both completed with no errors. - Automated app run on the same phone with this change: Home backgrounded past the reconnect grace period and resumed, then search and a large channel opened. No read-state errors logged. Read marks were not compared across devices. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> |
||
|
|
6b8a72e835 |
fix(mobile): send hosted relay auth with iOS custom emoji downloads (#9056)
On iOS, custom emoji in the native emoji picker often render as the sparkle fallback icon instead of the image. The native picker downloads custom emoji with `URLSession` and asks Flutter for request headers over the `mediaHeaders` channel call. That call returned only the media read `Authorization` header. Hosted relays also require the `Nostr-Federated-Identity` assertion, which the Flutter image path gets from `HostedRelayHttpClient`, so relay-hosted emoji get a 401 and the picker falls back to sparkles. Emoji hosted elsewhere still load, which is why only some are missing. The `mediaHeaders` bridge now also includes the hosted relay assertion via a new `HostedRelayAuth.headersForUrl`, scoped to the relay's own origin like `HostedRelayHttpClient`. The native loader refuses redirects for requests that carry the assertion, matching the Dart client, so the token can't be forwarded to another host. The iOS reactions sheet had the same gap: it passes avatar and custom emoji URLs to native views with only the `Authorization` header. A new `nativeMediaHeadersProvider` combines both headers for any native media consumer, and the reactions sheet uses it. ### Validation - Added tests: the native picker's `mediaHeaders` call returns the relay assertion for relay media and no headers for an external emoji URL; `headersForUrl` returns the assertion only for the relay origin (not another host, subdomain or port). The reactions sheet passes the combined headers for avatars and custom emoji, and `nativeMediaHeadersProvider` merges both headers only for relay URLs. - Focused existing validation: the emoji picker and organization relay auth suites pass, and `flutter analyze` is clean. In the full mobile suite, the only failures are 10 in `notification_toggle_test`, `push_bootstrap_test` and `push_bridge_test`, and they fail the same way on `main`. - Automated full-app simulator run (iPhone 17 Pro, iOS 27, debug build of this branch with hosted auth enabled, signed in to two hosted communities through a cloned simulator keychain, driven with AXe). Covered: launch signed in, channel and DM lists, opening a channel and a DM, the native emoji picker (its Custom section shows the emoji as an image, not the sparkle fallback), sending a custom emoji message and seeing it render, reacting with the custom emoji (shown in the quick-reaction row and as a reaction pill), opening a thread, switching communities, avatars and community icons, background and resume, and a cold relaunch. Result: no crashes or regressions seen. Screenshots are omitted because they show real account and community names. - Limitations: this run does not reproduce the original bug. The hosted community's custom emoji also loaded in a temporary build with the relay assertion removed from the `mediaHeaders` bridge, so its emoji URL evidently doesn't need the assertion, and the run doesn't prove the fix. The reactions detail sheet could not be reached because simulated long presses on the pill opened the message menu, and inline image attachments were not exercised. A device build against a community whose emoji are relay-hosted is still needed to confirm the fix. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> |
||
|
|
920b1e64bd |
perf(mobile): keep covered pages still while the keyboard moves (#8941)
## Summary A keyboard raised by a route above (the Add members sheet, search, a native picker) changes the app's `MediaQuery` on every animation frame. Covered pages that read `MediaQuery` directly or through a `Scaffold` rebuilt and relaid out all their content for keyboard motion they cannot show. On #4things, typing in Add members rebuilt every visible message of the channel page underneath for each keyboard frame. `CoveredRouteKeyboardInsets` holds a page's `viewInsets` and `padding` at their last values from when its route was current, while the route is covered. Every other `MediaQueryData` field passes through. A covered page still follows the keyboard down, so when a covering route takes focus and the page's own keyboard closes, the page lowers behind the scrim. A size change (rotation) drops the held values so safe-area padding stays right. When the route becomes current again with the keyboard still above the held value, the page keeps the held values rather than jumping up and riding the keyboard back down. It switches to live values once the keyboard reaches the held value, or as soon as focus is inside the route (the page raised its own keyboard). Otherwise the live values return. It always returns the same widget shape, so covering a page never remounts it. Home, search, the channel page and channel details use it. ## Measurements iPhone 12 mini, Profile build, Builderlab #4things (1,361 members). Flow: open the channel, open its details, tap Add members, tap the search field, then type "chris" one character per second. The window is the 10 s of typing, keyboard included. Both builds also contain the fixes from [#8928](https://github.com/block/buzz/pull/8928), [#8929](https://github.com/block/buzz/pull/8929) and [#8931](https://github.com/block/buzz/pull/8931). | | Before | After | | --- | --- | --- | | UI frame time, total | 3,344 ms | 383 ms | | Frames over 16.7 ms | 101 of 232 | 2 of 297 | | Median / p90 frame | 14.1 / 26.1 ms | 0.6 / 2.0 ms | | UI isolate CPU samples (1 ms) | 2,793 | 868 | | Samples in `MessageContent.build` | 621 | 0 | Single runs per side, not repeated. ## Testing - New widget tests: a covered page doesn't rebuild for keyboard frames and gets the live insets back when it's current again. A current page follows the keyboard. Covering a page keeps its state. A page uncovered with the keyboard still up does not jump, and follows the keyboard once it reaches the held value. A page whose own field takes focus after being uncovered follows the keyboard immediately. A covered page follows its own keyboard closing and takes new padding when rotated. The last test fails (3 mounts instead of 1) against a version that returned a different widget tree when covered. - `just mobile-check mobile-test` passes. - On the phone: Add members search, typing and results behave as before. The channel page and search still move above the keyboard when they're current. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost> |