Commit Graph

207 Commits

Author SHA1 Message Date
klopez4212
ea143ea64b feat(mobile): native iOS message menus and reaction sheets (#8055) 2026-10-02 20:59:05 +01:00
Logan Johnson
2782b9cd5e fix(mobile): preserve exact mention recipients in saved drafts (#7387)
<!-- Draft PR body for #7387 · fix(mobile): preserve exact mention
recipients in saved drafts · https://github.com/block/buzz/pull/7387 -->

🤖

## Summary

Saved drafts remembered their text but not exactly who each mention
referred to: reopening a draft (or restarting the app) could re-bind a
mention to the wrong same-name teammate, and a draft restored in one
thread could capture text arriving for another. This PR saves the exact
selected identities alongside the draft text and restores them together,
so a reopened draft names the same recipients — with their agent
markings — that you picked, including after a failed send.

- Persists selected keys and agent presentation in the app's existing
per-account/channel/thread draft storage (device-local; no cross-device
portability claim).
- Restores recipient bindings before text listeners run, including
recovery from a rejected send; malformed legacy entries are ignored
rather than guessed.
- Retires the previous draft's listeners before restoring another, so
switching threads can no longer write incoming text into your outgoing
draft.

### Related issue

- Fixes: N/A. No separate issue; the related work is the stack below.
- Depends on #7385 (declared base) — same-name selections and qualified
labels come from that PR.
- Landing note: branches in this series overlap in the composer — when
rebasing, keep exact/durable mention bindings, the explicit
invite/reference-only choice, the account/visit/revision fences, and
authorization before membership preparation and publication; don't
resolve conflicts by taking either side wholesale.
- Draft — not requesting merge yet; the security advisory doesn't run on
this stacked base (it reviews main-based ranges).

### Testing

- Regressions cover two namesakes, restart, rename, rejected send, a
second restart, and changing draft scope.
- At `e544b247c32bdccf1b316d3329be247faf412f5f`: `just mobile-check` and
full `just mobile-test` pass (2,085 tests). The earlier full `just ci`
receipt linked below is reused only for unchanged non-mobile
code/tooling, not claimed as a rerun at this head.
- Previous-head evidence: `just mobile-check`, the full mobile test
suite, and full local `just ci` all pass — receipts in the [exact-head
evidence
comment](https://github.com/block/buzz/pull/7387#issuecomment-5574635970).
- Verification is widget-test level; no native device or simulator run
is claimed.

To see it: mention an agent or a namesake, leave the channel, restart
the app, and return — the draft still names the exact recipients you
picked.

### Screenshots

Flutter production-widget test renders — not native-device screenshots
or acceptance captures.

| Scenario | Before | After |
|---|---|---|
| Draft reopened after leaving the channel and restarting the app |
![Before: the restored draft shows plain @Helper Bot text — the exact
selected recipient is
lost](https://github.com/user-attachments/assets/166b5c64-f7e8-46e5-b6b4-3b9e108217cb)
| ![After: the restored draft shows the highlighted robot mention chip
for the exact selected
recipient](https://github.com/user-attachments/assets/c3583a88-18bc-4513-9a87-7a4f7db113ac)
|

<details>
<summary>Capture provenance</summary>

Rendered by the Flutter widget engine in a `flutter test` run
(production widgets, production theme; no device or simulator). Before:
this PR's declared base `39afd73b0adfde14164f4b10dbd089cb498312b6`.
After: its head `8a781108507dc4501192b0101185decc1ed6f118`.

</details>

Signed-off-by: Logan Johnson <loganj@squareup.com>
2026-10-01 21:16:13 -04:00
Tom Brow
fad4ff637a fix(mobile): hide notification settings without relay push capability (#8025)
Hide the iOS Notifications settings section for communities that have
not enabled push unless their relay advertises valid push support. A
build with a configured gateway previously showed the switch even when
the relay could not support push.

Communities where push is already enabled retain an off switch and iOS
Settings recovery during discovery or when push support is unavailable.
The section explains that support is unavailable instead of claiming
notifications will arrive.

Capability discovery keeps its existing refresh behavior. Returning
after at least five seconds in the background reconnects the session and
fetches capability again. Tom explicitly chose this behavior without new
retries, polling, or refresh triggers.

Companion client change for squareup/buzz-releases#104. Related: #7826
(broader notification availability diagnostics).

### Validation

Settings widget tests cover hidden first-time opt-in during loading,
absent capability, and discovery errors. Recovery tests exercise the
real off switch for already-enabled communities in all three states,
verifying persisted opt-out, the native snapshot writer, a durable
pending relay tombstone after failure, and the iOS Settings link. They
also verify that pending recovery stays visible after a relay failure
and disappears only after opt-out confirmation. Tests without a gateway
definition verify hidden first-time settings and disabled native
registration. Mobile analysis and the mobile test suite passed.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-10-01 11:08:12 -07:00
klopez4212
6b5bb6f5a3 feat(mobile): refresh onboarding and smooth QR scanner motion (#8019)
## Summary
Refresh mobile onboarding with light/dark gradients, an animated fuzzy
Buzz wordmark and tagline, and bottom-aligned glass/ghost actions.
Remove the dot grid and idle scroll bounce, and smooth the QR scanner's
continuous corners and spring transitions by deferring camera teardown
until dismissal finishes.

### Related issue
N/A — no matching mobile onboarding issue or PR found.

### Testing
- Mobile formatting, analysis, and full test suite passed at
`0bb357f7a`.
- Regression coverage includes shader animation/reduced motion, keyboard
reachability, scanner interruption, deferred camera disposal, and
rendered error contrast in both themes (mutation-checked).
- Signed iOS release build installed on iPhone Air; Kenneth tried the
onboarding and scanner during iteration and approved the final blur.
- Agent review completed with no correctness blockers. Android device
validation was not performed; hosted checks run separately.

buzz-review-completed

### Preview
Production-widget renders at 420 × 912; before: `5fdb2e536`, after:
`443858a96`.

| Before | After — light | After — dark |
|---|---|---|
|
![Before](https://raw.githubusercontent.com/block/buzz/52fb09e3c7e536f3a365d61401a077ce03ac47d9/pr-8019--buzz-before-light.png)
|
![Light](https://raw.githubusercontent.com/block/buzz/52fb09e3c7e536f3a365d61401a077ce03ac47d9/pr-8019--buzz-blur-only-light.png)
|
![Dark](https://raw.githubusercontent.com/block/buzz/52fb09e3c7e536f3a365d61401a077ce03ac47d9/pr-8019--buzz-blur-only-dark.png)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-01 15:17:04 +01:00
Logan Johnson
965fe1dffa fix(mobile): hydrate and refresh relay presence snapshots (#7382)
<!-- Draft PR body for #7382 · fix(mobile): hydrate and refresh relay
presence snapshots · https://github.com/block/buzz/pull/7382 -->

🤖

## Summary

Mobile showed every person in a DM as **Offline** until they happened to
publish a presence update while the app was connected. Presence was
never loaded when the app opened, never refreshed, and a missing or
failed read looked identical to actually being offline. This PR makes
presence load and stay current: on connect or foreground, the app
fetches the relay's presence snapshot for everyone visible, keeps the
live subscription open while it does, and re-checks every 60 seconds
while the app is in the foreground. A missing or failed read shows
**Unknown** (no dot), not Offline.

- Snapshot reads are authenticated like the rest of the session and
batched by exact key (at most 100 per batch), instead of relying on live
events only.
- The live subscription opens before the snapshot, so updates published
during the read are not dropped. Per-key revisions fence stale **query
completions**: a snapshot that finishes after newer live evidence for
that key does not overwrite it.
- Refreshing every 60 foreground seconds observes relay lease expiry and
retry failures; missing or failed evidence simply stays absent — it is
never reported as Offline.
- Disconnects, backgrounding, and account/community changes reset the
cache cleanly. The app's existing subscription/recovery machinery is
reused rather than duplicated.

Mirrors Desktop's presence hydration
(`desktop/src-tauri/src/commands/profile.rs::get_presence`,
`desktop/src/features/presence/hooks.ts`).

### Presence contract: best effort between polls

Presence is a best-effort estimate that the next foreground poll
corrects. This PR does not give strict causal order between snapshots
and live events:

- Snapshots carry relay time. Live kind:20001 events carry the signer's
time. These clocks are not comparable.
- A live event at or below a snapshot's relay second is not applied
directly. The client confirms it with a fresh snapshot. This works
because the relay persists presence before fan-out.
- An empty (all-Offline) snapshot carries no relay second. If the relay
stored an Online event, then its fan-out was delayed past lease expiry
and an empty snapshot, the delayed event can show Online/Away until the
next 60-second poll. Base `main` has the same stale-delivery exposure
with no correction at all.

Strict ordering needs relay-issued ordering metadata on both paths, or a
fresh confirmation for every live event. Both are protocol or latency
tradeoffs and are out of scope here.

### Related issue

- Fixes: N/A. Closest open duplicates: #4417 (larger presence bootstrap
+ UI patch), #5617. Related: #6200, #7295.
- First slice of the mobile remote-agent parity port. This PR includes
the Unknown rendering at all three consumers (DM header, channel tile,
profile sheet). #7383 and #7526 stack on it and add test coverage only.

### Testing

- Rebased on `main` `95b018c87` (2026-09-30). At stack tip `7c8a93081`:
`just mobile-check` clean; full `just mobile-test` **2,593 passed**.
Earlier receipts below apply to the pre-rebase head.

- Regressions cover cold-open hydration, live/snapshot races, lease
expiry, failed reads and retries, disconnect/background/scope changes,
and >100-key batching.
- At the branch head: `just mobile-check` and the full mobile test suite
pass independently, and every broader CI gate has successful evidence
after isolated recovery runs (no monolithic local `just ci` run) —
receipts in the [exact-head evidence
comment](https://github.com/block/buzz/pull/7382#issuecomment-5573112882).
- Verification is automated-test level; no native device or simulator
run is claimed. No layout change in this slice.

### Screenshots

No layout change in this slice — the renders below show the same
existing profile sheet, with the presence status now following hydrated
relay data. Flutter production-widget test renders — not native-device
screenshots or acceptance captures.

| Scenario | Before | After |
|---|---|---|
| Existing profile opened cold, with a signed relay snapshot saying
Online | ![Before: the profile stays Offline — the snapshot is never
fetched](https://github.com/user-attachments/assets/4b4899c8-364c-4621-8d59-949189860eb3)
| ![After: the profile shows Online once the snapshot
hydrates](https://github.com/user-attachments/assets/7b1a9ecd-a7a8-4e04-b007-b86565a39cfc)
|

<details>
<summary>Capture provenance</summary>

Rendered by the Flutter widget engine in a `flutter test` run
(production widgets, production theme; no device or simulator). Before:
this PR's declared base `3c7f288c60d67df78577b237e27c3dfc8831aaa1`.
After: its head `a8e8028bfb9bb67de2db03ed448839e2f3ab2b0d`.

</details>

Signed-off-by: Logan Johnson <loganj@squareup.com>
2026-09-30 20:10:24 -04:00
Logan Johnson
7a1910e700 feat(mobile): show contextual names in lists, Search and Pulse (#7896)
🤖
## Summary

This PR finishes the change on the remaining mobile screens. After it,
every place that names a person or agent uses a contextual name:

- The channel list, DM labels, and the new-DM picker
- The channel header and details, and the members and add-members sheets
- Huddle avatars, spotlight, and overlay
- The Activity inbox
- Search results for people and messages
- Pulse notes, agent cards, and reply previews

Screens that belong to a channel compare names against its members.
Search, Pulse, and the new-DM picker have no channel, so they compare
the identities shown together. In the example below, Search showed four
results all named "murderbot". Now the human keeps the plain name, and
baxen's three agents are labeled by owner and key suffix.

Part 3 of 3: #7894 (resolver) → #7895 (conversations) → #7896 (lists,
Search, Pulse).

### Related issue

Related to #2910. The desktop search change in #6483 addresses the same
problem in a different app.

### Testing

- Widget tests cover the Activity inbox, Search, and Pulse notes with
colliding names.
- `flutter analyze` is clean, and the full `flutter test` suite passes.
- Checked on an Android emulator against the live relay by searching for
"murderbot". The after image also shows "classy-murderbot", a new
account that was created between the two captures.

| Before | After |
| --- | --- |
| <img width="300" alt="Before: Search shows four people all named
murderbot"
src="https://github.com/user-attachments/assets/21bbcc52-fea8-4b31-ab8c-ff3ec9b32a39"
/> | <img width="300" alt="After: three results show as baxen's
murderbot with a key suffix, and the human shows as murderbot"
src="https://github.com/user-attachments/assets/d59584d9-4a35-46da-b39a-83addfad3cd2"
/> |

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-30 10:46:14 -04:00
Logan Johnson
4ef23609b7 feat(mobile): show contextual names in channel conversations (#7895)
🤖
## Summary

In a channel conversation, two identities with the same display name
looked identical. In the example below, the channel shows "Bad Janet"
twice, for two different identities. A reader cannot tell which one was
added and then removed.

This PR uses the contextual names from the previous PR everywhere a
conversation names someone:

- Message authors, threads, forum posts and replies
- Reactions, the typing indicator, and system rows (joined, added,
removed)
- Inline mentions and the agent activity sheet
- The profile sheet title, which now matches the row that opened it

Names are compared against the channel's members, so a label only gets
longer when a real collision exists in that channel.

The mention picker labels each choice against every candidate, so two
"Honey" choices look different. When you pick one, it still inserts the
identity's own name ("@Honey"), so the message text does not change.

Part 2 of 3: #7894 (resolver) → #7895 (conversations) → #7896 (lists,
Search, Pulse).

### Related issue

Related to #2910.

### Testing

- A widget test checks that the profile sheet names an author the same
way the channel does.
- A unit test checks that two same-name mention choices get different
labels but insert the same wire name.
- `flutter analyze` is clean, and the full `flutter test` suite passes.
- Checked on an Android emulator against the live relay, in an archived
channel with two identities named "Bad Janet".

| Before | After |
| --- | --- |
| <img width="300" alt="Before: the channel shows two different
identities both as Bad Janet"
src="https://github.com/user-attachments/assets/c7f6c345-12d8-4e11-9434-bbd2c63722db"
/> | <img width="300" alt="After: the second identity shows as Bad Janet
· sqxl"
src="https://github.com/user-attachments/assets/51c6b9fc-8900-4200-8ce0-ad1887f41dba"
/> |

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-29 15:47:43 -04:00
Logan Johnson
efe04288ea feat(mobile): add the contextual identity-name resolver (#7894)
🤖
## Summary

When two identities share a display name, Buzz mobile shows the same
label for both. A reader cannot tell a person from an agent, or one
person's agent from another's. For example, a channel can show three
agents and one human all named "murderbot".

This PR adds the naming rules that fix this, without changing any screen
yet. It ports the contextual identity-name contract (v1) from the
desktop app to Dart. Given the identities shown together, the resolver
gives each public key a distinct label:

- A human keeps the plain name. Agents that share it get a qualifier.
- The viewer's own agents are named before other people's agents.
- An agent is qualified by its owner's name first ("Wes's Honey"). A
short public-key suffix is added only if that is still ambiguous
("baxen's murderbot · xa8v").

It also adds two providers that screens will use:

- `channelIdentityNamesProvider` compares names against a channel's
members, plus any referenced non-member such as an old author or an
outside mention.
- `watchIdentityNames` compares a set of identities shown together
outside a channel, and loads their profiles.

The next PRs in this stack connect these to the screens.

Part 1 of 3: #7894 (resolver) → #7895 (conversations) → #7896 (lists,
Search, Pulse).

### Related issue

Related to #2910 (default "Fizz" agents from different users collide in
one channel).

### Testing

- The contract's 31 portable fixture cases run against the production
resolver. The fixture file is copied unchanged from the contract, so
reviewers can skip it.
- Unit tests cover owner and suffix qualification, the viewer's own
identities, and invalid public keys.
- Provider tests check that a channel keeps its names while its roster
reloads, that an offline cached bot still counts as an agent, and that
changing relay or account never shows the old roster.
- `flutter analyze` is clean.

No UI change, so there are no screenshots.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-29 12:21:14 -04:00
Tom Brow
662014d544 fix(mobile): stale community selection during mobile invite setup (#7951)
Buzz sets up starter channels when a person accepts an invite. If the
phone already had a saved community, the first attempt could fail. Retry
could then succeed.

A delayed read could replace the new active community with the old one.
The read now leaves the saved selection unchanged. The fix also delays
channel-status updates until the channel provider finishes
initialization.

### Before and after

Controlled Flutter app captures against local relays, with the same
delayed storage read. The before capture restores the pre-fix
active-community provider in the test harness. The after capture uses
the fixed provider. These are separate from the native iOS validation
below.

| Before: first attempt fails | After: first attempt succeeds |
| --- | --- |
| ![Before: starter channel setup error with Retry setup
button](https://github.com/user-attachments/assets/14d8ba45-6e53-40ae-bc10-f219f08d2f92)
| ![After: joined community with Continue to welcome channel
button](https://github.com/user-attachments/assets/7dc32d9d-3aae-4378-913c-8ac68cc7d930)
|

### Validation
- Regression tests reproduce the delayed-read ordering and passing
timing control; removing the channel sequencing safeguards makes their
regressions fail.
- Production app against local relays passes the forced race on the
first attempt.
- Native iOS simulator with an existing saved community accepted the
original invite using a fresh identity, joined on the first attempt, and
opened the welcome channel without Retry.

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-09-28 14:44:43 -07:00
Will Pfleger
9b1c4f98e2 🤖 perf: bound long-thread aux reads and make query deadlines terminal (#7854)
Long threads (over ~100 replies) time out in desktop. The aux hop that
loads reactions and other e-tag references builds its filter as one
`tags @> …` term per id, joined with `OR`. Past about 100 ids the
planner switches to walking each partition's primary key backward,
expecting to stop early. When few rows actually match, it scans the
whole community. With a dirty GIN pending list, a 188-reply thread never
finishes.

### What changes

- **Set-based e-tag reads (`buzz-db`).** The aux e-tag query now puts
the id set behind a `MATERIALIZED` CTE and matches with `ANY`. Each
e-tag read also runs under a transaction-local `statement_timeout` of 20
s.
- **Terminal timeout contract (`buzz-relay`).** When a read hits the
deadline, the client gets a terminal timeout (`CLOSED` for REQ, the
timeout error for COUNT and search), not a retryable failure.
- **Subscription lifecycle (`buzz-relay`).** The per-connection
subscription map lock is now the lifecycle lock. Claims, replacement,
client `CLOSE`, owner-checked timeout and search cleanup, revoke, and
connection teardown all change the map, the registry, topic refcounts
and terminal frames while holding it. As a result, a timed-out
subscription's `CLOSED` goes out before any replacement with the same id
can claim it, and claims made after teardown are refused.
- **Dropped terminal frame disconnects (`buzz-relay`).** If the `CLOSED`
for a retired subscription (owner-checked timeout or channel eviction)
can't be queued, the relay cancels the connection instead of leaving the
client holding a subscription that will never receive fan-out. A
client-initiated `CLOSE` ack is exempt.
- **COUNT under the same deadline (`buzz-db`).** The fast COUNT path
runs e-tag counts under the same transaction-local 20 s cap as SELECT,
so WS COUNT and HTTP `/count` produce the terminal timeout too.
- **Replica reads surface the deadline (`buzz-db`).** When a
replica-routed read is cancelled by `statement_timeout` (SQLSTATE
`57014`), the error is returned instead of re-running the query on the
writer, which would run the same slow statement. Other replica errors
still fall back to the writer.
`PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md` records the exception.
- **Deadline handling (desktop).** Only the relay's `query timed out`
counts as a query deadline. A client-side `request timed out` stays
retryable. For one-shot reads a deadline is terminal: the global query
retry (budget 1), thread-replies retry and reaction hydration don't
re-send the same slow query. Reaction ids held back by a deadline are
released when the channel is opened again, so reopening retries them
while same-channel re-renders don't. Live subscriptions, in both the
renderer and the native archive-sync socket, keep treating a deadline
`CLOSED` as transient: they back off and resubscribe with the same
filter instead of being dropped.
- **Deadline handling (mobile).** Mobile recognizes the deadline on both
transports: HTTP `503 {"error":"query timed out"}` and the WS `CLOSED`
reason.
- The root `ProviderScope` retry policy returns no retry for deadline
errors and keeps Riverpod's default backoff for everything else.
  - Background thread-summary recounts don't retry a deadline.
- Forum posts polling pauses while the last load settled on a deadline;
reopening the forum retries.
- A channel's newest-window load surfaces a deadline instead of falling
back to legacy WS history, and the channel list's batched queries return
"unavailable" on a deadline instead of fanning out to per-filter
fallbacks. An unavailable batch is not treated as empty. Known
last-message timestamps and unread state are kept, and unread catch-up
moves `lastMessageAt` forward (never backward) the same way live events
do, so a cold start with no known timestamp still gets one.
- A live subscription that gets a deadline `CLOSED` backs off and
resubscribes with the same filter. History requests still fail on it.
- Settled load failures show a Retry control, matching desktop's
thread-replies error card: a shared `LoadErrorView` on the channel
timeline, forum thread and forum posts list, and an icon button beside
the thread replies summary. While the thread query has no value, the
thread page shows the route snapshot plus live, cached and optimistic
replies, with the loading or error status still visible.
- Other re-sends (reconnect, foreground resume, live events, the unread
backstop, scroll) are not suppressed; each is bounded by the relay's 20
s statement deadline.
- **Long-thread seeder.** `just seed-long-thread [replies]` posts one
thread (default 187 replies) with some reactions to the local dev relay,
for reproducing long threads in the apps. It uses a throwaway key,
refuses non-loopback relays and ignores ambient `BUZZ_*` credentials.
Writes are paced to fit the stock relay rate limit.

**Lifecycle concurrency.** The lock serializes one connection's
subscription changes. Other connections can still be delayed by pressure
on the shared pubsub command queue, and by serial eviction loops waiting
behind a blocked connection. This PR does not claim strict isolation
between connections.

**Recovery vs. load.** Clients stop automatic retries of the same
one-shot read, but they don't remember deadlines across other triggers:
reconnect, resume, live events and the unread backstop can each send the
read again later, bounded by the 20 s statement deadline. A live
subscription that hits the deadline resubscribes with its original
filter under the existing 1–30 s backoff, and against a filter that
keeps timing out it retries at the 30 s cap indefinitely, so the worst
case is one bounded query per subscription every 30 s. `since` is
deliberately not advanced to now on resubscribe, because that would skip
events missed while the subscription was closed. Unchanged from `main`:
when mobile's channel history sync fails with cached messages available,
the cached messages are shown without the error or Retry control.


### Benchmark

Local reproduction on an isolated bench stack (not committed):
- PG16, migrations applied, one 188-reply thread plus 5M background
events in the same community.
- Each request replays the desktop thread-replies filter through the
relay, with `include_aux` on. Times are total ms for 3 runs, with a 60 s
client cap.

GIN pending-list state is controlled with `VACUUM` (clean) or by
refilling the pending list (dirty), and recorded per partition with
`pgstatginindex`. Dirty runs held 402 pending pages on every partition's
tags index at the start and end of each matrix. Clean runs held 0–1
pages (relay startup writes).

| build | GIN pending state | limit 10 | 50 | 100 | 200 (188 ids) |
|---|---|---|---|---|---|
| `main` | clean | 26 / 16 / 13 | 56 / 51 / 52 | 92 / 93 / 95 | 662 /
639 / 641 |
| `main` | dirty | 315 / 298 / 327 | 22141 / 15888 / 16769 | 28992 /
27819 / 28080 | timeout ×3 (>60 s) |
| this PR | clean | 35 / 21 / 14 | 57 / 49 / 48 | 82 / 80 / 75 | 153 /
150 / 135 |
| this PR | dirty | 290 / 254 / 251 | 1257 / 1204 / 1206 | 2432 / 2378 /
2382 | 4333 / 4706 / 5104 |

Baseline `origin/main` `b37e47721`; this PR at `5f2650f5f`, whose e-tag
read builders are identical to the current head. At head `1ad81d868`
against `origin/main` `15d44dcce`, limit 200 measured 145–179 ms clean
(`main` 683–715) and 4.41–4.51 s dirty (`main` timeout ×3).

Every completed cell returns the same signed event set: 209 events, 187
replies at limit 200. With `include_aux` off, every cell is ≤66 ms. The
thread-window loader (`select_aux`) is unchanged from `main`, and its
timings and captured plans match `main`'s.

Actual aux-hop statements captured from each relay binary with
`auto_explain` (analyze + buffers), execution ms:

| build | clean, 100 ids | clean, 188 ids | dirty, 100 ids | dirty, 188
ids |
|---|---|---|---|---|
| `main` (N-way `OR`) | 40 | 298 | 28785 | 62892 |
| this PR (`MATERIALIZED` fence + `ANY`) | 23 | 45 | 1992 | 3539 |

**What the fence changes**
- On `main`, the planner combines `ORDER BY created_at … LIMIT` with an
overestimated match count and walks each partition's primary key
backward, expecting to stop early. With few real matches it scans the
whole community: the dirty 188-id statement touches ~4.9M shared buffers
on `main` vs ~0.6M with the fence.
- The fence prevents that early-stop plan. Across the measured cases
(1–188 ids, both pending states), the planner used the GIN tags index.
The fence doesn't force GIN in general; a broad enough filter could
still get a different plan.

**Tradeoff:** the fence materializes every match before sorting. In this
bench small limits stayed within run-to-run noise of `main`. A single
hot id (5,000 reactions, limit 100) measured 30–34 ms unfenced vs 35 ms
fenced.

**Deadline:** each e-tag read (SELECT and COUNT) runs under a
transaction-local `statement_timeout` of 20 s. It only tightens: a
shorter operator timeout (`BUZZ_DB_STATEMENT_TIMEOUT_MS`) is kept. It's
a per-statement limit, not a per-request one, so a request that runs
several pages or hops can take longer than 20 s in total. The benchmark
does not force the deadline.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-09-28 16:36:40 -04:00
Will Pfleger
f7ff4294fa feat(nip-fi): harden Blossom kind-24242 verifier to NIP-FI spec (#7288)
Brings `buzz-media`, `buzz-relay`, all first-party kind-24242 minters,
and desktop token minting into compliance with [NIP-FI
§kind-24242](https://github.com/block/buzz/blob/main/docs/nips/NIP-FI.md).

## What changed

**`buzz-media/src/auth.rs`**
- Add `BlossomStrictness` enum (`Strict` | `Permissive`). `Strict`
applies full NIP-FI rules (active modes); `Permissive` preserves
pre-NIP-FI Off-mode behavior byte-identical [FI-INV-15].
- Rewrite `verify_blossom_auth_event_for_verb` with count-based
cardinality: exactly one `t`/`expiration`/`server` in `Strict`;
duplicate `x` also rejected. `Permissive` keeps main's boolean semantics
exactly: any valued `t` other than the verb (including `""`) rejects,
valueless `t` is ignored.
- `Strict`: mandatory `server` tag on all proofs (upload + read); absent
or mismatched → `evidence_rejected` (`ServerMismatch`).
- `Strict`: 60-second proof window (`now - created_at <= 60s`,
`expiration <= created_at + 60s`). `Permissive`: 3600s / optional
server.
- **[F1]** Permissive server validation uses any-match semantics: a
proof with multiple `server` tags is accepted if any tag matches the
bound host. This restores origin/main base behavior. Strict
duplicate-rejection unchanged.
- **[F1 compat]** Permissive server gate uses `server_values.is_empty()`
(valued tags only) rather than `server_count > 0` (includes valueless
tags). A lone valueless `["server"]` tag is treated as absent in
Permissive/Off mode, matching pre-NIP-FI base behavior [FI-INV-15].
- **[F4]** Valueless `x` tags in Strict get reads are tracked before
`filter_map` so they cannot become absent scope (host-wide
authorization). Any `x` tag present in Strict must contain the exact
requested sha256.
- **[F3 unit]** Cases A–D in `auth.rs` are helper-level unit tests: they
call `verify_blossom_upload_auth` and `verify_upload_hash_only`
directly. They do not enter the upload pipeline
(`process_upload`/`process_video_upload`). Retained as helper evidence.

**`buzz-media/src/error.rs`**
- Add `DuplicateTag(&'static str)` variant.
- Add `blossom_denial_kind()` method: classifies each auth error as
`MissingEvidence`, `EvidenceRejected`, or `AuthorizationDenied`.
Non-auth errors return `None`.
- `IntoResponse` retains the legacy JSON 401 shape [FI-INV-15].

**`buzz-relay/src/api/media.rs`**
- Add `MediaDenial(MediaError, BlossomStrictness)` wrapper mapping to
byte-exact NIP-FI fixed responses in `Strict`; falls through to legacy
JSON in `Permissive`.
- `blossom_strictness_from_state()` selects `Strict` when
`state.config.nip_fi.is_enforce()`, otherwise `Permissive`. Strictness
is threaded through the `admit_nip_fi_http_on_state` NIP-98 closures on
upload and read, and through the post-admission X-SHA-256, hash-binding,
and membership denials.
- `extract_blossom_auth` keeps first-value `Authorization` extraction;
Enforce rejects repeated fields at the `admit_nip_fi_http` cardinality
gate, and Off keeps legacy behavior [FI-INV-15].
- Both membership-denial call sites use
`media_denial(RelayMembershipRequired, strictness)` so Strict mode
produces the correct fixed-text response.
- `upload_blob` serving-write fence conversion (`:406`) uses
`map_err(|e| media_denial(e, strictness))` — a community fence committed
between auth admission and lease acquisition no longer resets Strict to
Permissive.
- Mode-driven regressions in `mod postgres_tests` (selected by the
nextest `postgres-ci` profile via `test(/postgres_tests::/)`, `#[ignore
= "requires Postgres"]`), run through the full relay router with a real
Enforce verifier and assertion: Enforce rejects Permissive-only proofs
(300s lifetime, missing `server` tag) on both upload routes and reads
with `evidence rejected`; Off accepts the same proofs with pre-NIP-FI
responses; DenyProtected answers 503 before any proof check; membership
denials are `authorization denied` text/plain in Enforce and exact
legacy JSON 403 in Off, with a member positive control. GET and HEAD are
each discriminated (HEAD with a suppressed body). Both upload routes pin
the handler's `X-SHA-256` missing/malformed/unsigned and post-body
hash-mismatch denials in both modes. Off rejects an empty-valued `t`
beside a valid one with legacy 401 JSON in either order, and ignores a
valueless one. Exact status, Content-Type, challenge, and body bytes are
pinned.

**`buzz-media/src/upload.rs`** (`mod minio_tests`)
- **[F3 pipeline]** Four tests exercising the actual post-body call
sites (`process_upload` `:85`, `process_video_upload` `:413`) with real
MinIO I/O: buffered/streaming × expired-proof-accept +
mismatched-hash-reject. The positive cases use
`fresh_strict_upload_auth` (`created_at = now-58`, `expiration = now+2`,
lifetime = 60s) — Strict-admissible at sign time — followed by a real 3s
sleep so the proof is expired when the upload completes.
`nostr::Timestamp::now()` reads the OS wall clock directly; paused Tokio
time does not advance it. The negative cases use a pre-expired proof
with a mismatched `x` tag. All four are `#[ignore = "requires MinIO"]`;
no CI lane runs them automatically.

**`buzz-media/src/validation.rs`**
- `minimal_valid_mp4()` promoted to `#[cfg(test)] pub(crate)` — a
618-byte pre-computed H.264/avc1 fast-start MP4 available to all
`buzz-media` test modules; smoke-tested by
`test_minimal_valid_mp4_passes_validate_video_file`.

**`desktop/src-tauri/src/commands/media.rs`**
- `sign_blossom_upload_auth`: `server` tag mandatory; upload expiry 60s
(was 3600s/300s).
- `MEDIA_GET_AUTH_EXPIRY_SECS`: 60s (was 600s).

**`desktop/src-tauri/src/media_proxy.rs`**
- Single re-mint+retry on 401/403 for range requests; non-auth failures
do not retry.

**`crates/buzz-cli/src/client.rs`**
- Read expiry: 60s (was 600s). Upload expiry: 60s. `server` tag
mandatory on upload.

**`crates/buzz-dev-mcp/src/view_image.rs`**
- `MEDIA_GET_AUTH_EXPIRY_SECS`: 60s (was 600s). Test updated.

**`mobile/lib/shared/relay/media_auth.dart`**
- `_mediaGetAuthLifetimeSeconds`: 60 (was 600). Refresh margin updated.

**`mobile/lib/shared/relay/media_upload.dart`**
- `_uploadAuthLifetimeSeconds`: 60 (was 300). `server` tag mandatory.

**`mobile/lib/features/channels/media_viewer_page/video_viewer.dart`**
- **[F2]** Remove Android-specific `VideoPlayerController.networkUrl`
streaming path. `video_player_android` 2.9.5 freezes headers into static
`DefaultHttpDataSource` request properties — a 60s proof minted at
controller creation time becomes stale on seeks. All platforms use the
authenticated local-file download path.
- **[F2 transport]** `unawaited(request.sink.close())` before
`client.send()`. `AbortableStreamedRequest` carries no request body;
without closing the sink, `IOClient.send()` awaits
`stream.pipe(ioRequest)` which blocks until the sink ends — every video
download hung in loading indefinitely on every platform.
- **[F2 pending-ctrl]** `pendingController` ref set before the first
`await (initialize())`, cleared in all exit paths. Effect cleanup
disposes `pendingController.value` so a close-during-init releases the
native player even if the initialized event never arrives.
- **[F2r(a)]** On native init failure (post-create,
pre-initialized-event), the inner catch starts disposal detached via
`unawaited(localController.dispose().catchError(...))` — the
`.catchError` handler absorbs any disposal exception so it does not
reach the test error zone — then rethrows the original load error.
`video_player` 2.11.1 completes the init future with an error without
disposing the native player.
- **[F2r(b)]** Non-2xx error body cancelled via `_cancelVideoResponse()`
(subscribe+cancel) instead of `drain()`. `drain()` waits for the
upstream to close; a stalled server body blocked `initializeVideo`
indefinitely.
- **[F2r(d)]** `unawaited(localController.dispose().catchError(...))` in
the catch — not `await`. Two failure mechanisms exist: (1) when
`createWithOptions()` throws, `_creatingCompleter` is never completed
and `await dispose()` deadlocks at `:682-683`, blocking the outer catch
from setting error state. (2) when `createWithOptions()` succeeds but
initialization fails, `dispose()` runs but its thrown exception becomes
an unhandled async error reaching the test zone. Both are fixed:
`unawaited` lets the outer catch run immediately and set `error.value`;
`.catchError(...)` on the detached disposal future absorbs any disposal
exception.


**`mobile/test/features/channels/media_viewer_page/video_viewer_test.dart`**
- `_FinalizingFakeClient`: calls `request.finalize().drain()` — a test
without the sink fix times out because the drain never completes,
validating the transport contract.
- F2r(a): `forceInitError=true` fake emits `PlatformException` on
subscribe; `disposeCallCount >= 1` verifies disposal even on init
failure.
- F2r(b): `stalledBody` `StreamController` with `onCancel` Completer
(bounded completion signal, not a sleep). Asserts: (1)
`bodyStreamCancelled=true` while body stream is still open (impossible
with `drain()`); (2) error UI visible while body remains open. Restoring
`drain()` breaks both.
- F2r(c): `neverInitialize=true` fake never sends initialized event;
unmount asserts `disposeCallCount >= 1` via `pendingController`.
Distinct from the event-error case.
- F2r(d): `forceCreateError=true` fake throws `PlatformException` from
`createWithOptions()` itself (before any player ID is returned). Asserts
error UI visible within a bounded deadline. With the old `await
dispose()` the outer catch is blocked — `error.value` is never set and
the error UI assertion fails at the deadline; with
`unawaited(dispose())` the error text appears immediately.


**`mobile/test/features/channels/media_viewer_page/video_viewer_transport_test.dart`**
(separate file)
- Real-IO loopback probes using `flutter_test` (not `package:test`) to
avoid `depend_on_referenced_packages` lint. No
`TestWidgetsFlutterBinding` — no HttpClient override interfering with
`dart:io`.
- Probe 1: `HttpServer.bind(loopbackIPv4, 0)` reads the full request
body before replying. Without `sink.close()`, `req.drain()` hangs and
the test times out.
- Probe 2: server signals request arrival via a Completer (not a sleep);
abort fires after confirmed arrival; `client.send()` must throw
`RequestAbortedException` (typed assertion) within a bounded 5s
deadline.

**`scripts/test-video-upload.sh`**
- Proof minted with `+60` expiry and mandatory `server` tag.

**`buzz-test-client/tests/e2e_media*.rs`** +
**`buzz-relay/src/api/media.rs` test fixtures**
- All fixtures updated to 55s expiry + `server` tag.

**`RELEASING.md`**
- New "NIP-FI upload proof freshness (relay floor)" section documenting
that 60s proofs require the first relay release carrying this PR's
post-body hash-only check (the first tag > `v0.2.1` — update once cut)
for reliable large-file uploads. Operators on older self-hosted relays
(≤ `v0.2.1`) must upgrade before receiving these clients.

## Finding 3: 60s upload proofs vs old-relay compatibility

Desktop/CLI/mobile upload proofs are now 60s. Older relays re-run full
expiry verification after the body is transferred. A valid upload whose
body transfer takes >60s will fail on an old relay.

**Resolution (relay floor prerequisite):** `RELEASING.md` requires that
verified supported deployments (relay releases confirmed to carry the
post-body hash-only check, commit `75e9bef748`) be in place before
distributing 60s-proof client builds. Unknown or unverified self-hosted
relay deployments are excluded from distribution until they upgrade.
This makes the verified-deployment status a prerequisite for release
distribution, not a post-distribution advisory. The concrete tag (`>
v0.2.1`) is to be recorded once cut.

## Sequencing note

Builds on #7264 (merged): Blossom strictness is derived from its
`config.nip_fi` mode in this PR, so enabling `BUZZ_NIP_FI_MODE=enforce`
activates the strict verifier with no further wiring.

## GIF compatibility exception

An unauthenticated Off-mode request on a configured tenant with no GIF
provider changes from 404 to 401 (base `gifs.rs:269-275` checked an
unconfigured provider first; the current code authenticates first). All
other Off-mode Blossom responses are byte-identical to pre-NIP-FI
behavior.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-09-28 14:46:56 -04:00
Will Pfleger
28a8b90805 fix(mobile): keep retired sections manager out of successor cache (#7873)
Follow-up to #7806.

- **A retired sections manager never writes the shared prefs cache.**
`_persist()` now returns once the manager is disposed. That covers every
path a torn-down manager could take after an account or relay switch,
including a late OK restoring its submitted layout and the `dispose()`
flush's read-before-write merging a head. Before this change, both could
overwrite the successor's cache on the same pubkey key.
- **Coverage:**
- a retired late OK and a retired flush pre-read both leave the
successor's cache untouched
- an edit made while an OK is held still publishes after the OK is
released (`_lastPublishedStore` is built from the submitted store)
- a same-second losing head adopted after a local edit still converges
UI, cache and relay once our OK wins. The adopted layout's pending
debounce is deliberately left armed, because its republish is what
re-converges this race.

**Residual:** an edit made just before our own echo arrives can be
replaced by that echo. This is existing behaviour on main and on
desktop.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-09-24 15:12:36 -06:00
Will Pfleger
309e34d80f fix(mobile): converge sidebar managers on relay head with resume re-read (#7806)
Mobile half of the sidebar stale-reader fix (desktop: #7805). It makes
the channel sections, sort, stars and mutes managers converge on the
latest relay head.

## Changes

- **Tie-break:** sections, stars and mutes compare `(created_at, id)`
with `id < cursor`, which matches the relay's `ORDER BY created_at DESC,
id ASC`. Sort already did this.
- **Coherent cursor:** `_publish()` records the signed event ID through
`SignedEventRelay.submit(onSigned:)` and advances
`(_lastRemoteCreatedAt, _lastRemoteEventId)` together. It only advances
when the pair is after the current cursor, so a newer head learned
during the await is kept. This applies to sections, stars and mutes.
- **Stars/mutes startup retry:** one serialized `_syncWithRelay()`
(`_syncInFlight`/`_syncAgain`) with 2s→30s backoff. It retries until the
history head is applied and the live subscription is settled. The
stars/mutes managers no longer re-subscribe on CLOSED:
`RelaySessionNotifier` owns retryable and rate-limited CLOSED, and a
terminal CLOSED is never re-sent. Reconnect works through session replay
and provider rebuild. Sections and sort keep their existing manager
CLOSED retry.
- **Stars/mutes durability:** storage `write()` returns the
`setString()` result. A decode or apply error, or a persist that returns
false or throws, fails the attempt. The accepted head only advances once
it is durable, so the same head is retried.
- **Stars/mutes pending fence:** each read snapshots `_localRevision`
and `_publishPending`. If a local edit or a publish happened while the
read was in flight, the result is discarded. Startup recovery retries
it; a resume read does not. Per-entry arbitration is unchanged.
- **Sections publish commits content with its cursor:** the submitted
blob is committed together with its cursor, so a same-second loser
applied during the OK wait can't strand the device. A local edit made
during the wait is kept.
- **Stars/mutes undecodable head:** an undecodable head is skipped
without holding startup open. Only a persist failure keeps the head
unapplied and retried.
- **Resume re-read:** all four providers listen to
`appLifecycleProvider`. On `resumed`, each manager runs one guarded
history read through its existing fetch/apply path. It is one-shot:
skipped when a local edit or publish is pending as it starts, not
retried on failure, and it starts no timers or poll loop. The result is
discarded if a mutation lands before it applies, and a disposed or
superseded manager (sort generation fence) never persists it.

## Residuals

- If the app stays in the foreground the whole time and a healthy socket
drops an EVENT, the missed head is picked up on the next resume or
reconnect.
- Sections: a live event can still overwrite a pending local edit. This
is pre-existing, and the resume read adds no new path to it.
- Sort: a greater remote timestamp wins, by policy.
- Stars/mutes: entries with equal `updatedAt` can still resolve
differently on different clients, under the existing merge policy.
- Stars/mutes: publish reads first and keeps the entry with the later
`updatedAt`, so a peer whose clock runs fast can override a pending
local entry. This rule already exists on main and matches desktop.
- Sections and sort persistence failures are pre-existing and not
redesigned here.

## Size

Production: 10 files, +495/−162. Tests: 3 files, +718. The new
`sidebar_sync_fixture.dart` runs over the real `RelaySessionNotifier`
seam and is shared by `channel_reader_recovery_test.dart`. Plus a
sections tie-break case.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-09-24 10:16:14 -06:00
Tom Brow
a61239f0d8 fix(mobile): avoid opening empty threads on message tap (#7756)
Tapping a mobile channel message with no replies currently opens an
empty thread. Only enable message-row thread navigation when the
existing thread summary reports replies, including replies known to the
relay but not loaded locally. Long-press → Reply still starts a new
thread.

Added widget tests for zero replies, loaded replies, relay-only replies,
and starting a thread through Reply. The zero-reply test failed before
the fix and passes afterward.

Validation: all 227 channel-page tests, the full mobile pre-push checks,
and all PR CI checks passed. The full repository gate failed in
unchanged Rust ACP timing tests (idle reset, keepalive, and steering
deadlines); this PR changes only mobile Dart files.

No duplicate open PR or issue found.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-09-21 08:51:05 -07:00
Tom Brow
ef2aa1ae38 fix(mobile): preserve thread replies through refresh failures (#7757)
## Summary

A failed mobile thread refresh could briefly remove replies and show “0
replies” even though Riverpod still retained the previous result. Read
the retained reply value directly and merge it with pending local
replies through errors and retries. When a reopened thread has no
retained result, show loading or failure instead of a false zero count.
Error status remains visible alongside retained replies.

No additional cache or retention is introduced. Current main
intentionally disposes thread queries on close; reopening still fetches
fresh data. The recorded symptom is reproduced by the failed-refresh
test, but the recording alone does not establish its network trigger.

### Related issue

Related to #4831 (thread refresh and disposal behavior), incorporated
into main through #6691. No duplicate for this rendering defect found.

### Testing

- Reproduced disappearing cached replies in widget tests before the fix;
the new retry/recovery, terminal-error, reopen/loading, initial-error,
and empty-result tests now pass.
- Provider coverage verifies cached and pending local replies survive a
failed refresh and confirmation still clears the local overlay.
- All five new widget regressions, the existing
hydration/scroll-position regression, all nine thread-provider tests,
Flutter analysis, and the final-head full mobile pre-push gate passed
(2,261 tests passed; four skipped).
- Phone-width and large-text rendering checked with synthetic messages.
[Before/after
screenshots](https://github.com/block/buzz/pull/7757#issuecomment-5743469556).
- All final-head GitHub checks passed, including mobile tests, Android
debug APK, iOS simulator app, and unsigned iOS release builds.
- Codex code review and security review are clean for `4e5f606ae`; the
earlier accessibility finding is fixed and resolved. Builderbot review
was requested but has not returned a result.
- Broad local `just ci` could not finish: unchanged ACP timing tests
failed, including `keepalive_resets_idle_past_deadline` on the
lower-concurrency retry. The separate ACP run passed all 941 tests.
Initial parallel mobile runs also hit timing flakes; the final-head full
mobile gate passed. No unrelated native code was changed.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-09-19 11:00:47 -07:00
Tom Brow
4ee9f8b35a fix(mobile): keep iOS message menu actions responsive after rebuilds (#7758)
Long-pressing a message while the keyboard dismissed could leave the iOS
action menu visible but unresponsive. Rebuilds installed a new selection
handler before the previous hook's cleanup removed it. Keep that handler
for the native view's lifetime and dispatch to the latest callback. Also
let taps on decorative action icons reach the row control.

Added regression coverage for callback rebuilds, view
replacement/dismissal, and native icon/label/edge hit targets. The
callback regression fails before the fix and passes after it.

Validation: all 2,257 mobile tests (4 skipped), three unconfigured-push
tests, mobile analysis, 42 focused action/handler tests, and three
native XCTest checks passed. Physical iOS 26.5 taps verified
keyboard-open long press, Edit, Copy (including clipboard contents),
repeated opening, and outside dismissal. Desktop tests/builds, Tauri
checks/tests, and the web build passed. The full local gate encountered
unrelated Rust agent timeout failures; all 105 tests in the affected ACP
test module passed when rerun serially.

GitHub CI passed, including mobile tests, Swift tests, Android build,
and iOS simulator/release builds. Codex code review completed without
findings.

No matching open issue or PR found.

### Before
Synthetic message using the production long-press/menu components: Edit
and Copy taps leave the menu open; tapping outside still dismisses it.


https://github.com/user-attachments/assets/5e062d38-cbca-4be2-98b5-ca9a47b89df3

### After
With the keyboard open, the first Edit icon tap opens the editor.
Reopening and tapping Copy copies the message and dismisses the menu.


https://github.com/user-attachments/assets/49e11bfd-d531-4fa1-b551-5abd4fec83d7

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-09-19 10:25:18 -07:00
Tom Brow
4af2324d85 fix(mobile): keep relay sessions stable during push lease updates (#7745)
Enabling push notifications could repeatedly reset the relay session:
reserving a lease generation updates community metadata, which rebuilt
the relay configuration even though the connection identity was
unchanged. This made the avatar and inbox flash and prevented the new
lease from publishing.

Select only the community ID, relay URL, and signing identity as
configuration dependencies. Push bookkeeping keeps the session intact;
real community, URL, and identity changes still notify listeners.

### Validation
The production-provider regression test failed before the fix and passes
afterward; 48 focused mobile tests and static analysis pass.

A personal signed profile build on a physical iPhone published a fresh
lease and received a background notification with the actual message
text. The user confirmed the flashing stopped and tapping the
notification opened the correct community/channel and highlighted the
exact message.

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-09-18 12:10:26 -07:00
Tom Brow
779af8886c fix(mobile): fail open when age checks are unavailable (#7714)
Age-check errors previously blocked Buzz startup. Normal app startup now
proceeds immediately; only a valid under-18 response from the current
launch request can restrict access.

Age check enforcement remains disabled at compile time by default.
Builds can opt in with `--dart-define=BUZZ_AGE_GATING_ENABLED=true`.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-09-17 16:44:08 -07:00
Tom Brow
d1cae93c1a fix(mobile): temporarily disable age gating (#7708)
Disable the mobile launch age gate to restore access for users locked
out after updating. The production provider now allows access
immediately without calling the platform age API, while preserving the
existing push snapshot restoration path.

This is a temporary disable of age enforcement on both mobile platforms.
The original gate implementation remains available for a separate
follow-up.

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-09-17 16:30:20 +00:00
Tom Brow
b36600fc44 feat(mobile): show build number with version in settings (#7697)
Mobile Settings now identifies the installed build as e.g. `v0.16.0
(432)` rather than just `v0.16.0`. If the platform supplies no build
number, the footer keeps the existing version-only display.

| Before | After |
| --- | --- |
| Marketing version only | Version and native build number |
| ![Before:
v0.16.0](https://raw.githubusercontent.com/block/buzz/e8449f63d1fabe80ff86d395ac1177912c5d1bc5/pr-7697--before.png)
| ![After: v0.16.0
(432)](https://raw.githubusercontent.com/block/buzz/e8449f63d1fabe80ff86d395ac1177912c5d1bc5/pr-7697--after.png)
|

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-09-16 20:29:42 -07:00
Tom Brow
b8aa0233f9 perf(mobile): verify notification cache events in background (#7650)
On iOS, Buzz stores verified profile and channel information for the
notification extension. The extension uses this cache to show sender and
channel names. Loading profiles or channels also updates the cache.

Cache updates currently check event signatures on the UI thread, even
when the caller does not wait for the update. This can delay taps and
screen updates. This change moves those checks to a background worker.
Signature checks, event selection, community boundaries, and the order
of native writes are preserved.

The worker accepts up to eight batches. When it is busy, later profile
requests stay coalesced before fetching; profiles already loaded remain
available immediately. Overlapping channel updates trigger a fresh
joined-channel cache snapshot after the current export finishes, without
delaying or restarting the channel screen. Queue saturation gets up to
five retries per operation. Pending updates get their own recovery
attempt even if the preceding export fails. Recovery fetches
joined-channel data once and retries cache admission without repeating
relay reads. It does not fetch the open-channel directory. Verified
payloads are split into native-sized writes (256 profiles or 512 paired
channels), keeping the same export ordering. Native profile/channel
write failures retry the same verified chunk while retaining the
export's queue position. All chunks share five retries with 7.75 seconds
total backoff; other native errors fail immediately. Terminal failures
are recorded, and later successful writes do not hide them. Captured
batches keep their original community. Pending channel refetches are
retired on a community switch and refreshed when the user returns. The
cache remains best effort; prolonged failures can still prevent
delivery.

Tests through the production APIs reduced UI delay from 27–31 ms to
below 0.3 ms for four profiles, and from 59–60 ms to below 0.3 ms for
four channel and membership pairs. The native payloads were identical.
This does not measure overall screen speed.

Validation: 2,225 mobile tests passed (one skipped), along with 18
focused export tests, 12 recovery tests, 39 native Swift tests, 35
focused profile tests, and static analysis. Production regressions cover
13-entity bursts, invalid newer events, community rebuilds, reentrant
refreshes, and terminal failures. Regression tests fail when producer
recovery controls, terminal-failure draining, or member-only recovery
scope are removed.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-09-15 23:01:46 +00:00
Tom Brow
76f7e5ef0c feat(push): make gateway origin configurable (#7261)
Make the push gateway origin configurable across the relay, gateway,
mobile client, and deployment chart.

This is a fresh deployment. The gateway has only run on personal
development infrastructure, and push has no existing enabled users.
Legacy migration, configured-to-unconfigured mobile transitions, and
rolling upgrades from pre-launch gateway binaries are not supported.

Populated pre-launch databases are rejected with an informative error
before migrations run; their data is untouched. Mobile builds may omit
the gateway URL, leaving push unavailable. Once push is enabled,
subsequent builds must retain that configuration.

### Validation

- All applicable CI checks pass at
`5f546cfaf403f0386f146d213adfb4392f4ec29c`, including Rust,
mobile/Swift, desktop/integration, security, image builds, and Helm
checks. An unrelated agent cancellation test passed on retry.
- PostgreSQL CI passed 384 tests, including the production-entry-point
regression for refusing populated pre-launch databases before
migrations, fresh initialization, and subsequent initialized
deployments.
- Focused local validation passed all 10 gateway PostgreSQL tests under
the CI discovery filter and all 3 unconfigured-mobile tests.
Repository-wide PostgreSQL discovery validation and the complete
pre-push hooks passed.
- Physical-device APNs/App Attest behavior and deployment to a live
cluster were not verified in this work.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
2026-09-15 14:56:45 -07:00
Tom Brow
41c5ace937 perf(mobile): move profile sig checks off main thread (#7648)
The mobile app loads user profiles to show names, avatars, and agent
ownership. Some profiles include a signature that proves who owns the
agent. Checking these signatures for a batch of profiles can block
interaction because the checks run on the same thread that draws the
screen and handles input.

This change moves profile batch parsing and signature checks to a
background worker. It processes one batch at a time and combines queued
profile requests. It keeps the same signature checks, preserves newer
profiles that arrive while a batch runs, and discards results after a
community change. Individual live profile updates still use synchronous
verification.

Tests cover newer live updates, community changes, overlapping requests,
stale malformed events, and UI timer responsiveness through the
production parser. The responsiveness test fails if parsing is moved
back to the UI isolate. A regression also exercises a confirmed profile
save while an older batch is paused, including preservation of verified
ownership. All 38 focused tests, all 2,132 mobile tests, and the
analyzer pass.

With 500 valid owner signatures, an AOT test reduced the main-isolate
timer delay from about 3.4 seconds to less than a millisecond. Total
verification time stayed similar. This measures verification work, not
overall screen speed.

GitHub CI passes. The full local test run failed on two timing-sensitive
ACP tests in unchanged Rust code; both passed isolated retries. All
other local checks passed, including the downstream tests and builds run
after that failure.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-09-15 20:00:37 +00:00
Tom Brow
5b9dbcb4c2 perf(mobile): avoid redundant message list sorting (#7647)
When new messages arrive in a mobile channel, the app updates the list
of loaded messages on the UI thread. It currently sorts that list twice.
The second sort is needed only when the app has retained messages opened
through a link to a specific message.

This change uses the first sorted list when there are no retained
messages to merge. It reduces work during message bursts and keeps the
existing merge and ordering behavior for message links.

A controlled debug test with 2,000 loaded messages and 1,000 incoming
messages reduced processing time from 446 ms to 266 ms. This measures
the message update code, not screen rendering or release-device
performance. All 26 focused tests pass, including a regression test that
fails if the extra pass returns. Tests also cover message order and
retained messages.

Repository checks and downstream test/build suites passed, apart from
unrelated Rust timing failures in the full local run; each passed an
isolated retry. GitHub CI passed after retrying an unrelated desktop
attachment test failure.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-09-15 11:29:54 -07:00
Tom Brow
98a6db5d67 perf(mobile): avoid reparsing unchanged Markdown (#7649)
When a channel screen updates, Buzz Mobile can parse and format a
message again even when its text has not changed. This repeated work
runs on the same thread that handles taps and updates the screen.

The current Markdown library, `gpt_markdown` 1.2.1, checks whether the
objects used to render mentions, emoji, and channel links have changed.
Buzz creates new objects on every message rebuild, so the library parses
the message again. This change reuses those objects while their inputs
stay the same. Changes to labels, mention targets, emoji, media details,
or available actions still update the message. Taps continue to use the
latest action handlers.

In a synthetic test with the production message widget, ten unchanged
rebuilds caused 30 parser calls before this change and zero after it.
The first render still requires parsing. This test measures repeated
parsing, not overall screen speed.

Validation:

- All 91 focused message tests and 2,127 mobile tests passed. The new
regression test fails with the original code. Tests also cover changed
message data and current tap actions.
- Static checks across the repository, desktop and Tauri tests, and
desktop and web builds passed. The full local test run hit an existing
Rust ACP keepalive timing failure. Its exact retry passed, and all
checks skipped after that failure were run separately and passed.

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-09-15 11:11:34 -07:00
Tom Brow
f78a32cf03 feat(mobile): hard-block under-18 users on the store age signal (#4665)
### What changed?

Buzz mobile checks the app-store age signal at launch and hard-blocks
the app behind a full-screen 18+ notice when the store says the
signed-in person is under 18.

No age signal (e.g. declined permission, indeterminate value,
unsupported OS versions) results in no block.

The age value is never persisted. It is read, used to compute one
boolean, and discarded. Nothing is written to disk, and neither the age
value nor the range is logged or sent to analytics.

Platform notes:
- **New dependency `com.google.android.play:age-signals:0.0.4`.** This
is the only way to read the Play signal. Its AAR declares `minSdkVersion
23`, below the app's 24, so it forces **no minSdk bump**, and it
self-declares its own consent activity, so **no AndroidManifest change**
is needed.
- **iOS entitlement and linking.** `DeclaredAgeRange` autolinks from the
import. `Runner.entitlements` now declares
`com.apple.developer.declared-age-range`, and the call remains behind
`#available(iOS 26.0, *)`.

### Why?

App stores now expose a declared-age range, and Buzz's Terms require
users to be 18 or older. This wires the store signal to that rule with
the smallest change that is defensible.

Absent signals, declined or undeterminable results, and unsupported OS
versions allow the app through. A transient native request failure does
not become evidence that access is allowed: the app remains gated until
a retry produces a result. Only an affirmative under-18 signal produces
the permanent restriction screen.

### How is it tested?

Added tests:

-
[`age_signal_provider_test`](https://github.com/block/buzz/tree/main/mobile/test/features/age_gate)
covers the 17/18 boundary, open-ended ranges, no-signal, transient
recovery, bounded retry exhaustion, malformed payloads, and request-once
behavior
-
[`age_gate_app_test`](https://github.com/block/buzz/tree/main/mobile/test/features/age_gate)
proves authenticated content remains unmounted while the signal is
pending or retryable, and verifies the retry path
-
[`age_restriction_page_test`](https://github.com/block/buzz/tree/main/mobile/test/features/age_gate)
asserts the block screen offers no bypass affordance
-
[`community_provider_test`](https://github.com/block/buzz/tree/main/mobile/test/shared/community)
verifies age restriction clears push state, persists inactive leases,
and retries a failed tombstone
-
[`AgeSignalPayloadTest`](https://github.com/block/buzz/tree/main/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile)
pins the Android success payload to exactly `{status, ageUpper}` and
verifies failures return the distinct retryable platform error

### Deployment note

Enable the Declared Age Range capability and regenerate signing profiles
carrying the entitlement. This relops/App Store Connect task is not
performed by this PR.

*🤖 This PR was authored [with an
agent](codex://threads/019e5128-536b-73b3-8348-88651123f6da).*

| Age restriction screen |
| --- |
| <img
src="https://github.com/user-attachments/assets/5b0f48b1-df9b-4a60-9800-80b33f128535"
alt="Age restriction screen" width="320"> |

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: npub12wpjffj7q5qjsky5jvk4ldwlxmse5xll3d8gytk4wqd0c5y7jvwspg37n6 <538324a65e0501285894932d5fb5df36e19a1bff8b4e822ed5701afc509e931d@buzz.block.builderlab.xyz>
Co-authored-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz>
Co-authored-by: Codex <noreply@openai.com>
2026-09-15 05:21:27 -07:00
Logan Johnson
d074576871 fix(mobile): bind same-name mentions to exact selected identities (#7385)
<!-- Draft PR body for #7385 · fix(mobile): bind same-name mentions to
exact selected identities · https://github.com/block/buzz/pull/7385 -->

🤖

## Summary

If two people in a channel share a display name, mobile couldn't tell
them apart in mentions: picking the second could overwrite the first's
selection, a rendered mention linked whichever same-name person matched
first, and a later rename or a shorter name could re-bind the text to
the wrong recipient. This PR binds every mention to the exact selected
identity:

- Each same-name selection keeps its own recipient instead of
overwriting by name; conflicting picks get a qualified label like `Name
(key…)`.
- The longest matching label wins, so a shorter or interior name can
never claim part of a longer one and steal its identity.
- Rendering resolves recipients by the signed identity key from the
event's tags — never from message text alone — so qualified labels stay
correct regardless of tag order and survive later renames; an untagged
ambiguous label blocks shorter mentions instead of silently re-binding.

Ports the landed Desktop exact-recipient behavior (see
`docs/mention-editor.md`).

### Related issue

- Fixes: N/A. No mobile issue; Desktop's landed exact-recipient fixes
are the reference this ports.
- Independent base (`main`). #7387 (child) persists these exact
selections in saved drafts.
- Landing note: branches in this series overlap in the composer — when
rebasing, keep exact/durable mention bindings, the explicit
invite/reference-only choice, the account/visit/revision fences, and
authorization before membership preparation and publication; don't
resolve conflicts by taking either side wholesale.
- Draft — not requesting merge yet; the security advisory run for this
range timed out without results (no verdict).

### Testing

- Regressions cover same-name collisions, prefix/overlap, removal, tag
order, and renames.
- At `acd841354a692243f1cb4c04059baad42f1d2abb`: `just mobile-check` and
full `just mobile-test` pass (2,082 tests). The earlier full `just ci`
receipt linked below is reused only for unchanged non-mobile
code/tooling, not claimed as a rerun at this head.
- Previous-head evidence: `just mobile-check`, the full mobile test
suite, and full local `just ci` all pass — receipts in the [exact-head
evidence
comment](https://github.com/block/buzz/pull/7385#issuecomment-5574635836).
- Verification is widget-test level; no native device or simulator run
is claimed.

To see it: mention two teammates with the same display name — both stay
distinct, the second shows a qualified label, and the rendered message
keeps both correct even after either renames.

### Screenshots

Flutter production-widget test renders — not native-device screenshots
or acceptance captures.

| Scenario | Before | After |
|---|---|---|
| Qualified mention chip for same-name recipients, at 200% text scale
(deliberate stress fixture) | ![Before: the short chip is followed by
the distinguishing key spilling out as raw
text](https://github.com/user-attachments/assets/f1e40acc-24f1-47f7-b2f7-e3ba2348c169)
| ![After: the full qualified label wraps inside the bounded chip,
keeping the @ and robot glyph
visible](https://github.com/user-attachments/assets/b949e44f-e3f6-4c72-98ff-68139b2e1ab3)
|

<details>
<summary>Capture provenance</summary>

Rendered by the Flutter widget engine in a `flutter test` run
(production widgets, production theme; no device or simulator). Before:
this PR's declared base `3c7f288c60d67df78577b237e27c3dfc8831aaa1`.
After: its head `39afd73b0adfde14164f4b10dbd089cb498312b6`.

</details>

Signed-off-by: Logan Johnson <loganj@squareup.com>
2026-09-11 11:19:17 -04:00
Logan Johnson
82656ffea0 fix(mobile): standardize public-key identity display on npub (#7493)
🤖

## Summary

In the mobile app, anyone who hasn't set a display name shows up as a
raw 64-character hex key (e.g. `3a5d4f9c…`) — unreadable, and
unrecognizable as the same identity across screens. Profile and Settings
also let you copy that raw hex. Nostr public keys have a standard
readable form — `npub1…`, the same encoding other Nostr apps and our
desktop app already display. This PR makes every mobile identity surface
render npub instead:

- **Unnamed people everywhere** — message and thread authors, reactions,
typing indicators, member lists, channel details, DM headers and tiles,
inbox, search, forum cards, Pulse notes and reply context, mention
suggestions, and invite rows — now show a compact npub label: first 8 +
last 4 characters of the full npub joined by an ellipsis
(`npub1abcd…wxyz`), the same truncation desktop uses. Previously these
showed truncated raw hex.
- **DM fallback avatars and blank names** — 1:1 DM tiles and headers key
their fallback avatar to the same non-self counterpart the label names,
including self-first participant order; a self-DM keeps its
hex-key-derived initial. Blank or whitespace-only display names fall
back to the compact npub instead of rendering empty, while nonblank
authored names render verbatim (padding included).
- **Profile sheet → "Copy public key"** now copies the full canonical
npub — never raw hex. When the identity string isn't a valid public key,
the copy tile is disabled, so a malformed key never reaches the
clipboard.
- **Settings → Identity (pubkey)** displays and copies the full npub; an
invalid identity reads "Identity unavailable" with copy disabled.
- **Invalid identities never leak truncated raw hex** into the UI
anywhere — they render a neutral "Unknown identity" label.
- **Unchanged on purpose:** display names and verified handles (NIP-05 —
the `name@domain` badge) still render as before. Unnamed avatars keep
distinct per-key initials, derived from the underlying hex key rather
than the npub — otherwise every unnamed key would render the same "N"
initial. Event IDs are not public keys, so they keep their hex
truncation (in Pulse's "Replying to", the parent author shows npub while
an event-id fallback still shows hex). The nevent share link, private
keys, and internal hex storage are untouched. Inputs that accept a key
(invite/member entry) accept both hex and npub and keep working in hex
internally.

### Related issue

N/A. Searched open issues/PRs for npub identity display on mobile —
closest related: none found. Desktop's parallel npub standardization
lives in the stacked desktop PRs (#7488 foundation, #7489 controls,
#7495 display surfaces); this is the independent mobile slice (based
directly on `main`, not on those branches).

### Testing

At exact head `5a620e420a1fd57d9d8011ac26434eed32fcf765` (base: `main`
`44316ff72`; 40 files, +1,345/−154):

- Full mobile suite: 2,098 tests passing (`cd mobile && flutter test`);
`flutter analyze` clean; `dart format --set-exit-if-changed .` clean —
the same checks CI runs.
- Widget/unit coverage at production seams: compact labels and hex-keyed
avatar initials for DM headers/tiles, member rows, mention suggestions,
and Pulse reply context; DM fallback avatars keyed to the labeled
counterpart (self-first order and self-DMs); blank/whitespace
display-name npub fallback with nonblank authored labels verbatim,
including the Activity inbox sender and profile-sheet heading (each with
its own empty/whitespace production-seam regression); full-npub copy and
disabled-copy semantics in profile and settings; invalid-key
suppression; and hex↔npub input round-trips.

Verified via unit and widget tests — no device/simulator validation is
claimed.

### Task provenance

Buzz channel: `1f0e4a3d-7e01-4efe-bb16-843b357f85c9`

Task:
buzz://message?channel=1f0e4a3d-7e01-4efe-bb16-843b357f85c9&id=86b34eb4bd84a1472419e9af22636c011c0fe273e3c196f967d7a36996e149b6

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-09 14:08:24 -04:00
TolgaCinisli
fa1b27bcc8 fix(mobile): style inline code with the app mono face (#6631)
## Summary

Inline code on mobile renders as **bold body text on a faint background
wash** — no monospace face, no chip, and it cannot wrap. #5257 diagnosed
this as a missing `highlightBuilder`.

That is no longer the right fix. `gpt_markdown` 1.2.0 deprecates
`highlightBuilder` (removal in 2.0.0), renders inline code as a real
chip, and adds `InlineCodeStyle` for restyling it. The package author
confirmed this on the issue. So this PR is an upgrade — 1.1.6 → 1.2.1 —
plus one theme declaration, rather than the builder the issue originally
asked for.

**Where the style is declared.** `GptMarkdownThemeData` goes in
`AppTheme._buildTheme`, which both `light()` and `dark()` call. That
reaches all four `GptMarkdown` call sites — `message_content`,
`transcript_item_widget`, `token_pill`, `custom_emoji_render` — so the
style is stated once instead of per widget. A widget-level
`inlineCodeStyle` would have covered channel messages only, leaving the
other three on the package's defaults.

**What is declared.** Face, size, ink, chip fill and outline — not the
face alone. A face name on its own leaves the rest on the package's
defaults, which put inline code at 14.1sp beside a fenced block's 13, on
a neutral `onSurface` tint rather than the app's code surface. In dark
that tint is *lighter* than the surface, while every other code surface
in the app is recessed, so the chip read as a different kind of object.
All of it now comes from one `CodeStyle` declaration that the fenced
block reads from too, so the two cannot be edited apart.

**Three adaptations the upgrade requires.** Each was found by running
the gate, not by reading the changelog:

1. **`imageBuilder` widened** to `(context, url, width, height)`. This
is a hard compile error, and it is **not listed in the package's
migration guide**, which states "nothing here stops code compiling".
Worth reporting upstream.
2. **`autolink` now defaults to `true`.** `normalizeBareLinks()` already
rewrites bare URLs into Markdown links before rendering, so both would
run. `message_content` opts out with `autolink: false` to keep current
behaviour exactly. The migration guide argues for dropping the
pre-processor instead — a better fix, but a behavioural change that
belongs in its own PR.
3. **`gpt_markdown.dart` now re-exports `markdown_config.dart`**, making
two direct imports redundant. `flutter analyze` reports `No issues
found!` on 1.1.6 and flags both on 1.2.1, so these warnings are new, not
pre-existing.

**Deliberately out of scope.** The three non-message call sites now
autolink bare URLs, since only `message_content` has a pre-processor to
collide with. Custom inline components (`_MentionMd`, `CustomEmojiMd`,
`_ChannelLinkMd`) could additionally declare `allScopesExceptLinkLabel`
— 1.2.0 offers it as the fix for a `WidgetSpan` chip going blank inside
a link label on iOS — but current behaviour is unchanged without it, so
that stays a separate change.

### Related issue

Fixes #5257

Duplicate scan: searched `gpt_markdown`, `inline code mobile`,
`highlightBuilder` and `InlineCodeStyle` across both PRs and issues. No
open PR touches inline code styling. #6135 (link labels) and #6166 (text
selection) also touch mobile Markdown but address different defects.

### Testing

Full gate, `just ci` — exit 0:

| Stage | Result |
|---|---|
| Rust (33 suites) | 4768 passed, 0 failed |
| Desktop | 5799 passed, 0 failed |
| Mobile | **2011 passed**, 0 failed |
| `flutter analyze` | `No issues found!` |
| Desktop + web build | ok |

Run on the branch with `main` merged in, so these numbers match what CI
builds.

**New regression test** — `renders inline code in the app code style`.
It resolves the `CodeTextSpan` the package tags inline code with, which
carries both the resolved `TextStyle` and the colours the chip behind it
is painted with, so face, size, ink, fill and outline are all asserted
rather than a widget's presence. It is negative-controlled: reverting
only the theme declaration fails it with

```text
Expected: a numeric value within <0.001> of <13.0>
  Actual: <14.1>
```

and dropping the declaration entirely falls back to
`packages/gpt_markdown/JetBrainsMono` — so the test measures the real
thing, and it would catch a future regression that silently drops the
theme extension.

The test passes `baseStyle: messageBodyTextStyle`, the style the message
surfaces actually use; the widget's own fallback is the smaller
`bodyMedium`, which would move the expected size.

The test finds paragraphs with `find.byWidgetPredicate((widget) =>
widget is RichText)`, not `find.byType(RichText)`: inline code renders
through `BidiRichText`, a `RichText` subclass, and `byType` matches
exact runtime types.

That is a hazard for any test that reads text back out of a paragraph,
and one landed after this branch was cut:
`message_content_custom_emoji_test.dart` arrived with #6996 and its
`code keeps literal emoji while adjacent known tokens render` case reads
a code span through `find.byType(RichText)`. It passes on `main` and
fails on the merge result, which is what CI builds, so it went red only
once CI was authorized. It now uses the same predicate. The two other
`byType(RichText)` call sites — the rest of that file and
`message_author_meta_test.dart` — were re-run and pass: their content
carries no code span, so the exact type still matches. They were left
alone.

### Screenshots

Rendered through the real `MessageContent` widget with the app's own
fonts loaded, at 390pt wide, 3x DPR. Sample text: ``Set `BUZZ_RELAY_URL`
before launch, then run `just mobile-test` to verify.``

| | Before (1.1.6) | After (1.2.1) |
|---|---|---|
| Light |
![before-inline-code-light](https://raw.githubusercontent.com/TolgaCinisli/buzz/2d2d846291416d9b32d3fb9cfead950bcc4fe123/pr-6631--before-inline-code-light.png)
|
![after-inline-code-light](https://raw.githubusercontent.com/TolgaCinisli/buzz/f230b95c7260a32bd5d76b1ac42130720a168521/pr-6631--after-inline-code-light.png)
|
| Dark |
![before-inline-code-dark](https://raw.githubusercontent.com/TolgaCinisli/buzz/2d2d846291416d9b32d3fb9cfead950bcc4fe123/pr-6631--before-inline-code-dark.png)
|
![after-inline-code-dark](https://raw.githubusercontent.com/TolgaCinisli/buzz/f230b95c7260a32bd5d76b1ac42130720a168521/pr-6631--after-inline-code-dark.png)
|

Before: bold Inter on a flat wash, no chip edge, and `just mobile-test`
breaks across the line with the wash simply ending. After: Geist Mono in
a bordered, rounded chip, and the wrapped fragment gets its own chip on
each line.

---------

Signed-off-by: Tolga Cinisli <tolgacinisli@gmail.com>
Co-authored-by: Tolga Cinisli <tolgacinisli@gmail.com>
2026-09-08 08:05:11 -06:00
klopez4212
6f5ec4a731 Add mobile voice notes (#7121)
## Summary
- add iOS and Android voice-note recording and preview directly in the
mobile composer
- add waveform playback, scrubbing, speed controls, haptics, and
one-shot playback in chat
- package recordings in a canonical H.264/AAC MP4 envelope on both
platforms so existing relays accept them
- preserve the shared composer interaction and attachment-card treatment
across mobile platforms

Mobile counterpart to #6978.

## Testing
- `just ci`
- `just mobile-check`
- `just mobile-test` (2,026 tests)
- Android debug build compiled, installed, launched, and Voice note
verified in the attachment menu on Pixel 10
- signed iOS device build installed on iPhone

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz>
2026-09-03 08:07:04 +00:00
Wes
4794a5cecf ci: relax file-size ceilings by surface (#6485)
## Summary

- raise Desktop Rust's differential file-size ceiling from 1,000 to
1,500 lines
- raise Desktop frontend and Mobile ceilings from 1,000 to 1,200 lines
- keep Web at 1,000 lines and preserve the existing no-growth ratchet
above each ceiling

## Why

The flat 1,000-line limit is forcing mechanical trimming in Desktop and
Mobile even for small cohesive changes. Surface-specific ceilings
relieve that pressure without granting Web or every authored component a
blanket 2,000-line budget.

## Testing

- `just file-size-check`
- `cd desktop && pnpm exec biome check scripts/check-file-sizes.mjs`
- `node --check mobile/scripts/check-file-sizes.mjs`
- `git diff HEAD^ --check`

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
2026-09-01 19:14:15 +00:00
Wes
2f3dd850db fix(relay): reject a frame on its own acknowledgement channel (#6961)
Pinky, an AI agent, updated this description on Wes's behalf after
taking over the startup investigation.

**Category:** fix

**User Impact:** An EVENT refused by WebSocket admission or handler
saturation receives a correlated `OK(event_id, false, reason)` instead
of an uncorrelated NOTICE, so the client can settle that refusal without
waiting for its publish timeout. Rate-limited refusals also arm client
backoff. This fixes a protocol failure mechanism; it does not establish
that every startup send will succeed or that the reported Desktop
startup incident is fully resolved.

**Problem:** Startup opens several live subscriptions and publishes at
once, and the relay's WebSocket admission gate is a fixed 5-second
window (`ws_admission_budget` = `human_ws_events_per_sec * 5`). If that
shared per-principal quota is exhausted, `enforce_ws_admission`
previously rejected an EVENT with a bare `["NOTICE", reason]`. Quota
pressure is a possible trigger, not proof of the original incident's
complete cause.

A NOTICE carries no event id. Both clients settle a pending publish
*only* from an `OK` keyed by event id (desktop `pendingEvents`, mobile
`_pendingEvents`), so nothing settled — and `handle_text_message`
returns early, so no `OK` ever followed either. The send **could not
fail**; it could only time out at `PUBLISH_TIMEOUT_MS` = 25s. That
explains how this rejection mechanism can produce a roughly 25-second
timeout; attributing the original report to it still requires the actual
startup/send workflow.

The handler-semaphore saturation path had the identical defect, and that
one needs no quota burst to fire.

**Solution:** NIP-01 gives each request type its own acknowledgement
channel, and a rejection is only actionable on the same one. Reject a
REQ with `CLOSED`, an EVENT with `OK(id, false, reason)`, and fall back
to `NOTICE` only where no per-request correlation exists. COUNT refusals
now also use `CLOSED(query_id, reason)` per NIP-45, covering both quota
admission and handler saturation (added in
`cd12c93804b87a24b61075dfd171dc471a0a527f`).

Reason strings are unchanged, so the `rate-limited:` prefix and `retry
in {N}s` hint that existing client gates parse keep working (desktop
`parseRateLimitHint`, mobile `RelayRateLimitGate`, buzz-acp
`set_rate_limit_gate`). Only the frame *type* changes, so
`docs/multi-tenant-relay.md` L7 stays satisfied.

Two notes on how this landed, both worth a reviewer's attention:

1. **A survived mutation became a design change.**
`send_admission_result` originally took a `RejectionTarget` parameter,
and reverting the *second* call site (the per-minute message quota)
survived the whole suite — with Redis unreachable the first quota check
short-circuits, so that line is unreachable in test. Rather than test
around it, the parameter is gone: the target is derived from the frame,
so no call site can name the wrong channel.

2. **The relay fix would have caused a client regression on its own.**
Gate arming lived only in the NOTICE branch. Once rejections arrive as
`OK:false`, `handleOk` failed the send without ever backing off — the
client would retry straight into the same quota. Desktop and Mobile now
arm on a `rate-limited:` OK rejection. ACP was subsequently fixed in
`3b06dd32493596ec650f20abf8805791c50fdc24`: it arms the gate and
re-parks only the refused observer frame, preserving other in-flight
frames. Desktop gets `activateRateLimitIfSignalled` as the single owner
of that prefix test, called from both `handleOk` and the NOTICE branch.

<details>
<summary>File changes</summary>

**crates/buzz-relay/src/rejection.rs** (new)
Owns the admission-rejection concern: `RejectionTarget`,
`rejection_target_for`, `request_rejection_message`,
`send_admission_result`, and `enforce_ws_admission`, moved out of
`connection.rs`. Six tests, two of which drive the real
`enforce_ws_admission` against a real `AppState`.

**crates/buzz-relay/src/connection.rs**
Fix the EVENT handler-semaphore rejection to correlate to the event id;
delegate admission to the new module. Add two tests that drive the real
`handle_text_message` with every handler permit held. Down from 1319 to
1116 lines.

**crates/buzz-relay/src/state.rs**
Widen the existing `test_state` helper to `pub(crate)` so the rejection
tests reuse it rather than adding a ninth copy of `AppState`
construction.

**desktop/src/shared/api/relayRateLimitGate.ts**
Add `activateRateLimitIfSignalled` — one owner for the `rate-limited:`
prefix test, since three inbound frame types now carry it.

**desktop/src/shared/api/relayClientSession.ts**
Arm the gate on a rate-limited OK rejection; route the NOTICE branch
through the same helper. Net zero lines, which keeps this
already-oversized file within the differential ratchet.

**desktop/src/shared/api/relayClientPublishRejection.test.mjs** (new)
Four tests against the real `RelayClient`: a rate-limited OK settles the
pending publish and arms the gate; an ordinary rejection does not arm
it; an accepted OK still resolves.

**mobile/lib/shared/relay/relay_session.dart**
Arm the gate in `_handleOk` for a rate-limited rejection.

**mobile/test/shared/relay/relay_session_test.dart**
Two tests driving the real `publish` + `debugHandleMessage` path.

</details>

<details>
<summary>Validation</summary>

**Mutation-tested — 5 mutations, all now killed.** Each production call
site was reverted to the defective behaviour to confirm a test fails.
This caught two false-negative tests:

| # | Mutation | Result |
|---|----------|--------|
| 1 | `rejection_target_for`: EVENT → `Connection` | 4 tests fail |
| 2 | EVENT handler-semaphore call site → bare NOTICE | **survived at
first** |
| 3 | per-minute quota call site → `Connection` | **survived**; fixed by
removing the parameter |
| 4 | desktop `handleOk` gate arming removed | 1 test fails |
| 5 | mobile `_handleOk` gate arming removed | 1 test fails |

Mutation 2 is the lesson: my first saturation test called
`request_rejection_message` directly, so reverting the real call site
inside the `match` arm left it green. It now drives
`handle_text_message` itself and dies on that mutation.

- `cargo test -p buzz-relay` — 928 passed, 1 failed:
`api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo`,
**pre-existing**, reproduced with all changes stashed at `4dd4d73de`.
- `cd desktop && npm test` — 5721 passed, 0 failed (full suite).
- `cd mobile && flutter test` — 1876 passed, 0 failed (full suite).
- `just fmt-check`, `just clippy`, `just desktop-check`, `just
mobile-check`, `just file-size-check` — clean. Desktop's 5 biome
warnings are pre-existing (reproduced with changes stashed).
- All 9 pre-push lanes green, including `rust-tests` and
`desktop-tauri-checks`.

**Not verified:** not reproduced end-to-end against a live relay under a
forced quota burst. The causal chain is source-proven and
mutation-proven at the frame level; the ~25s attribution follows from
`PUBLISH_TIMEOUT_MS` but is not directly measured. A packaged-build
click-through would close that gap.

</details>

Related work: #6957 bounds Desktop HTTP event submission, but safe
retained-operation recovery after exhausted/ambiguous outcomes remains
unfinished. #6998 is the separately reviewable Desktop
readiness/duplicate-subscription slice. Neither is claimed to complete
native before/after startup-send validation.

Diagnosis note: `RESEARCH/DESKTOP_STARTUP_SEND_STALL_2026_08_27.md`
(Brain's workspace).

## Current review disposition (2026-08-28)

The [review on
`cd12c938`](https://github.com/block/buzz/pull/6961#pullrequestreview-5052902510)
identified ACP's missing rate-limited-OK handling. Commit
`3b06dd32493596ec650f20abf8805791c50fdc24` fixes gate arming, re-parking
the specifically refused observer frame, and the stale NOTICE comment.
Two regressions drive the real frame dispatcher. See [the implementation
and validation
response](https://github.com/block/buzz/pull/6961#issuecomment-5455032054).

The Mobile generation-check inline thread is resolved: its `async
publish` returns a failed Future when superseded; it does not throw
synchronously at invocation. No further production change was indicated
by that comment.

The validation counts above describe the original slice, not a new
rerun. At `3b06dd324`, the current GitHub check rollup has successful
completed test/build checks (non-applicable jobs skipped). The
security-review comment still requires review for the current base/head
range; do not read a green authorization job as a completed security
review. Approval and merge remain human decisions.

---------

Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Co-authored-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz>
2026-08-31 14:43:31 -06:00
Arjun Mahanti
bc006f6708 feat: render agent avatars as squircles (#7106)
## Summary

- render every agent/AI identity as a 30% squircle across desktop and
mobile while keeping human avatars circular
- propagate agent identity through message, thread, profile, reaction,
member, DM, search, workflow, project, huddle, forum, pulse, and
agent-management surfaces
- preserve squircle geometry for fallbacks, focus/status treatments,
add-agent controls, and overlapping avatar outlines (`calc(30% + 2px)`
for the outer background)

### Related issue

None found. This change was requested and visually reviewed in the
originating Buzz thread.

### Testing

- `just desktop-test` — 5,799 passed
- `just mobile-test` — 2,008 passed
- pre-push gates passed at `0d59d77b120dcb90aac2f918e422c11c9fa5353b`:
desktop check, TypeScript typecheck, desktop full test suite, mobile
format/analyze and full test suite, Rust tests, Tauri checks, and
differential file-size gate
- deterministic desktop visual sweep covered channel messages/thread
summaries; thread, subthread, and sub-subthread depths; reactions and
reactor popovers; hover/full profiles; added-to-channel activity;
channel members/settings; agent library/team overlaps; agent creation;
mention autocomplete; and DM header/sidebar/settings

### UI evidence

The complete labeled visual matrix is available in the originating Buzz
review thread. GitHub-hosted copies will be added in a follow-up PR
comment using the repository screenshot script.

---------

Signed-off-by: Fizz <dae5f6af70b8695a8b83c8deae555f63be41630ec2b8cd493e41a439c9527dd8@buzz.block.builderlab.xyz>
Co-authored-by: Fizz <dae5f6af70b8695a8b83c8deae555f63be41630ec2b8cd493e41a439c9527dd8@buzz.block.builderlab.xyz>
2026-08-31 13:31:33 -04:00
Wes
b593c7d7fe perf(mobile): reduce cold startup and channel rendering delays (#6996)
Pinky is opening this PR on Wes’s behalf.

## Summary

Reduce two separately measured mobile delays without changing the relay
API or removing rich message rendering:

- Publish the finite channel-list snapshot without waiting for live
subscription setup.
- Batch active channel-list subscriptions into sorted, deterministic
chunks of at most 128 explicit channel IDs, retaining unchanged chunks.
- Install replacement chunks before retiring old coverage. Retain old
chunks across thrown replacement failures; filter callbacks to the
current relay/identity and still-desired channels; clean up
retired/in-flight work across disconnect and disposal.
- Scope the custom-emoji Markdown matcher to known shortcodes actually
referenced in the rendered content, rather than embedding the whole
community palette in every message’s regex. Preserve unknown literals,
shared-colon token boundaries, event-tag URL priority, content edits,
and code literals.
- Honor explicit zero retry hints without inventing a ten-second
session-wide gate, while preserving the ordinary live-subscription retry
backoff and any already-active gate.

## Matched performance results

Medians of three before and three after process-cold launches,
alternated on the same authenticated iPhone 17 Pro / iOS 26.5 simulator.
Before is mobile source at `e76c81968b65b0755b83efdd59dc3375c59ddf40`;
after is this production patch before two documentation-only comment
fixes.

First channel-list frame: 11.617s → 3.179s · 73% lower latency

Live setup duration: 8.475s → 0.185s · 98% lower latency

Channel-open first message-list frame: 2.754s → 1.230s · 55% lower
latency

Message data ready → first frame: 1.977s → 0.286s · 86% lower latency

Channel-open reveal complete: 2.845s → 1.394s · 51% lower latency

Channel-open data readiness: 0.770s → 0.944s · 23% higher latency

The gain is client-side orchestration/rendering, not a claim that the
relay became faster. First channel-list frame ranges were 10.835–11.788s
before and 2.872–3.395s after; channel-open first-frame ranges were
1.560–2.906s before and 1.149–1.317s after.

### Measurement boundaries

- Debug simulator builds, CPU sampling disabled, bounded timestamp
probes enabled identically. These are not release/physical-device
measurements.
- Startup clock starts at Dart `main`; build/install/native pre-main
time is excluded. Auth/preferences and OS/disk caches are retained
between new processes.
- Same account scale: 113 active channels. Latest-message events varied
slightly with live activity (1543–1546).
- Channel-open uses the same initial 50-row history window, 97 query
events, and 67 provider events. The 2306-entry emoji palette is
explicitly loaded before navigation on both sides; palette preparation
is excluded from the channel-open clock and happens after the startup
frame measurement.
- Both diagnostic builds temporarily disabled unused avatar segmentation
to work around the existing Google ML Kit arm64-simulator slice
limitation. The workaround, dependency/native changes, auto-navigation,
and all probes are excluded from this PR.

## Validation

- Full mobile package suite: `flutter test` — 1890 passed.
- `just mobile-check` — 506 files unchanged; analyzer clean.
- `just file-size-check` — policy tests and all client ratchets passed.
- `git diff --check` — passed.
- New lifecycle regressions cover front-sorting insertion across a chunk
boundary while replacement readiness is paused, failure
retention/departed-channel filtering, retired generation + disconnect
cleanup, disposal, chunk limits, unchanged-set reuse, and scope
switches.
- Emoji unit/widget coverage includes a 2500-unused-emoji palette,
unknown tokens, case matching at the component level, shared-colon
boundaries, rich text, event URL priority, and content edits.
- Fresh-frame source review traced the subscription queue/fences,
callback scopes, duplicate-event paths, matcher/wiring, and retry
scheduling.
- At committed/pushed head `13a83b628c8411c5885e6f76a250ba87accf6067`,
all normal pre-push hooks passed: `mobile-checks` (formatter, analyzer,
and the full 1890-test mobile suite), `file-size-check`, `branch-skew`,
and `push-head-scope`. The commit hook formatted 506 files with no
changes. Runtime measurements preceded only the two
documentation-comment fixes; no runtime source changed afterward.

## Limits / follow-ups

- `RelaySession.subscribe` still settles under its existing
EOSE/fallback/retryable-CLOSED contract. “Setup completed” is not an
unconditional EOSE or live-delivery guarantee. This PR does not add
status-aware replacement ownership.
- The channel-message provider still awaits subscribe before fetching
history; that separate serialization is not removed here.
- Oversized Huddle queries and the separate history batching path above
128 active channels remain follow-ups, as do pre-existing read-state
initialization/size warnings.
- Palette-only widget refresh and upstream Markdown uppercase-dispatch
behavior are not changed.
- A clean source build still has the existing Google ML Kit
arm64-simulator issue; the profiling workaround is not a proposed
product fix.

Originating Buzz conversation:
buzz://message?channel=793b0522-7995-4375-b1a6-fd94a96fa21d&id=6ba88afdec78ab2cfb6728afcd4a6d10f29e6aa33ff0f62f45d6750381e4d789

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Co-authored-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz>
2026-08-28 15:38:03 -06:00
Tom Brow
c432a111ca feat(mobile): push notifications MVP (#6269)
This PR implements MVP, iOS-only,
[NIP-PL](8d2d0ff5ad/docs/nips/NIP-PL.md)-compliant
push notifications.

A relay with `BUZZ_PUSH_ENABLED` will send a push notification for any
message that appears in the in-app Notifications tab.

## Enrollment flow
The first time the client first connects to a relay with
`BUZZ_PUSH_ENABLED`:
```mermaid
sequenceDiagram
    autonumber
    participant App as Buzz iOS app
    participant iOS
    participant Relay as Buzz relay
    participant Attest as Apple App Attest
    participant Gateway as Push gateway

    App->>Relay: Fetch NIP-11 push capability
    Relay-->>App: Push profile, current relay public key, and limits

    par
        App->>iOS: Request notification permission
        iOS-->>App: Permission result
    and
        App->>iOS: Register for remote notifications
        iOS-->>App: Device token
    end

    App->>Gateway: Request installation challenge
    Gateway-->>App: Single-use challenge
    App->>Attest: Attest installation transcript
    Attest-->>App: Attestation proof
    App->>Gateway: Enroll device token and proof
    Gateway-->>App: Installation handle

    App->>Gateway: Request delegation challenge
    Gateway-->>App: Single-use challenge
    App->>Attest: Assert relay-key delegation
    Attest-->>App: Assertion
    App->>Gateway: Create delegation
    Gateway-->>App: Opaque endpoint grant

    App->>Relay: Publish encrypted push lease and filters
    Relay-->>App: Lease acknowledged
```

## Push-time flow

When a notification-eligible event is received by the relay:

```mermaid
%%{init: {
  "sequence": {
    "actorMargin": 20,
    "width": 110,
    "messageMargin": 18,
    "diagramMarginX": 8,
    "wrap": true
  }
}}%%
sequenceDiagram
    autonumber
    participant Relay as Buzz relay
    participant Gateway as Push gateway
    participant APNs as Apple Push<br/>Notification service
    participant iOS
    participant NSE as Notification service<br/>extension

    Relay->>Gateway: POST /v1/deliveries/apns<br/>opaque endpoint grant, request ID, expiry, NIP-98 authorization

    Gateway->>APNs: POST /3/device/{device-token}<br/>topic, request ID, expiry, constant mutable-content payload
    APNs-->>Gateway: 200 OK: request accepted
    Gateway-->>Relay: 200 OK: accepted status

    APNs-->>iOS: Notification: constant reconnect alert<br/>mutable-content = 1
    iOS->>NSE: Invoke extension<br/>original notification content

    NSE->>Relay: POST /query: subscription filters, limit 10<br/>NIP-98 authorization
    Relay-->>NSE: 200 OK: signed Nostr events<br/>kinds 9, 40002, 45001, or 45003

    NSE->>iOS: Complete notification: title, body, subtitle<br/>thread ID, exact-message target
```

relay → push gateway → APNs -> NSE -> Notification Center

## Known limitations

The APNs wake payload is intentionally constant and opaque: it contains
no originating community or message identifier, in keeping with the
implemented NIP-PL privacy design.

The Notification Service Extension must therefore reconnect to the relay
and resolve eligible messages after each wake. Around overlapping wakes,
timing boundaries, or resolution windows, notification presentation may
occasionally omit an expected message or display a message more than
once.

This best-effort behavior is deliberately accepted for the current
implementation and will be measured during the internal rollout to
determine whether the user experience is acceptable before any broader
deployment; the implementation does not claim exactly-once presentation.

## Validation

Live end-to-end hardware validation used an internal remotely hosted
development relay and push gateway, the APNs sandbox, and a physical
iPhone 12 mini:

- A second real Buzz client published a uniquely marked message through
the hosted relay.
- The relay matched the message and sent the constant opaque wake
through the hosted gateway. The gateway made an actual APNs request; no
`simctl push` or simulated notification was used.
- The iPhone received the notification on its lock screen. The
Notification Service Extension reconnected to the relay, fetched the
event, verified its ID and signature, and replaced the placeholder
content with the real notification title and body.
- After the app populated its shared presentation cache, a final marked
notification visibly showed the sender display name, sender avatar, and
hashtag-prefixed channel name.
- Tapping a lock-screen notification opened Buzz and exercised the
notification-response path and navigated to the corresponding message.

Final validation with a dogfood-signed artifact and production App
Attest/APNs configuration remains a release step.

## Independent pre-reviews

- **First pass:**
[Carl](buzz://message?channel=18882f4c-289f-41db-942f-81f6f8066da1&id=74ab9a93bb227f3e762568f1cf9fee66d7495b0edc3918735ff787238b9cc585)
found missing transient retries, executor-key rotation suppression,
duplicate installation renewal, and an unauthenticated challenge write
amplifier. These were resolved by [retry-safe
bootstrap](https://github.com/block/buzz/pull/6269/changes/12c66ea62)
and [authenticated renewal plus a cross-replica
quota](https://github.com/block/buzz/pull/6269/changes/8e5ece0bd).
[sol-max](buzz://message?channel=ad83385f-8e9e-4461-9a35-c1bf2e208532&id=d26d53daa4684669e2ed354638241f13f36c3a97027fe8b4dd738aff09038962)
found delegation generation burning and an edited applied migration,
resolved by [exact-generation
revocation](https://github.com/block/buzz/pull/6269/changes/c26d2159d)
and a [forward-only
migration](https://github.com/block/buzz/pull/6269/changes/956c1d099).
[k3-max](buzz://message?channel=5e46055d-a766-4065-ae25-05d1e4aaa6b2&id=d43139138a0b15f806cbdbeeedd8f69d992cadf2e805876db6fdde6a34c7eda1)
found no blockers.
- **Exact-head re-review:**
[Carl](buzz://message?channel=18882f4c-289f-41db-942f-81f6f8066da1&id=a897721673459301b0cf26e8b85a1478d7ebbb56a4621f93d774c98d395b8f68),
[sol-max](buzz://message?channel=ad83385f-8e9e-4461-9a35-c1bf2e208532&id=fb2159f709ec68f74f7b21459acd76da0e8a7c5c0f3d469f99826b0cc2380849),
and
[k3-max](buzz://message?channel=5e46055d-a766-4065-ae25-05d1e4aaa6b2&id=2ce2842910435f562e9d9cc718595848f281b122c94605e523a4b964254b8bfb)
independently returned **NO BLOCKERS** at `7eb3a650b`; k3-max also
revalidated every remediation and the endpoint-specific App Attest
enrollment bound.

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Jordan Mecom <jm@squareup.com>
2026-08-28 15:15:09 -06:00
klopez4212
ed11c8d8bf Refresh mobile utility surfaces and theme picker (#6944)
## Summary

- invert mobile utility surfaces so pages and sheets use the softer page
background with raised containers
- rebuild the community theme picker around swipeable Home/Chat
previews, accent and appearance controls, and native iOS glass
interactions
- align shared sheet/profile spacing and radii, and move Theme into the
Community settings card

### Related issue

None found.

### Testing

- `just mobile-check`
- `just mobile-test` (1,871 tests)
- signed iOS Release build installed on a physical iPhone

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
2026-08-28 18:56:42 +01:00
klopez4212
58cc4b7e9b Fix mobile jump-to-latest flicker (#6807)
## Summary

- Keep jump-to-latest hidden through composer and keyboard layout
changes while the user is still following the tail.
- Stabilize thread tail detection during lazy scroll updates.
- Unmount inactive iOS Liquid Glass controls so the channel arrow cannot
bleed into an opened thread.

## Testing

- just mobile-check
- flutter test (1,864 passed)
- Signed iOS Release build and install on iPhone Air

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-08-28 17:12:36 +01:00
klopez4212
4d77175c78 fix(client): resurface hidden DMs from live activity (#6885)
## Summary

Hiding a DM row and then receiving a new message from that person never
brought the row back — the reader was left with only an Inbox
notification. This restores the row from live activity, client-side,
with no relay or database changes.

- Subscribe to hidden-DM activity **channel-scoped** over the hidden-DM
id set (`#h`), re-registering whenever the set changes. The relay's
`fan_out_scoped` only routes channel-scoped events (every kind:9 DM
carries a `channel_id`) to channel-scoped subscriptions, so a
community-global `#p` filter receives nothing. Hiding a DM never drops
membership, so `#h` authorization holds. Because every delivered event
is already for a hidden DM the reader belongs to, eligibility no longer
needs a self `p` tag — untagged CLI/agent DMs resurface too — and no
per-event visibility fetch is required.
- On qualifying activity, invoke the existing open-DM action (`41010`)
to reopen the hidden one-to-one or group DM, validating that the relay
reopened the same channel.
- Preserve the existing relay-synced hide snapshot (`41012` → `30622`);
hide state still syncs across devices.
- Let Inbox reopen a hidden DM before navigating to its message, with a
busy state on the row and context-menu "Open in channel" action while a
reopen is pending.
- Coalesce resurface attempts per channel: a concurrent message that
arrives while a reopen is in flight triggers a retry on failure instead
of being marked handled and dropped.
- Write exact dm-visibility query keys on open/hide mutations.

This is the client-only replacement agreed in the Buzz design thread and
supersedes #6713. The accepted limitation is that resurface only fires
while a client is running — a message that arrives while all clients are
closed does not restore the row on next launch (the `30622` snapshot has
no watermark); Inbox click-through is the safety net for that case.

Addresses #6316.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
2026-08-27 10:29:26 -04:00
klopez4212
8b812017a7 Fix mobile Huddle agent voice turn states (#6611)
## Summary

- ignore mobile room tone when deciding whether human speech should
interrupt agent audio
- show a subtle waiting animation while an agent prepares a response,
then restore its avatar when speech begins
- render agents from Huddle membership before their audio peer starts
transmitting

## Testing

- `just ci`
- signed Release build installed and launched on a physical iPhone
- live Huddle voice and agent-membership behavior verified on device

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz>
2026-08-25 14:59:19 +01:00
klopez4212
9aa332af03 Add inline profile camera capture (#6680)
## Summary

- add inline profile photo capture with smooth avatar-to-viewfinder
transitions
- add close, flip, shutter, retry, and use-photo states with haptics and
front-camera mirroring
- polish iOS liquid-glass controls and avatar editor motion

### Related issue

Follow-up to #6583.

### Testing

- just mobile-check
- just mobile-test (1,743 tests)
- built, installed, and launched on Pixel 10 and iPhone Air

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz>
Signed-off-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Signed-off-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Signed-off-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz>
Co-authored-by: Tyler <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Co-authored-by: Meli <5aaa86bce934fc3445fc254aab560a40923f10252f92107e665073dede0e04d3@buzz.block.builderlab.xyz>
Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-25 14:58:55 +01:00
klopez4212
822c5ab231 Hide Huddles in mobile agent DMs (#6676)
## Summary
- hide the mobile Huddle action in one-to-one agent DMs
- preserve Huddles for human DMs and group DMs

## Testing
- just mobile-check
- flutter test (1,662 tests)

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
2026-08-25 08:18:22 +01:00
Tom Brow
6eff84d127 fix(mobile): join starter channels after accepting invite (#5915)
### What changed?

After a new mobile invite claim succeeds, Buzz now best-effort ensures
membership in the same public starter channels as desktop:

- `#general`
- `#welcome-everyone`

Missing starters use desktop's deterministic per-relay IDs and exact
public channel configuration, so concurrent mobile and desktop setup
converges safely. Setup failures do not invalidate or retry an already
successful invite claim, and failure for one starter does not block the
other.

The success sheet offers **Continue to #welcome-everyone** when that
channel is available. Mobile does not create the private `Welcome`
channel because it cannot provision the desktop Welcome agents that make
that channel useful.

This PR is stacked on #6145 because it deliberately reuses that PR's
open-channel directory and join behavior. Once #6145 merges, this PR can
be retargeted to `main` without changing its BUZZ-12 diff.

Fixes
[BUZZ-12](https://linear.app/squareup/issue/BUZZ-12/bug-community-appears-empty-after-using-invite-link-on-mobile).

### How is it tested?

- Desktop/mobile deterministic starter-ID parity coverage.
- Existing-channel join and missing-channel creation coverage.
- Duplicate-create convergence and per-channel failure isolation
coverage.
- Invite success remains successful when starter setup fails.
- Widget coverage for continuing directly into `#welcome-everyone`.
- Focused invite/deep-link tests: 23 passed.
- `just mobile-check`: passed.
- `just mobile-test`: 1,483 passed.
- Pre-push repository checks: passed.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
2026-08-24 12:59:21 -07:00
klopez4212
a0298539f7 Add mobile profile editing (#6583)
## Summary

- add mobile editing for display name, profile description, and profile
photo
- support image positioning, emoji backgrounds, and animated avatar
capture with native iOS controls
- refine settings navigation, profile motion, and the connection
identity row

## Testing

- `just mobile-check`
- `just mobile-test` (1,685 tests)

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz>
2026-08-24 20:26:28 +01:00
Wes
01091c15a1 fix(mobile): recover stale and shuffled messages (#6691)
## Summary

- refetch mounted mobile thread replies after relay reconnect,
preserving the previous reply list during recovery
- auto-dispose route-scoped relay reply caches so reopening a thread
queries current relay state
- invalidate live replies through both the channel-window and legacy
websocket-history paths
- preserve optimistic-reply confirmation when the route closes before
its deferred cleanup
- stabilize rapid same-second messages using desktop's existing split
contract: channel timelines render `(created_at ASC, id DESC)` while
threads render `(created_at ASC, id ASC)`
- retain late live rows after a channel window is exhausted instead of
dropping same-second tail messages

Closes #4404.
Closes #4830.
Closes #6204.

## Context

The broad all-channel/all-DM stale-session defect reported in #4402 is
already addressed on current `main` by #4372 and #3053. Two distinct
mobile gaps remained:

1. `threadRepliesProvider` was a process-lifetime one-shot query, so
replies missed while the socket was stale remained absent after
reconnect or after closing and reopening the thread.
2. Mobile had inconsistent timestamp-only and event-id ordering across
channel producers. Rapid messages routinely share Nostr's one-second
timestamp, so later hydration/live reconciliation could reshuffle them.
Desktop deliberately has two render contracts: channel windows reverse
the relay's composite order to `(created_at ASC, id DESC)`, while thread
replies use `(created_at ASC, id ASC)`.

This consolidates the current-main portions of #4831 and #3243 rather
than reviving stale overlapping branches.

## Validation

Exact pushed head: `be92d9542c6cd1342733bdc5e8359664b511ce02`

- focused channel-provider/window/thread suites: 48/48 passed
- incident regression: a mounted thread misses a reply while
disconnected, reconnects, and renders the recovered reply
- route regression: closing and reopening a thread performs a fresh
authoritative query
- websocket fallback regression: live reply invalidates the mounted
thread even without the channel-window path
- disposal regression: optimistic confirmation survives provider
disposal between rebuild and deferred cleanup
- ordering regressions: channel window/live, websocket fallback,
optimistic sends, deep links, both pagination paths, and thread merges
preserve their desktop-compatible same-second order
- boundary regression: exhausted windows admit late same-second live
rows without weakening open-page cursor boundaries
- independent adversarial review: no production blocker; source contract
verified across all producers and relay cursor semantics unchanged
- pre-push Mobile lane passed at exact head, including analysis,
file-size/branch checks, and full Flutter suite: 1,675/1,675 passed
- `git diff --check`

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
2026-08-24 10:59:58 -06:00
Tom Brow
26f4c3ed30 feat(mobile): browse and join open channels (#6243)
Mobile previously exposed no way to browse or join channels.

Users can now browse and join eligible open channels from the Home
quick-actions menu. The public directory loads on demand when Browse
channels opens, while the existing kind 9021 join path refreshes
membership after success.

| Browse channels | Join channel |
| --- | --- |
| <img width="320" alt="Browse channels"
src="https://github.com/user-attachments/assets/f12c46c8-8bf4-487d-8a45-7bec63b16028"
/> | <img width="320" alt="Join channel"
src="https://github.com/user-attachments/assets/d6306ca7-9ab8-4f5a-8131-7f9b2a76d653"
/> |

### How is it tested?

Manually tested (see screenshots) and added tests:

-
[`channels_provider_test.dart`](https://github.com/block/buzz/blob/main/mobile/test/features/channels/channels_provider_test.dart)
covers access filtering, independently paginated membership and
directory queries, relay-capped pages, repeated-page termination, hard
page caps, on-demand directory loading, load failures, retry, and
cached-channel retention.
-
[`channels_page_test.dart`](https://github.com/block/buzz/blob/main/mobile/test/features/channels/channels_page_test.dart)
covers browse eligibility, loading and retry states, quick-action
layout, and scrolling and joining from a 500-channel directory.
-
[`search_page_test.dart`](https://github.com/block/buzz/blob/main/mobile/test/features/search/search_page_test.dart)
covers discoverable open-channel results without presenting unknown
membership counts as zero.

Local validation:

- `just mobile-check`
- `just mobile-test` (1,560 tests)
- full pre-push gate

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: leader <71e9f2c44a6932b6772caaaccda1911d010463c3e2c6c40410b8329956046801@buzz.block.builderlab.xyz>
2026-08-24 09:48:02 -07:00
klopez4212
e236329413 Polish Huddle participant interactions (#6312)
## Summary
- arrange Huddle participants in a responsive, equal-weight cluster with
spring enter/exit motion and a `+N` overflow
- spotlight tapped participants over a blurred call surface, with a
roster for hidden participants and no self-avatar action
- add selection haptics across full-screen and drawer controls,
including both end-call buttons
<img width="1080" height="2424" alt="Screenshot_20260819-151448"
src="https://github.com/user-attachments/assets/00b7fdca-2304-4788-9952-e07224798513"
/>
<img width="1080" height="2424" alt="Screenshot_20260819-151422"
src="https://github.com/user-attachments/assets/a0cfc861-0519-44ff-bb56-4c983ed6344c"
/>

## Validation
- `just mobile-check`
- focused participant, drawer-control, and full-screen end-call widget
tests
- Huddle-focused widget suite (15 tests)
- full mobile Flutter suite (1,538 tests)

## Dependency
Built on #6056 and contains only the follow-up interaction work. Merge
after #6056 lands.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: leader <71e9f2c44a6932b6772caaaccda1911d010463c3e2c6c40410b8329956046801@buzz.block.builderlab.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz>
2026-08-22 16:06:36 -07:00
klopez4212
4baccd5394 Downgrade mobile Huddles to audio protocol v2 (#6558)
## Summary

- downgrade Mobile Huddle authentication and native media configuration
from protocol v3 to the currently deployed relay's v2 contract
- restore the released one-byte relay peer prefix while retaining later
reconnect, roster, and playout-reset reliability fixes
- update Android, iOS, protocol documentation, and focused tests
together

Protocol v2 does not carry v3's occupancy epoch on audio frames, so it
cannot fence the narrow delayed-packet/peer-index-reuse race. This is an
intentional compatibility tradeoff until the relay v3 rollout is ready.

### Related issue

None found.

### Testing

- `just mobile-check`
- `just mobile-test` — 1,661 tests passed
- Android debug build installed and launched on Pixel 10 as
`xyz.block.buzz.mobile.sprout_mobile_profile_settings`; foreground
process verified
- signed iOS Release build installed and launched on iPhone as
`com.buzz.buzzMobile`; running process verified

A live two-device Huddle audio call remains a manual verification step.

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-08-22 18:37:08 +01:00
klopez4212
8c0f42e187 Add mobile Huddles voice MVP (#6056)
## Summary
- add foreground mobile Huddles on Android and iOS with native Opus
capture/playback, mute, speaker routing, participants, lifecycle, and
minimized drawer UI
- keep mobile Huddle cards and roster state live, including ended rooms,
relay-resolved profiles, and agents
- broadcast desktop agent TTS through the existing Huddle audio protocol

## Scope
Foreground human-to-human voice MVP only. Agent setup/transcripts,
background calling, recording, and advanced device controls remain out
of scope.

## Validation
- `just mobile-check`
- `just mobile-test` — 1,500 passed
- `just desktop-check` and `just desktop-test` — 4,957 passed
- desktop typecheck, strict Clippy, and Tauri tests — 2,445 passed, 15
ignored
- mobile worktree identity contract checks
- physical Pixel/iPhone behavior reviewed during development

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: leader <71e9f2c44a6932b6772caaaccda1911d010463c3e2c6c40410b8329956046801@buzz.block.builderlab.xyz>
Co-authored-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz>
2026-08-22 05:28:16 -07:00
klopez4212
aeb741fd31 Polish mobile channel navigation and message sends (#6488)
## Summary
- align iOS back controls and affected titles across channel details,
settings, and pairing
- make channel star and mute actions reflect their state immediately
- animate locally sent channel, thread, and DM messages from behind the
composer with a 300ms ease-out

## Testing
- `just mobile-check`
- full mobile test suite (1,573 tests)

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
2026-08-21 19:13:16 +01:00
klopez4212
ffb12d3b05 Fix mobile thread tail and iOS channel header (#6399)
## Summary

- Hide the thread Latest control once the lazy tail is fully reached.
- Polish the mobile channel header and use native iOS liquid glass for
Back.
- Keep the two-line header aligned and safe at larger text sizes.

## Validation

- `just mobile-check`
- `flutter test` (1,553 tests)
- Signed iPhone build installed and launched

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz>
2026-08-21 15:27:30 +01:00