mirror of
https://github.com/block/buzz.git
synced 2026-11-10 19:32:00 +00:00
c502e53e634bbd4945fc0dcd49e104dc57acf470
386 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c502e53e63 |
feat(mobile): tag the reacted-to message author on reactions (#9141)
## Why NIP-25 says a reaction should carry a `p` tag naming the author of the reacted-to event. buzz-app's Activity → Reactions tab (block/buzz-app#2638, merged) finds reactions to your messages with a `#p` read. Reactions sent from mobile have no `p` tag, so they never appear there. block/buzz#9121 makes the same change for `buzz reactions add`. ## What - Every reaction sent from mobile now carries `["p", <message author>]`. This covers the reaction pill, the quick-reaction row, the emoji picker, and reactions on system rows. - Removing a reaction is unchanged. - Agent status reactions (buzz-acp) and the desktop app are unchanged by decision. Agent status reactions stay out of Activity. ## Testing - New unit test: `ChannelActions.addReaction` publishes exactly `["e", target]` and `["p", author]`. It fails without the change (actual tags: `[['e', 'target-event']]`). - The message-actions widget test now checks that a quick reaction passes the message's author. - `just mobile-check`: format and `flutter analyze` are clean (no issues). - `just mobile-test`: 4,507 passed, plus the push-unconfigured and secp256k1 suites. All pass. - No screenshots: the change is one tag on the published event, and nothing on screen changes. - Not yet: a run on a device, and the human test. ## How a human can test 1. On mobile, as user B, react 👍 to a message from user A. 2. As user A, open Activity → Reactions in a current buzz-app build. 3. It works if a "B reacted 👍" row appears. Before this change, no row appears. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Matthew Boston <mboston@squareup.com> Co-authored-by: mboston Implementer <932d3b6ebea6cc83e091551ae46d1c7e7f8f7d0fa8e9cef5e6facc8544af413b@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
5f59940544 |
fix(mobile): steady New DM picker rows and animate picks (#9249)
On iOS, the New DM (and Add Members) picker is a native UIKit sheet that Flutter drives by pushing the picker's whole state on every rebuild. Each push called `reloadData`, which rebuilt every visible row in a fresh, unreused cell showing the initials fallback, then fetched each avatar again over the platform channel. So whenever anything refreshed (several refreshes land right after foregrounding), every avatar blinked to its initial for a frame or two. Picks also jumped instantly from the results to the chosen section. Now the picker: - ignores a pushed state identical to the one it shows; - applies the rest as a diffable snapshot, so unchanged rows keep their cells and changed rows are redrawn in place, in reused cells; - keeps loaded avatars by avatar key for the life of the sheet, so a redrawn row shows its avatar at once; - animates a member's row moving between the chosen and result sections when it's picked or removed (no animation with Reduce Motion). Flutter's toggle handlers in both pickers now read the current selection rather than the one captured at build time. A fast second tap could otherwise add the same person twice, which the diffed table would reject. ### Validation - Native member picker test (`scripts/test-mobile-member-picker.sh`), extended: after an avatar loads, a changed row is redrawn with that avatar and requests nothing more. The avatar check fails on `main` and passes here; the existing identity and accessibility checks still pass with the diffable table. - Added a New DM test: two toggles before Flutter rebuilds never list a person twice. It fails without the toggle fix. - Full mobile Flutter suite and analyzer pass in CI on the current head, which includes `main`'s fix for the right-drag link tests. - Simulator runs of the app (iPhone 17 Pro, iOS 27, signed in to a test community): opened New DM, backgrounded and foregrounded the app, searched, picked from the list and from a search, and removed a pick. Without this change, every avatar flashed to its initial after a foreground. With it, no avatar flashed at any point, and picks and removals animate between sections. Recordings show real community members, so they're shared in Buzz rather than here. #### Full-app comparison, `main` vs this PR The same scripted New DM sequence on a clone of the same signed-in simulator (iPhone 17 Pro, iOS 27), first on `main` ( |
||
|
|
52cb6818fa |
perf(mobile): keep remote images on disk and show small ones as thumbnails (#9158)
## Summary The mobile app now saves remote images on disk. After a relaunch, avatars, custom emoji, message attachments and agent artwork show immediately. The app does not download them again. Small images on a relay now load only the relay's thumbnail. The app never downloads the full-size upload to show a small image. ## Why Before this change, the app kept remote images only in memory. Each cold launch downloaded every image again. On a slow connection, attachments showed a "Loading image" placeholder for a long time. Also, when a relay had no thumbnail for an image, the app downloaded the full-size upload. That upload can be up to 20 MiB, for an avatar that is a few dozen points wide. ## What changes ### Images on disk - `MediaImageProvider` reads and writes a `MediaDiskCache`. Each account in each community has its own cache, at `<root>/communities/<community id>/media/<signer pubkey>`. The cache is built on `flutter_cache_manager`, with a JSON index. - On iOS, the root is the app group's `Library/Caches` (from a new `buzz/app_group` method channel). App extensions can then read the same cache. On other platforms, the root is the app cache directory. - Each cache holds a maximum of 250 MB. When it is full, the least recently used images go first. `MediaCacheIndex` measures each file when it writes it, because `flutter_cache_manager` limits only the number of files. Every disk read marks the file used, and every write schedules eviction, including a refresh that replaces bytes the package already holds in memory (by default the package does neither for those). - The cache does not save an image that is larger than 5 MiB. The app still shows that image from memory. Thus, one large image cannot push out all the others. - Relay media URLs contain a content hash, so their bytes never change. The cache keeps them until it evicts them. For other URLs, the cache shows the saved image, and then gets a new copy in the background after one week. A maximum of two of these refreshes run at the same time. The app decodes a refreshed image before it replaces the saved copy. - When you remove a community, the app deletes its cache. When a community gets a new signing key, the app deletes the media of the old account. At launch, the app also deletes caches of communities and accounts that are no longer present. - Startup does not wait for the cache. The cache directory resolves in the background. Image reads wait for it for a maximum of 3 seconds. ### Thumbnails for small images - A small relay image loads the relay's WebP thumbnail. If the relay has no WebP thumbnail, the image loads the JPEG thumbnail. If the relay has neither, the widget shows its placeholder (initials for an avatar, an empty tile for artwork). - Native avatars drawn by UIKit (the navigation bar, the community icon on the create button, and the New DM and Add Members pickers) use the same choice, through a new `smallMediaImageProviders`. They try the same thumbnails, fall back the same way, and show initials when neither thumbnail exists. Before, they downloaded the full-size upload and only decoded it small. - Larger layouts and the full-screen viewer load the full-size upload, as before. ### Agent artwork - Preset agent artwork (the tray, flight, video placeholder, avatar hero and the avatar editor's blurred backdrop) now loads through `MediaImage`, so it uses the disk cache too. - The create-agent and empty-bestie pages warm the same `MediaImageProvider` that their widgets read, through a new `mediaImageProviderFor(ref, url)`. - No code in `mobile/lib` uses `Image.network` or `NetworkImage` now. ## Demo A cold relaunch (the app is stopped, then started) into a synthetic "QA demo" Solo chat that holds a few generated images. Shown at half speed. Before: the attachment shows a "Loading image" placeholder while it downloads. https://github.com/user-attachments/assets/107f43a3-63d0-40c3-bd80-4ea9d7897a97 After: the attachment shows with the chat, from disk. https://github.com/user-attachments/assets/d3985bb2-838c-4efa-93cb-44cef51f677d ## Tests - `test/shared/relay/media_disk_cache_test.dart` follows one journey through the real provider and a real cache in a temporary directory. The first launch downloads and saves. An offline relaunch shows the full-size image, its thumbnail and an external image from disk, and the oversized image goes back to the network. Eight days later, only the external image refreshes. Removing the community deletes its directory. - A second test checks the order of eviction when the cache is full, that a file read from disk is evicted after files that were not, and that replacing a file's bytes after eviction last ran evicts again when the cache goes over budget. - `community_provider_test` checks that launch deletes orphan media directories, that a new signing key deletes only the old account's media, and that removing a community deletes its media. - `avatar_image_test` checks that a missing WebP thumbnail falls back to the JPEG thumbnail, and that an avatar with no thumbnail shows its initials. - `native_avatar_image_test` checks the same for native avatars: a WebP 404 paints the JPEG thumbnail, the full-size upload is never requested, a relay with no thumbnail paints initials and is not asked again, and any other failure stops at the initials. - `flutter analyze` and the full mobile test suite pass. - Automated app runs on an iPhone 17 Pro simulator (iOS 27): the channel list, a DM, Home, the Agents grid and an agent details sheet show their images. After a relaunch, the images came from disk and the app wrote no new files. In the "QA demo" chat, a new image from the app showed immediately. - Automated run of the native-thumbnail fix on an iPhone 17 Pro simulator (iOS 27): the native New DM picker shows photo avatars, and the cache gained only relay `.thumb.webp` entries for them, no full-size uploads. After a cold relaunch, the picker showed the same avatars within about 1.5 seconds, and the cache directory gained no files. The avatar editor backdrop was not exercised in the app. The test community has no remote agents, and the backdrop tests cover only emoji and picked-image selections. ## Before merge - [x] Automated simulator run of this head: avatars and agent artwork show their thumbnails, and a cold relaunch shows images from disk. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost> |
||
|
|
f38285562c |
fix(mobile): read only shown events from filtered Activity rows (#9215)
## Summary Follow-up to [#9213 Activity filters UI](https://github.com/block/buzz/pull/9213), from its [last Codex finding](https://github.com/block/buzz/pull/9213#discussion_r4497322772). With Threads, Mentions or a People/Agents filter active, an Activity row holds only part of its conversation. Marking it read advanced the `thread:<root>` (or channel) marker through the row's latest event, which also read replies the filter hid. For example, reading a person's reply in People also read an older agent reply in the same thread. Filtered rows now advance the synced thread or channel marker through every event that is read, stopping just before the first unread event the filter hides, and write local `msg:` marks only for shown events past that point. Once the hidden event is read too, the marker catches up and syncs both. This also fixes filtered DM rows from #9213, whose `msg:`-only reads never synced to other devices. Unfiltered rows are unchanged. ## Testing - New widget test: "reading a thread row under People leaves hidden agent replies unread". It fails without the fix, and checks the synced marker stops short of a hidden unread reply, then advances fully once nothing hidden is unread. - `flutter test test/features/activity/` (136 pass) and `flutter analyze` on the activity feature. ## Before merge - [x] Automated simulator run (iPhone 17 Pro sim, iOS 27, re-run on d17f79a; the catch-up change in |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
e558f15faa |
Mobile: one New chat screen (#9079)
Mobile had two New chat screens. [#8884](https://github.com/block/buzz/pull/8884) made the Flutter draft page (first send creates and names the chat) the New chat screen, but kept the older create form (a native iOS sheet, with a Flutter bottom sheet on Android) for one case: resuming a creation that an older build had saved with a chat name and no first message. Nothing creates that state anymore, so the old form only appeared for users with an interrupted creation from before that change. This removes the old form. Every New chat, including that resume case, now opens the draft page: - **New chat**: unchanged. - **Resuming an interrupted creation**: after the existing Resume / Discard setup / Later prompt, the draft page shows the saved chat with its agent and workspace locked and a Retry button, the same choices the old sheet offered when it locked its fields. A creation saved with a first message resumes exactly as before. One saved without a message finishes without one, because a saved creation can't change after its first relay write. The iOS editor's chat-only code (`presentChat`, `updateChatAssistants`, the agent list, and its avatar loader) goes too. The same editor still handles profile fields, channel details and workspace names. The native test and CI step that covered only the removed chat form are removed. ### Screenshots Resuming an interrupted creation saved without a first message: | Before: old native sheet (removed) | After: draft page (kept) | | --- | --- | | <img src="https://raw.githubusercontent.com/block/buzz/5d0a502e92fc3dfeffbe4fa95d63ba8f73d1e612/pr-9079--01-before-resume-native-sheet.png" width="300"> | <img src="https://raw.githubusercontent.com/block/buzz/5d0a502e92fc3dfeffbe4fa95d63ba8f73d1e612/pr-9079--02-after-resume-draft-page.png" width="300"> | A brand new chat already opened the draft page and is unchanged: <img src="https://raw.githubusercontent.com/block/buzz/5d0a502e92fc3dfeffbe4fa95d63ba8f73d1e612/pr-9079--03-new-chat-draft-page.png" width="300"> ### Validation - Changed tests: `me_page_test` checks that Resume and Discard setup both open the draft page and that no native sheet is requested. `solo_creation_tags_test` checks that recovering an interrupted named creation through the draft page's Retry continues with member setup, keeps the saved name and agent, and adds no first message. - Full mobile Flutter suite (4,431 passed) and `flutter analyze` (clean). iOS debug simulator build succeeds. - Automated simulator run (iPhone 17 Pro, iOS 27, signed-in account): New chat opened the draft page, the agent picker selected an agent, and the first send created the chat, which then appeared under Solo. New workspace still opened the native editor, enabled Create once named, and showed the Discard changes prompt on cancel. The recording isn't attached here because it shows internal account content. - Resuming an interrupted pre-[#8884](https://github.com/block/buzz/pull/8884) creation can't be produced by the current build. For the screenshots above, a capture-only, uncommitted patch to a `main` build seeded one; the branch build installed over it resumed it on the draft page. The widget tests cover finishing it. - `solo_creation_tags_test` also covers a conflicting pending Solo organization edit during that Retry: the draft offers the Conflicting Solo changes dialog, keeps the saved creation, and the next Retry finishes it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) buzz-review-completed --------- 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> |
||
|
|
237d794acb |
perf(mobile): skip re-decrypting read-state blobs (#8966)
## Summary Read-state blobs are NIP-44 encrypted, and the app decrypts them with pure-Dart ChaCha20 and HMAC-SHA256 on the UI isolate. Most of those decrypts repeat earlier work: - Every live subscription (each launch, resume and reconnect) replays all of the account's read-state slots, and `_handleIncomingEvent` decrypts each one again. The measured account has 27 slots of about 55 KB ciphertext each. - Before every publish, `_publishOnce` fetches the read-state snapshot and decrypts it, and decodes our own slots a second time to check they're readable. - The relay echoes each publish back, and our own blob is decrypted again. `ReadStateCrypto` now keeps a bounded memo from ciphertext to plaintext, one per conversation key. `decrypt` checks it first, and `encrypt` records the ciphertext it produced. NIP-44 payloads carry a random nonce and a MAC, so under one key the same ciphertext always decrypts to the same plaintext. Failed decrypts aren't cached and still throw every time. The memo is least recently used: a hit moves the entry to the newest end, so our own superseded blobs are evicted before live ones. It holds at most 8,000,000 code units across keys and values (about 8 MB, about 80 full slots). The first version used oldest-first eviction with a 2M bound, which saved nothing on the device: the 27 slots total about 2.6M, and a replay in slot order evicted each entry just before its next use. ## Measurements iPhone 12 mini, Profile/AOT, Tom's Block community. Each run resumes the app twice from the home screen and samples UI-isolate CPU for 15 s from each resume. | Build | `ReadStateCrypto.decrypt` per resume | | --- | --- | | `main` |
||
|
|
5d44038ea5 |
mobile: invite new users to create their first agent with a bold card (#9036)
## Summary
New users had no cue that the slot at the bottom of Home is where they
make an agent. A one-time, deliberately loud "Create your first agent"
card now sits above the empty Bestie slot and points at it.
- The card spans the screen above the bottom bar, with agent pictures
(the same ones the empty slot cycles through) scattered around its edges
and the message centered as plain text. It's allowed to cover part of
the chat list.
- It waits three seconds on Home, then pops up from the agent slot with
the popover entrance (a quick fade, rise and scale). Leaving Home
restarts the wait, so it never pops over another screen. With reduced
motion it appears without animation after the same wait.
- Tapping the card starts agent creation. The close button dismisses it
for good, as does a screen reader's dismiss action, activating the slot,
or ending up with a Bestie. Dismissal is remembered per account and
community. Other taps on Home go through to the page and leave the card
up.
- It shows when remote agents are on, the Bestie preference has loaded
with none set, and the account owns no agents yet. The last check keeps
existing users who never picked a Bestie from being told to create their
"first" agent.
- It only shows while Home is the current route, so sheets and pushed
screens hide it and it comes back afterwards if it was never dismissed.
The card is a new `BuzzCallout.card` variant of the reusable
`BuzzCallout` in the mobile design system (the plain tip variant stays
available). It uses the inverse surface in light mode and the raised
dark surface in dark mode (matching toasts), with an arrow, existing
radius, spacing and popover elevation tokens, and stays phone-sized on
wider screens. It grows to fit large text below its close button, and it
has live-region semantics, so screen readers announce it.
## Screenshots
These come from the widget test harness with the real agent pictures, at
small (320), standard (393) and landscape phone sizes, plus a tablet.
The harness can't render the native iOS glass bar, so these use the
Flutter bar; the card is the same on both platforms. Pictures look a
little soft because the harness renders at 1x and scales up. In
landscape and on tablets the card stops at large-phone width so the
pictures don't spread too thin.
| Standard, light | Standard, dark | Small, light |
| --- | --- | --- |
| 
| 
| 
|
| Landscape, light | Landscape, dark |
| --- | --- |
| 
| 
|
| Tablet, dark |
| --- |
| 
|
## Testing
- Launcher tests cover first show and placement, Home staying tappable
underneath without dismissing, the card tap starting creation, the close
button, slot activation without a touch, screen reader dismiss, a Bestie
appearing, no reshow after dismissal, a separate community getting its
own first run, staying hidden for people with agents or without remote
agents, hiding under a sheet, the three-second wait and entrance,
reduced motion, and leaving Home during the wait (each
mutation-checked).
- Component tests cover the tip beside an edge target (320 and 1024
wide, 3x text) and the card: full width at 320, capped on wide screens,
growing at 3x text so the message stays clear of the close button
(mutation-checked), and the close button staying its own screen reader
control.
- The full mobile suite passes locally with CI's dart-defines, and
pre-push mobile checks passed at
|
||
|
|
af54258f19 |
fix(mobile): streamline identity setup and community selection (#9070)
Selecting a saved community now connects only that destination, and **Find my communities** remains available through **Switcher → Add Community**. Failed destinations are named; completing nested Add/Find opens the selected destination without resuming the outer batch. Fresh hosted account setup now creates and backs up an identity automatically through email sign-in, invites, community links and community creation when the resolver confirms there are no account-bound, backed-up, saved or device identities. Welcome has one email sign-in entry. Authenticated import, existing-key recovery and selection retain their explicit paths; pending import survives restart and attaching a destination. Backup retry retains the same identity. Web binding retains explicit identity creation and its browser-return behavior. Self-hosted setup is separate and explicit. New-key creation still requires platform sealing support; passive account restoration never creates, binds or backs up an identity. **Account tradeoff:** setup cannot discover a key stored only elsewhere and never associated with this account. Such an account receives a new backed-up, bound identity automatically. Importing a different old key afterward fails with a binding mismatch; this flow does not undo that account binding. There is no separate pre-authentication import button. Pending import now survives retained community/web-bind link decoding: internal import metadata is stripped before strict public-link parsing. Once an identity is saved and resolved, the import request is retired before binding or backup, so restart resumes the existing key. Passive restoration also repairs stale import intents written by earlier builds without creating, binding or backing up a key. Validation (`f114c0e228`): - All 30 hosted onboarding journeys pass. Import coverage includes invite-to-community-link retention, restart before import, failed-backup restart, successful-backup restart, and an older stale import intent. - Three isolated production mutations (decode filtering, import completion persistence, passive stale-intent handling) each fail the relevant journey; restored control passes. Earlier auto-creation and nested-selection mutations also failed their relevant journeys. - Carl reviewed both P2 fixes and the final commit with no blockers. Carl and Pinky previously cleared the shared setup behavior and Android parity. - Wes confirmed Android sign-in and fresh account creation on the preceding build `be5690c684`. Android/iOS builds and earlier manual acceptance do not establish native acceptance of the new import/restart fixes. Installed devices remain on that preceding build. **Remaining acceptance:** current-head CI and native validation of the import/restart edge cases. Required publication hooks passed, including mobile analysis/tests, branch-skew and file-size checks. The existing iOS 16 sealing limitation remains: it cannot create a fresh hosted identity. No membership/ownership repair, denial-classification change or floating sign-in-control fix is included. --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> |
||
|
|
ec21753e1b |
Make mobile New DM and Add members pick recipients like mention autocomplete (#9065)
The mobile New DM picker listed the first 50 relay profiles alphabetically, mostly identities the viewer doesn't know. It ran its own debounced directory search with no cache, and its rows disappeared on every keystroke until the search returned. Mention autocomplete in the composer already solves the same problem well, so New DM and Add members now use its implementation. Add members was reusing the same New DM directory search. ### What changes - **Same candidates and order as mention autocomplete.** The chooser's candidate building, ranking, row stability, 200 ms debounced search and search cache now back both surfaces. New DM is treated like a stream with no members yet: the viewer's own agents and agents shared with them appear up front, people are found by name, and agents that couldn't answer the viewer are left out. Typing a name shows the same people in the same order as `@name` in a stream. - **Results stay on screen while a search runs.** A query searched before shows its people immediately, and a longer query starts from the shorter query's results. A failed search keeps its rows and adds a retry row below them. - **Presentation matches the mention rows.** Rows show the name, and agents show "agent · managed by …". Npub lines are gone. - **Sheet chrome.** The title is "New DM", like the menu item that opens it. Both pickers keep their grouped cards but drop the "People and agents" and "Selected" section titles and the empty header bands above them (selected recipients stay in their own card at the top with checkmarks on iOS, as chips on Android). The native iOS picker now focuses search once it appears, which iOS used to ignore. In New DM a pick clears the query, as an inserted mention ends its query. - **Add members keeps what is specific to it.** Existing members and members just added are never offered, names are still compared with the channel roster so a same-name candidate is told apart, the query stays after a pick so several matches can be added in a row, and agents that don't answer the viewer can still be added, since a channel can hold them. While the WebSocket reconnects, the relay agent directory and its owners keep their last read in the community instead of going empty, so shared agents already found stay eligible in both New DM and the composer chooser. The debug-only offline preview directory and the old directory listing and search providers are removed, since no picker reads them any more. ### Validation Added tests: the New DM widget journey now drives the real mention search through a fake relay session. It covers the empty-query state, the debounce, rows kept while a longer query searches, cached results shown again at once, selection, chips and submit. The layout journey covers the 50-row cap, and the native-bridge journey covers title, rows without key detail lines, the selection limit and failure handling. The full `mobile/test/features/channels` suite passes. Automated app run (iPhone 17 Pro simulator, iOS 27, debug build, Remote Agents Pilot community): opened New DM from the menu, confirmed the keyboard comes up with only the viewer's own agents listed, searched for people and an agent, selected both and confirmed the query cleared after each pick. Also exercised the composer mention chooser (same results and order for the same query) and the shared Add members sheet (plain style, autofocus, search, query kept after a pick). Captures show real community members, so they're shared internally rather than here. The DM was not actually created, to avoid a stray conversation; submit is covered by the widget tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
f09956179d |
fix(mobile): keep the composer focused through iOS inactive spells (#9055)
On iPhone, opening Control Center or the app switcher while the composer is focused can leave the keyboard up over a collapsed composer. The orphaned keyboard then stays put while you navigate, for example back from a thread to its channel, and it clips the composer pill. ### Cause iOS reports a zero keyboard inset while the app is inactive and restores the keyboard natively when the app returns. The composer treated any inset drop as a user dismissal: it collapsed, unfocused and removed its text field. When the keyboard came back, it was attached to no Flutter field, so nothing could dismiss it. Flutter's own report of the native side is [flutter/flutter#189051](https://github.com/flutter/flutter/issues/189051). ### Change The composer's keyboard observer ignores metrics while the app is not resumed. iOS reports `resumed` with the inset still at zero and brings the keyboard back a few hundred milliseconds later, so when a keyboard that was up hasn't returned at resume, the composer waits a one-second grace before treating it as dismissed. Any nonzero inset in that window cancels the wait. A restored keyboard leaves the composer expanded and focused; a keyboard that really stayed down still collapses the composer once the grace passes. ### Validation - Extended the existing "returns to the compact capsule when the keyboard drops" widget journey with both inactive cases in the order iOS reports them: resumed at zero with the keyboard returning about 400 ms later (composer stays focused), and resumed with the keyboard staying down (composer collapses after the grace). It fails without the fix and against the first revision of this PR. - Full mobile check and test suite pass. - iOS 27 simulator (iPhone 17 Pro, debug build of the first revision): focused the composer, typed, switched to Settings and back, opened and closed Control Center, sent a message, and left the channel. The composer stayed focused through each interruption, sending kept focus, and leaving dismissed the keyboard. Limitation: the simulator keeps the keyboard inset through these interruptions, so it reproduces neither the original bug nor the resume grace; the on-device inset drop is from the reporter's screen recording and the Flutter issue. Human app testing is waived under the mobile testing exception; the simulator pass above is the automated full-app run. 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> |
||
|
|
7b3c2f95f2 |
fix(mobile): bound notices to loaded message history (#9062)
## Summary Mobile reads system notices older than its loaded message window, even though those notices cannot yet appear. Bound notice reads by the oldest loaded message's timestamp to avoid fetching history outside the loaded time range. A short or empty bounded response finishes that time range, rather than all notice history. Preserve the full boundary second, resume when older message pages load, and keep notices before the first message available at the start of history. Enforce the 2,000-notice cap before fetching reactions. Reuse cached reactions and deletions when older pages reveal their notices. For channels with fewer than 500 notices, scrolling back can now require one bounded notice read per older message page. Previously, one unbounded read covered the channel. ### Related issue Related: #8962, which changed how mobile loads and displays membership notices. No duplicate notice-bound PR found in the open mobile-notice title search. ### Testing Mutation checks: removing the production `since` argument or restoring the old unconditional history-end flag each made all four notice-range cases fail. Command: `cd mobile && flutter test --no-pub --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example test/features/channels/channel_notices_journey_test.dart --name 'notice ranges'`. Replacing the captured message cursor with a fabricated maximum ID also fails the empty-range progress regression. Command: `cd mobile && flutter test --no-pub --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example test/features/channels/channel_messages_provider_test.dart --name 'an empty notice range'`. A read-only prepared-query check on local Postgres 17.10 used a representative kind-40099 query. It held 22 event-related relation locks with `force_custom_plan` and 99 with `force_generic_plan`. The generic plan pruned seven of eight partitions during execution but retained the broader locks. These results do not establish lock reduction or throughput improvement under the relay's actual cached-plan workload; a relay load test remains pending. Native app scrolling against a live relay and human confirmation remain pending. For the device check, run `just mobile-dev` on a host with an iOS Simulator runtime. Connect to a local test relay and open a channel with more than 150 messages and interleaved topic notices. Scroll back through at least three message pages, then to the start. Check that visible notices remain in order and appear once. Disconnect during a page read, reconnect, and continue scrolling to check recovery. Generated with Codex --------- Signed-off-by: BuzzPRReviewAssistant1 <a581a7e8115222acdc32ffb14bfce55d48c24478d98e155f8eba126b69b83c7b@buzz.block.builderlab.xyz> Co-authored-by: BuzzPRReviewAssistant1 <a581a7e8115222acdc32ffb14bfce55d48c24478d98e155f8eba126b69b83c7b@buzz.block.builderlab.xyz> |
||
|
|
99873459e9 |
Keep sign-in toasts usable at large text sizes (#9057)
At large text sizes, organization sign-in prompts could clip their message and push recovery actions off-screen. Stack actions below the message above 1.3× text, use fixed card corners, and skip the Dynamic Island morph for that layout. Follow-up to #9050, addressing [Wes’s large-text blocker](https://github.com/block/buzz/pull/9050#discussion_r4469530660). Normal-size styling and recovery callbacks are unchanged. Validation: extended existing sign-in/cancel/error journeys at 1×, 2× and 3× using Inter on a 320×568 viewport; assert text lies inside the painted clip and the card stays on-screen. Removing the fix fails all six enlarged-text cases. Mobile checks and full tests run through the push hook. Human device testing of this follow-up is pending. Local Flutter previews at 3× text (320×568): | Before | After | | --- | --- | |  |  | Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
ce94656b0d |
fix(mobile): use join template for community creation (#9072)
Community creation now uses the same mobile template, borderless iOS input, and keyboard-coordinated transition as joining, replacing the old welcome screen and boxed field. Name validation, separate form drafts, reduced motion, and accessible error announcements are preserved. Validation: analysis and 4,407 Flutter tests pass (45 skipped, four workers); independent review passed. Kenny approved the simulator preview. Agent verification of the full native transition remains pending; the after snapshot below is a Flutter widget render. | Before | After | | --- | --- | |  |  | --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
5b738eb647 |
Restyle mobile organization sign-in prompts as toasts (#9050)
Replace the mobile organization sign-in bottom card with the existing top toast treatment. Use shorter status copy, a solid-red Cancel button with white text in both modes, and stacked Retry / Use a different account buttons for denied access with 12 pt side and bottom padding. The account-switch action uses a dark secondary treatment. Recovery callbacks and visibility rules are preserved. Validation: `just mobile-install mobile-check mobile-test`; widget journeys cover sign-in/cancel/completion, saved-session errors, account switching at 200% text size, and transient-toast coordination. The cancel regression was verified to fail with its callback disabled. Local Flutter previews using production widgets, with synthetic state; not device captures. Manual test: sign in, cancel, retry, and switch accounts after denied access; verify each action remains reachable and the toast clears after successful sign-in. Before:  | Light mode · 375 pt | Dark mode · 375 pt | | --- | --- | |  |  | --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
0bdad8ea96 |
mobile: simplify verified profiles and prioritize full names (#9051)
Simplifies mobile identity presentation: removes verification checkmarks from messages, threads, mentions and profiles, plus issuer/time/disclaimer copy. Profiles show the community-verified full name as the title and the display name below, with fallback when no full name exists. Validation: mobile formatting/analysis, full Flutter suite (4,401 passed, 45 skipped), all 4 unconfigured-push checks, and pre-push hooks passed. Independent review found no blockers. Device and human visual checks pending. <details> <summary>Before / after snapshots</summary> Real Flutter profile widget with synthetic data, light/dark themes; widget previews, not device screenshots. Images are PR attachments only. | Theme | Before | After | | --- | --- | --- | | Light |  |  | | Dark |  |  | </details> <details> <summary>Inline messages — before / after</summary> Actual Flutter channel and thread widgets with synthetic verified-user data; local widget previews, not device screenshots. | Screen | Before | After | | --- | --- | --- | | Channel · light |  |  | | Channel · dark |  |  | | Thread · light |  |  | | Thread · dark |  |  | </details> Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> |
||
|
|
49fcab3324 | mobile: refine message avatars, metadata and reply gestures (#9046) | ||
|
|
0dd778f800 |
mobile: start new agents on Sonnet at medium effort (#9034)
## Summary
The mobile create-agent screen opened with model and thinking effort
blank, so the easiest path was picking from the full catalog, expensive
models included. A new agent now starts on the first Sonnet model the
catalog offers, at medium effort when that model supports it. People can
still change either one.
- An agent link that suggests a model still wins when Builderlab offers
that model.
- A setup resumed after a failure keeps the choice it saved.
- If the catalog has no Sonnet model, both start blank like today. If
the Sonnet model has no medium effort, only effort is left blank.
Builderlab doesn't send a default or pricing, so the app matches on
"sonnet" in the model id rather than pinning a version. That keeps
working when the catalog moves to a newer Sonnet.
## Screenshots
Captured from the widget test harness with a catalog listing Opus first,
then Sonnet. The avatar preview is blank because the test doesn't load
network images.
| Narrow, light | Narrow, dark | Wide, light |
| --- | --- | --- |
| 
| 
| 
|
## Testing
- New unit tests cover the default, missing medium effort, no Sonnet,
draft model wins, and a draft model the catalog lacks.
- A new widget test checks the form opens on Sonnet at medium and only
needs a name before Create.
- Full mobile suite passes locally with the CI dart-defines
(`BUZZ_PUSH_GATEWAY_URL`, `BUZZ_AUTH_DOMAINS`) at
|