**Category:** improvement
**User Impact:** Mobile users can scan Activity as a focused
conversation inbox and open the exact unread message or thread
represented by each item.
## Context
Mobile's Activity tab had not kept pace with Desktop: it presented
isolated event headlines, advertised categories that were often empty,
and opened a channel without clearly landing on the selected item.
This PR brings the Mobile surface toward the conversation-oriented
direction explored in Clay Delk's Desktop [Inbox refactor PR
#2045](https://github.com/block/buzz/pull/2045), while adapting it to
Mobile rather than copying the Desktop split-pane implementation. The
related product/UX discussion is captured in the originating [Buzz
thread](buzz://message?channel=a9bbc0e5-d25d-4740-849c-93c34bb578a4&id=a7d9a4d33dcd8c6bf0dc67d81c328892b9e38dedaa8548920224ef388301b6ab).
## UX decisions in this PR
- **Conversation-oriented, not event-oriented:** related updates
collapse into one row per thread/DM conversation, represented by the
latest update and ordered by latest activity. Separate top-level
conversations in the same channel remain separate rows.
- **Resume at the oldest unread:** tapping a grouped row opens the
represented canonical message/thread/DM at its oldest unread item,
rather than merely opening the channel at an arbitrary position.
- **Desktop-aligned row hierarchy:** rows lead with a full avatar and
sender, followed by contextual location/type metadata, unread dot +
time, and a two-line preview. A **New** boundary separates unread and
read content.
- **Mobile-native navigation:** Mobile keeps a single-column `Activity →
canonical conversation → Back` flow. It does not introduce Desktop's
persistent detail pane.
- **Compact filtering:** the old horizontal chip rail becomes a compact
filter menu so the source set fits a phone viewport without horizontal
scanning. Filters are All, Mentions, Threads, Needs Action, Activity,
Agents, Reminders, and Drafts.
- **Focused source semantics:** All covers personally relevant work—DMs,
mentions, thread replies, needs-action events, owned-agent activity, due
reminders, and active drafts—rather than becoming a generic stream of
every channel message. Mobile's standalone Activity source is currently
limited to DM traffic because it does not have Desktop's aggregated
channel-activity feed.
- **Shared read behavior:** rows project canonical
channel/thread/message markers, support unread-only and mark-all-read,
and use local overrides only where canonical markers cannot represent an
item.
- **Reminders and drafts are real data:** reminders use the same
encrypted NIP-ER events as Desktop. Drafts persist device-local composer
state, restore on return, survive failed sends, and clear after
successful sends.
- **Explain navigation failures:** an unavailable destination produces
an explanatory message rather than silently doing nothing or falling
back to an unrelated channel position.
## Implementation summary
- Adds a Mobile inbox model for conversation grouping, category
priority, contextual labels, sorting, filtering, and oldest-unread
targets.
- Expands relay-backed sources for mentions, approvals, owned-agent
lifecycle events, and DM traffic.
- Adds fail-closed NIP-ER reminder decryption and device-local
compose-draft persistence.
- Redesigns Activity rows, boundaries, filters, unread controls, and
empty/loading states.
- Routes rows through Mobile's existing canonical channel/thread screens
with precise target IDs.
- Adds model, provider, widget, reminder, read-state, draft-lifecycle,
and deep-link coverage.
## Reproduction steps
1. Run Mobile and open **Activity**.
2. Confirm full avatars, sender-first rows, context labels, unread
indicators, timestamps, two-line previews, and the compact filter
control.
3. Open the filter menu and verify All, Mentions, Threads, Needs Action,
Activity, Agents, Reminders, and Drafts.
4. Tap a grouped thread row and confirm the canonical conversation opens
at its oldest unread message.
5. Mark rows read/unread, enable unread-only mode, and use
mark-all-read; confirm state agrees with the channel/thread destination.
6. Type without sending in a channel or thread, leave, and confirm the
draft appears in Activity and restores in the composer.
## Screenshots
| Before — merge-base `dd222a509` | After — PR head `52ad40aee` |
|---|---|
| <img width="1206" height="2622" alt="image"
src="https://github.com/user-attachments/assets/ae961b08-bf8a-4bd5-b487-f6321ae8d85b"
/> | <img width="1206" height="2622" alt="image"
src="https://github.com/user-attachments/assets/bcbe7ab0-552a-417c-9e85-7a85eb4592ee"
/> |
Recaptured on the same authenticated iPhone 17 simulator, account,
theme, and Activity view, at this PR's current merge-base (`dd222a509`)
and head (`52ad40aee`). Both frames were taken within a few minutes on
the same live feed, so the visible conversation set overlaps closely
(the recent Ned/Bart/Tommy items appear in both). The compared change is
the row *structure*: Before leads with an `@ Mention` headline over a
small inline avatar and a horizontal chip rail; After leads with a full
avatar, a compact `labelMedium` sender label, contextual "Mentioned in"
metadata, and a filter menu. The sender username now renders at the same
compact scale the old `@ Mention` label used.
## Verification
- Current rebased head: `5bd87f4f4` on `origin/main` at `dd222a509`;
GitHub reports the PR mergeable.
- `flutter analyze` — clean at `5bd87f4f4`.
- Full Mobile suite — 698 passed, 1 skipped, 4 failed; all four failures
reproduce identically on clean `origin/main` (`channels_page_test`
create-channel sheet and three `compose_bar_test` agent-mention cases).
- The prior PR-specific `home_page_test` failures were fixed by
providing the Activity local-state dependency in that harness.
- Independent code and simulator UI review — approved.
- Post-rebase GitHub checks are running.
---------
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f <ab176f059d100602ea25073d9a69ee9817f7b691c76a897180d48498d959faa2@buzz.block.builderlab.xyz>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Co-authored-by: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f <ab176f059d100602ea25073d9a69ee9817f7b691c76a897180d48498d959faa2@buzz.block.builderlab.xyz>
**Category:** new-feature
**User Impact:** Mobile users can copy permalinks, revisit, follow, and
manage messages through a clearer long-press menu that matches desktop
capabilities.
**Problem:** The mobile message menu exposed only a small subset of
desktop actions, and important workflows such as copying a permalink or
scheduling a reminder were unavailable or hard to discover.
**Solution:** Bring applicable desktop actions to mobile using native
patterns, promote Reply, Copy link, and Remind me above the fold, and
group the remaining actions by intent in a scrollable sheet.
**Intentional behavior changes (per review):**
- The quick-reaction row goes from 6 emojis to 4 (👀 and 🙏 dropped) to
make room for larger 52px tap targets alongside the `+` picker, which
still offers the full set.
- **Copy link — not a native share sheet — is the permalink path.** An
earlier revision shipped a `share_plus` Share message row; it was
removed in review since Copy link covers the job and custom-scheme
`buzz://` URIs are handled inconsistently by share targets. Native share
can return as a follow-up with an https fallback.
- Mark unread is message-scoped and session-local: it forces just that
message unread (surfacing its channel as unread), and message-level Mark
read can never clear a channel-level unread set from the channel tile.
<details>
<summary>File changes</summary>
**mobile/lib/features/channels/channels_provider.dart**
Feeds followed thread roots into unread and notification evaluation so
following a thread has meaningful behavior.
**mobile/lib/features/channels/message_actions.dart**
Reworks the long-press sheet with promoted fast actions, message links,
reminders, read state, thread following, and clearer action grouping
while preserving existing guards. Quick-reaction circles share one
extracted widget.
**mobile/lib/features/channels/read_state/message_read_state.dart**
Centralizes message-level unread evaluation across channel, message, and
thread markers; channel-level forced unread deliberately does not leak
into message state.
**mobile/lib/features/channels/read_state/read_state_provider.dart**
Forced-unread flags are per-context (channel id or `msg:` key) mapped to
their channel, so message- and channel-level unread choices round-trip
independently.
**mobile/lib/features/channels/thread_follows/thread_follows_provider.dart**
Exposes per-identity thread follow state to the message menu and
notification pipeline.
**mobile/lib/features/channels/thread_follows/thread_follows_storage.dart**
Persists a bounded, validated set of followed thread roots on the
device.
**mobile/lib/shared/reminders/remind_me_later_sheet.dart**
Adds reminder presets and a native custom date/time flow for deferring a
message. Lives under `shared/` so the channels feature never imports
another feature module. Cancelling the custom picker keeps the preset
sheet open; submission failures show stable copy and log the underlying
error.
**mobile/lib/shared/reminders/reminder_service.dart**
Creates desktop-compatible, self-encrypted kind-30300 reminder events.
**mobile/lib/shared/reminders/reminder_time_presets.dart**
Defines reminder choices that match the desktop experience.
**mobile/lib/shared/deeplink/deep_link.dart**
Builds canonical Buzz message links, including thread context when
present.
**mobile/lib/shared/relay/nostr_models.dart**
Adds the reminder event kind to the shared Nostr model constants.
**mobile/lib/shared/widgets/sheet_divider.dart**
Shared bottom-sheet section divider used by the message actions and
reminder sheets.
**mobile/test/features/channels/message_actions_test.dart**
Covers action visibility and guards, promoted actions, read/unread
round-tripping (including channel- vs message-level force isolation),
thread follows, and canonical links.
**mobile/test/features/channels/read_state/message_read_state_test.dart**
Covers unread precedence for channel, message, and thread contexts.
**mobile/test/features/channels/thread_follows/thread_follows_storage_test.dart**
Covers follow persistence, identity separation, validation, and storage
bounds.
**mobile/test/shared/reminders/reminder_service_test.dart**
Covers reminder payloads, tags, crypto round-tripping, and preset
behavior.
**mobile/test/features/channels/read_state/read_state_provider_test.dart**
Drives the production ReadStateNotifier/ReadStateManager (no fake
bookkeeping) through message unread → read → unread round-trips,
explicit channel-level Mark read clearing forced messages, and automatic
channel-open reads preserving them.
**mobile/test/shared/reminders/remind_me_later_sheet_test.dart**
Covers custom-picker cancel keeping the sheet open, stable failure copy
without the raw error, and the happy preset path.
**mobile/test/shared/deeplink/deep_link_test.dart**
Covers canonical top-level and threaded message-link generation.
</details>
## Reproduction steps
1. Run the mobile app with a signed-in identity and open a channel
containing regular messages and threads.
2. Long-press a message and confirm reactions plus Reply, Copy link, and
Remind me appear as fast actions above the fold.
3. Use Copy link; confirm the resulting `buzz://message` link opens the
correct channel and thread context.
4. Toggle Mark unread/Mark read and Follow thread/Unfollow thread,
reopening the sheet to confirm each state changes correctly. Force a
channel unread from the channel tile, then mark a message read — the
channel stays unread.
5. Choose a reminder preset and a custom date/time; confirm the reminder
is created and appears in the desktop reminder experience. Cancel the
custom date picker and confirm the reminder sheet stays open.
6. Long-press a system message and a message you cannot manage; confirm
utility and destructive actions remain appropriately hidden.
## Screenshots / demos
<img width="1206" height="2622" alt="image"
src="https://github.com/user-attachments/assets/81096cd6-329b-408f-bcff-712e23b268a4"
/>
---------
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
### What changed?
Adds optimistic rendering for newly locally posted messages in channels
and threads.
- Messages appear immediately
- Relay echoes and history are deduplicated by event ID
- Rejected or timed-out publishes erase the optimistic rendering.
The implementation covers reconnect and hydration races, channel-window
and legacy WebSocket paths, thread-local overlays, and rapid concurrent
sends.
### Why?
This is a valuable partial mitigation for
[BOT-1449](https://linear.app/squareup/issue/BOT-1449/buzz-mobile-posted-messages-dont-appear-until-leavingre-entering-the):
senders no longer depend on receiving a relay echo before seeing their
own post.
It does not address the likely primary cause of stale channels. Mobile
currently does not recover live subscriptions after a rate-limited relay
`CLOSED`; that recovery is being handled separately.
### How is it tested?
Full mobile suite: 676 passed, 1 skipped.
Added regression coverage for optimistic insertion, authoritative
deduplication, rollback, reconnect and hydration, thread replies, rapid
and equal-time sends, never-echoed successful sends, and legacy
WebSocket retirement.
---------
Signed-off-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz>
Co-authored-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz>
**Category:** improvement
**User Impact:** Developers can identify which worktree produced a
mobile debug app, keep a bounded set of worktree builds installed side
by side, and preserve each worktree app's login and local state while
switching branches.
**Problem:** Mobile debug builds from every checkout currently appear as
the same “Buzz” app and share one application identity, so the running
source is ambiguous and one worktree build replaces another. A
branch-keyed identity would avoid replacement but create stale installs
and fresh app state on every branch switch.
**Solution:** Give each linked worktree a stable Debug-only application
identity derived from its sanitized directory name. Show the sanitized
branch name (or short commit SHA when detached) in the display label,
persist generated native overrides for direct IDE builds, and leave
Release/Profile identities unchanged. Worktree defaults remain lower
precedence than a developer's iOS `AppOverrides.xcconfig`. `just
mobile-clean` provides a safe cleanup path for suffixed worktree
installs while preserving production Buzz.
<details>
<summary>File changes</summary>
**.github/workflows/ci.yml**
Runs the expanded worktree override contract when relevant mobile or
native configuration changes.
**AGENTS.md**
Documents worktree-aware mobile development and cleanup for contributors
and agents.
**Justfile**
Generates overrides before mobile development and Android debug builds,
and exposes `just mobile-clean`.
**mobile/README.md**
Explains stable per-worktree identities, branch/SHA labels, direct IDE
usage, cleanup, and Release/Profile guarantees.
**mobile/android/.gitignore**
Ignores generated worktree properties.
**mobile/android/app/build.gradle.kts**
Loads and validates generated properties, then applies the application
ID suffix and display label to Android Debug only.
**mobile/android/app/src/main/AndroidManifest.xml**
Resolves the Android app label through an overridable string resource.
**mobile/ios/.gitignore**
Ignores generated iOS worktree settings.
**mobile/ios/Flutter/Debug.xcconfig**
Loads generated worktree defaults before developer `AppOverrides`, so
personal signing overrides retain precedence.
**mobile/ios/Flutter/Release.xcconfig**
Pins the production display name and bundle identifier for
Release/Profile builds.
**mobile/ios/Runner/Info.plist**
Resolves the visible iOS app name from build settings.
**scripts/mobile-worktree-overrides.sh**
Detects linked worktrees, derives a stable directory-keyed identity,
sanitizes branch/SHA display context, writes native Debug overrides, and
removes stale overrides in the main checkout.
**scripts/mobile-worktree-clean.sh**
Lists or removes suffixed Buzz worktree installs from booted iOS
simulators and connected Android emulators without matching production
IDs; supports `--dry-run`.
**scripts/test-mobile-worktree-overrides.sh**
Covers worktree detection, branch-switch identity stability, detached
HEAD fallback, special-character sanitization, iOS override precedence,
brace-aware Release/Profile purity, cleanup safety, ignores, and command
integration.
</details>
## Reproduction steps
1. From a linked worktree, activate the repository toolchain and run
`just mobile-dev`.
2. Inspect the running app: its label should be `Buzz
(<sanitized-branch>)`, while its application ID suffix is derived from
the worktree directory.
3. Switch branches in the same worktree, rerun the override script, and
confirm the application ID remains stable while the display label
updates. In detached HEAD, confirm the label uses a short SHA.
4. Build Debug from a second worktree and confirm both apps remain
installed side by side with independent state.
5. Build from Xcode after setting `AppOverrides.xcconfig` and confirm
developer overrides still win over generated worktree defaults.
6. Run `just mobile-clean --dry-run`, then `just mobile-clean`, and
confirm suffixed worktree installs are targeted while the production app
is preserved.
7. Build Release/Profile and confirm the production name and application
identity remain unchanged.
8. Run `scripts/test-mobile-worktree-overrides.sh`, `just mobile-check`,
`just mobile-test`, and `just mobile-build-android`.
## Screenshots / demos
| iOS — labeled app switcher | iOS — side-by-side installs |
| --- | --- |
| <img width="360" alt="Buzz worktree label in the iOS app switcher"
src="https://github.com/user-attachments/assets/4bcae067-7ce5-4333-bb11-2803c4107663"
/> | <img width="360" alt="Buzz production and worktree debug apps
installed side by side on iOS"
src="https://github.com/user-attachments/assets/08a107b5-fdf2-463a-8a4c-81d41d7bf5e7"
/> |
| Android — side-by-side installs | Android — labeled app switcher |
| --- | --- |
| <img width="360" alt="Buzz production and worktree debug apps
installed side by side on Android"
src="https://github.com/user-attachments/assets/4f5841a1-adae-42da-ae84-47c09ec85fb9"
/> | <img width="360" alt="Buzz worktree label in the Android app
switcher"
src="https://github.com/user-attachments/assets/0546ff51-efcc-4cb6-a4bd-2a3af26cd60f"
/> |
---------
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
## Summary
- require invite relay destinations to be secure public origins in
production
- reject non-public and ambiguous IP literals before confirmation and
again before the claim request
- disable redirects for invite claims so a validated relay cannot
redirect the request elsewhere
- preserve explicit debug-only localhost support
## Validation
- pre-commit `dart format` and `flutter analyze`
- pre-push full mobile test suite: 666 passed, 1 skipped
- independent source reviews from Princess Donut and Mongo found no
remaining blockers
## Scope and residual risk
This fixes the mobile invite trust boundary without changing NIP-98 or
NIP-42. Hostnames are not resolved and pinned by this patch, so DNS
rebinding remains a networking-layer residual risk requiring
connect-time resolution/pinning.
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@sprout-oss.stage.blox.sqprod.co>
## Summary
- reorganize mobile settings around profile, appearance, and connection
cards
- add System/Light/Dark theme pairing, accent selection, and the Buzz
gradient theme
- align avatar badges, status editing, and supporting mobile chrome
## Test plan
- `just mobile-check`
- `just mobile-test`
### What changed?
Made the mobile new-message directory providers
(`relayDirectoryUsersProvider`, `relayDirectorySearchProvider`)
`autoDispose`, and both now watch `relayConfigProvider` so they refetch
when the active relay/community configuration changes.
### Why?
Follow-up to #2810 (Codex P1 review flag: "Invalidate the directory when
the community changes").
Both providers previously cached results for the whole app session. They
watched only the relay-session notifier (a stable instance that survives
dependency rebuilds) and the current pubkey, which keeps its value when
two communities share a signing key. Switching between such communities
could reopen the New message sheet showing the previous relay's people,
and submit their pubkeys to the current relay. The search provider was
also a non-autoDispose family keyed by raw query strings, so every
distinct typed query leaked a cached provider entry for the session.
Watching `relayConfigProvider` (which rebuilds on every community switch
via `activeCommunityProvider`) invalidates cached browse and search
results at the community boundary, and `autoDispose` releases the cache
when the sheet closes.
### How is it tested?
Full mobile suite green (585 passed / 1 skipped), `flutter analyze`
clean.
Added tests:
-
[`channel_management_provider_test.dart`](https://github.com/block/buzz/blob/gated/directory-provider-invalidation/mobile/test/features/channels/channel_management_provider_test.dart)
— browse and search refetch on relay-config change with an unchanged
session notifier and pubkey; cached search families are released once
unlistened.
Signed-off-by: npub1kqarnt4re38nuttqnml3mrqp8cnm6wzpywl2kesc2ejasp0luc5q275nkx <b03a39aea3cc4f3e2d609eff1d8c013e27bd384123beab66185665d805ffe628@buzz.block.builderlab.xyz>
Co-authored-by: npub1kqarnt4re38nuttqnml3mrqp8cnm6wzpywl2kesc2ejasp0luc5q275nkx <b03a39aea3cc4f3e2d609eff1d8c013e27bd384123beab66185665d805ffe628@buzz.block.builderlab.xyz>
## Summary
- Refine mobile navigation with icon-only tabs, haptics, a solid active
state, and spring quick actions.
- Bring Create channel and New message closer to desktop with radio
settings, keyboard submission, relay people, and wrapped recipient
chips.
- Keep both sheets draggable below the status area and prevent keyboard
overflow with many recipients.
## Testing
- `just mobile-check`
- `just mobile-test`
- Pixel 10 manual verification
## Summary
- Expand the pairing scanner from the Dynamic Island on supported
iPhones
- Reveal the camera behind the pairing UI on Android and standard
iPhones
- Preserve tap-to-dismiss and reduced-motion behavior
## Testing
- `just mobile-check`
- `just mobile-test`
- iOS `RunnerTests`
Mobile's ChannelSection model dropped desktop's optional `icon` field, so any
mobile section mutation republished the whole-blob LWW channel-sections event
with every icon stripped — wiping sidebar section emojis on all devices. Now
mobile round-trips the icon through storage/sync, preserves it across all
mutations and the publish no-op check, and renders it in section headers
(native glyph or registered custom emoji; unknown shortcodes fall back to
literal text).
Co-authored-by: morty <1d284070cd2ca08ceeb15c6bafa9ef1a43b717d6837f30727f6e9819b8439f40@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>