Commit Graph

4 Commits

Author SHA1 Message Date
Tom Brow
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>
2026-11-09 07:21:21 -08:00
Tom Brow
acf56fff6f fix(mobile): use the desktop agent squircle for every agent avatar (#8873)
On mobile, the Create agent form previewed emoji and image avatars as
circles. Once the agent was created, its avatar showed as a rounded
square, and other surfaces used yet other radii. None of them matched
the agent squircle desktop uses.

This PR makes `lib/shared/widgets/avatar_shape.dart` the single source
of avatar silhouettes. Agents get desktop's squircle (traced from
buzz-app's `avatar-squircle.svg`), and people keep circles.
`avatarShape(isAgent:)`, `avatarPath()` and `AvatarClip` are the only
ways to get either shape, and the squircle border class is private.

Every agent avatar surface now goes through that bottleneck:

- Create agent form and avatar picker previews (previously circles for
emoji and photo avatars, and unclipped for catalog characters;
`PresetAgentAvatar` now owns the clip)
- The avatar picker's camera cutout (`ImageAvatarCapture` with `isAgent:
true`); profile editing keeps the circle
- Agents grid card portrait (previously a 22pt rounded rect)
- `AvatarImage` (message authors, DM rows, member lists), the profile
sheet, `SmallAvatar`, and the native avatar PNG used by iOS Add members
(previously 30% rounded rects)
- Huddle speaking halo, preparing placeholder, and spotlight frame
- The iOS Add members fallback tile (Swift), shown while an avatar
loads, now draws the same squircle instead of a 30% rounded rect

To catch future drift, `avatar_shape_test.dart` adds a source guard. It
fails when code under `lib/` picks an avatar shape inline from agent
identity (for example `isAgent ? BoxShape.rectangle` or `if (!isAgent)
return ClipOval`), hand-rolls the old 30%/60% agent radius, or uses a
person shape (`ClipOval`, `CircleAvatar`, `BoxShape.circle`) under
`lib/features/agents/`. Against main it flags 8 of the 10 old sites. It
is best effort: the native PNG's inline ternary and the card's RRect
didn't match those patterns and are fixed directly here.

The Create agent form also gets 8pt above **Choose avatar**, so the
button and everything below it move down from the preview, as Kenny
requested.

### Before / after

Captured on the iPhone SE (iOS 27) with a release build against a hosted
Builderlab community, using a throwaway agent with an emoji avatar. In
each pair, before (main) is on the left and after is on the right.

**Create agent form** (also shows the 8pt spacing)

![create](https://raw.githubusercontent.com/block/buzz/f1f6a019c69e245146db761a6344524969275d04/pr-8873--create.png)

**Avatar picker**

![picker](https://raw.githubusercontent.com/block/buzz/f1f6a019c69e245146db761a6344524969275d04/pr-8873--picker.png)

**Agents card**

![card](https://raw.githubusercontent.com/block/buzz/f1f6a019c69e245146db761a6344524969275d04/pr-8873--card.png)

**Agent profile sheet**

![profile](https://raw.githubusercontent.com/block/buzz/f1f6a019c69e245146db761a6344524969275d04/pr-8873--profile.png)

**Message author avatar** (`AvatarImage`)

![dm-message](https://raw.githubusercontent.com/block/buzz/f1f6a019c69e245146db761a6344524969275d04/pr-8873--dm-message.png)

**DM list row** (`AvatarImage`)

![dm-list](https://raw.githubusercontent.com/block/buzz/f1f6a019c69e245146db761a6344524969275d04/pr-8873--dm-list.png)

**Add members** (native iOS list, `nativeAvatarImage`)

![add-members](https://raw.githubusercontent.com/block/buzz/f1f6a019c69e245146db761a6344524969275d04/pr-8873--add-members.png)

Not captured on device: `SmallAvatar` (thread reply and search avatars)
and the Huddle halo, placeholder, and spotlight, which need a live agent
thread or huddle. Widget tests cover these.

### Validation

- Device: the before/after captures above. They predate the catalog
preview clip
([355ebca](355ebca27e)),
which only trims the transparent character art at the squircle corners.
The test agent was deleted afterwards.
- Added tests: `avatar_shape_test.dart` pins the squircle geometry to
desktop's mask and runs the source guard. The avatar picker journey now
checks that the catalog preview, emoji preview, and camera cutout use
the agent shape. Existing `AvatarImage` and channel-detail
Huddle/thread-avatar assertions were updated to the new shapes.
- Full mobile format, analyze, and test suite pass locally. The iOS
release build compiles with the Swift change.
- The device build predates a merge of main that brought in
[#8773](https://github.com/block/buzz/pull/8773). That merge touched the
card, create page, and profile sheet without changing any avatar shapes.

Human pre-readiness app testing is not applicable under Tom's standing
exception for Buzz mobile changes. An independent agent review ran, and
its optional findings were addressed in 00b1ec6.

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: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 11:55:47 -07:00
klopez4212
b1d60f98e2 Bring synced Solo chats and projects to mobile (#8337)
## Summary
Bring desktop Solo chats and project organization to mobile, with synced
collapsible sections, shared unread highlights and tab badges, agent
selection, shared search, native iOS creation sheets, and inline typing
bubbles that transition into replies.

Resolve Solo placement for every chat entry route, retain encrypted
organization snapshots for offline reading, and let users resume or
discard unfinished chat and project setup without deleting existing
messages.

### Related issue
Related: #6565; expandable agent transcripts remain separate work.

### Testing
- Fresh Solo read state seeds each active chat from its captured initial
last-message timestamp before exposing row/section/tab unread state.
Baselines survive intervening channel updates and reset across
identity/community boundaries while surviving read-state readiness
changes. Regressions cover multiple chats, events before read-state
readiness and during/after seeding, and identity changes; removing
either the seed or the display gate fails them.
- Chat completion reconciles a retained Solo-save journal even when
placement is already confirmed. False/throwing cleanup regressions
ensure the outer intent remains until reconciliation and only one
placement is published. Typing terminal-close loops remain bounded; the
retry budget resets after an uninterrupted eight-second health interval
or transport reconnect.
- Typing retry budgets reset after sustained recovery; invalid Solo
updates retain cached rows with a retry warning that survives failed
refreshes. Project names use the editors’ 120-grapheme limit, including
emoji and combining characters. Regression coverage exercises repeated
recovery, invalid live/refresh heads, and character-limit boundaries.
- Rebased onto current main, preserving the newer Search controls,
Huddle timeline filtering, and message-media layout.
- Failed project journal writes reload storage before claiming recovery
and reopen the entered name for retry. Typing subscription rejections
are caught; terminal closures clear stale indicators and retry with a
bounded backoff, canceled on disposal. Regression tests cover both
false/throwing storage writes and initial/late subscription rejection.
- Explicit ordinary destinations skip optional Solo lookup waits.
Generic destinations perform one bounded lookup in the destination
(search no longer duplicates it); an unavailable classification falls
back to ordinary sending per the product owner's explicit choice.
Positively identified Solo destinations use Solo providers.
- Terminal Solo subscription closure preserves cached rows and exposes
live-update Retry. Page coverage verifies warning visibility, usable
rows during recovery, and a subsequent terminal close. Removing the
warning or the immediate ordinary-route guard fails its regression.
- Generic channel entries share an ordinary default when Solo
classification is unavailable, per the requested product behavior. Known
Solo placement takes precedence; callers can explicitly require
unresolved placement to remain blocked. Bounded initial lookup preserves
known Solo deep-link routing.
- Dirty newest snapshots and offline-save warnings survive transport
reconnects; Retry persists that same event even if the relay returns an
older head. Community changes replace the service, and agent-directory
reconnect refresh remains intact.
- Pending-save discard reloads failed removals. Repeated reconnect
testing confirms typing subscriptions are released on provider rebuild
and disposal; removing the existing cleanup fails the regression.
- Ordinary Home/create routes explicitly preserve private-channel
sending during Solo failures; identified Solo sends use the Solo sender
and explicit unresolved routes remain fail-closed. Solo unread replies
beyond the initial 200-event page are loaded through the oldest-unread
boundary.
- Chat/project/agent cleanup reloads failed removals, retains
same-process recovery, and protects replaced records. False/throw
regressions verify retries do not duplicate creation or membership.
Route, unread, and cleanup guard-removal mutations each fail their
regression.
- Ordinary channels, DMs, and forums remain sendable when optional Solo
sync fails; explicit unresolved placement stays blocked. Controlled
cache races retain the newest verified snapshot, keep usable chats
visible, and retry offline persistence without stale writes. Both
protection-removal regressions fail as expected.
- Confirmed journal cleanup checks false/throwing removal and
exact-record identity. Interrupted agent choices are visible
immediately, identify the saved agent, and cover Resume/Later/Discard
with accessible name/key-fallback controls.
- Notification deep links preserve the contiguous history cursor;
same-second messages infer the newest agent consistently. Failed
offline-cache writes surface Retry, with false/throwing storage failures
and offline restart covered.
- Composite history cursors cover 350 same-second events plus older
history; removing the ID cursor makes the regression fail. Agent changes
now journal before granting access, with restart/failure and
Resume/Later/Discard widget coverage. Signed malformed profiles remain
isolated.
- Native iOS avatar tests passed on the simulator: visible-row loading,
three-request concurrency, streamed byte limits, response validation,
downsampling/pixel limits, cancellation, and visible avatar updates.
- Solo and Home share observed-event/read-marker logic. Widget coverage
verifies unread rows, collapsed-section indicators, the tab badge before
first Solo visit, message/thread reads, forced unread, and filtering
archived/nonmember/unplaced chats.
- Chat intent is saved before ownership and organization preflight;
tests cover interrupted ownership lookup and a disconnect while the
native creation form is open.
- Mobile analysis, formatting, file-size checks, and full mobile tests
passed at `1d4c41354`.
- Project creation persists its encrypted name/UUID before connectivity
checks or relay reads. Restart tests cover Resume/Later/Discard, scope
isolation, stale-intent rejection, and lost acknowledgements. Removing
the pre-network save makes all four production-flow regressions fail.
- Regression coverage includes notification/Activity routing, inline
replies, unknown-placement send protection, restart recovery/discard,
and encrypted offline cache isolation/refresh.
- Earlier feature build exercised on a physical iPhone with existing
account/community data; review follow-ups have not been reinstalled on
the device. UIKit agent-row accessibility checks passed on an iOS
simulator.
- Earlier cross-platform `just ci` stopped on the existing `buzz-acp`
test `signals_cancel_startup_turn_and_pending_stdin_without_hanging`
(process exit-code assertion); no Rust production changes are included
in this PR.

### Remaining review limitation
Agent discovery is bounded to 10 pages / 1,000 profiles with automatic
retries disabled. Owner-scoped discovery remains open because the
current relay ignores `#auth` filters; supporting that query requires a
relay change.

### Snapshots
Sample data, shared Flutter/Android surface. Native iOS uses platform
sheets and controls.

| Solo: projects and chats | New chat | Choose an agent |
| --- | --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/d9b29aa079a3b9a88e8ed89b70df8d6af06fa41b/pr-8337--01-solo.png"
width="240" alt="Solo with collapsible projects and chats" /> | <img
src="https://raw.githubusercontent.com/block/buzz/d9b29aa079a3b9a88e8ed89b70df8d6af06fa41b/pr-8337--02-new-chat.png"
width="240" alt="New chat form" /> | <img
src="https://raw.githubusercontent.com/block/buzz/d9b29aa079a3b9a88e8ed89b70df8d6af06fa41b/pr-8337--03-agent-picker.png"
width="240" alt="Agent selection list" /> |

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
Co-authored-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-15 17:07:23 -04:00
klopez4212
6cf4582002 Use native iOS navigation bars with large titles and avatars (#8052)
## Summary

Use UIKit navigation bars across iOS pages, with large titles that
collapse into the toolbar on scroll and a light native blur. Home uses
the active community name as its title and shows community and profile
avatars with centered emoji and stable loading, without flashing back to
icons. Channel headers retain the member count, tap-to-settings title,
and Huddle action. Their ultra-thin native material has a soft lower
edge and stays visible at the timeline bottom. The hidden title-layout
scroll view has its automatic edge effects disabled so it cannot add a
second backdrop.

| Expanded Home | Scrolled Home | Settings |
| --- | --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/47a77d3ff1d30fc74f7488c1a4427e41bbb85c4e/pr-8052--home.png"
alt="home" width="240"> | <img
src="https://raw.githubusercontent.com/block/buzz/47a77d3ff1d30fc74f7488c1a4427e41bbb85c4e/pr-8052--scrolled.png"
alt="scrolled" width="240"> | <img
src="https://raw.githubusercontent.com/block/buzz/47a77d3ff1d30fc74f7488c1a4427e41bbb85c4e/pr-8052--settings.png"
alt="settings" width="240"> |

Channel title, member count, and Huddle control:

<img
src="https://raw.githubusercontent.com/block/buzz/47a77d3ff1d30fc74f7488c1a4427e41bbb85c4e/pr-8052--channel.png"
alt="Channel navigation" width="280">

### Related issue

No matching open issue found.

### Testing

- Added causal regressions for Profile/Edit Photo native titles,
metrics-only collapse changes, and native-header suppression throughout
channel/thread reaction and action backdrops. Each regression fails when
its corresponding fix is removed.

- Mobile formatting and analysis passed. Full mobile test rerun passed:
2,622 tests, 4 skipped, plus 3 push-disabled build tests.
- Native iPhone Air regression passed: repeated title expansion/collapse
and scroll material behavior.
- Navigation and blur were accepted on the connected iPhone using the
existing profile. Simulator snapshots use real community data. The
subsequent review fixes have widget coverage; fresh physical-device
verification of Profile states, Dynamic Type changes, and message
backdrops is pending.
- Full repository `just ci` was blocked by local disk exhaustion while
compiling Rust dependencies.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-03 18:10:03 +01:00