Files
buzz/mobile/test/features
Tom Brow d342cf141c fix(mobile): catch up unread from the newest message already seen (#8901)
## Summary

Every channel refresh path (app resume, reconnect, the backstop, joining
a channel) re-runs the unread catch-up. Each run asked for every
channel's messages since its read timestamp, up to 1000 full messages
per channel. For a reader with long-unread channels that is megabytes,
every time.

Measured on an iPhone 12 mini against the internal relay (gzip wire
bytes, instrumented profile build):

| | main | this PR |
| --- | --- | --- |
| Cold start unread catch-up | 3.5MB | 3.5MB (unchanged, first catch-up)
|
| Unread catch-up on app resume | 3.5MB | 23KB |

The 60s foreground backstop is one of those paths, so on main an open
app downloads the full catch-up again every minute (about 3.5MB a minute
on this account). With this PR each tick costs about 23KB.

On an Edge-class link (Network Link Conditioner "Edge", about 30KB/s) a
3.5MB batch needs about two minutes but has an 8s history timeout, so it
never completes and the next refresh starts it again.

A later catch-up in the same session now starts
`relayCrossDeviceSkewSeconds` (1860s) before the previous one was sent.
The relay rejects events whose `created_at` is more than 900s from its
clock, so a sender 900s slow and this phone 900s fast are at most 1800s
apart. Every message stored since the previous catch-up therefore falls
inside the window, including one that arrives late with an older
timestamp (for example, composed offline or on a phone with a slow
clock). Messages before that point were already recorded or skipped, so
unread and attention state is unchanged. The record is cleared with the
rest of the unread state when the channel list is rebuilt (community or
identity switch), and ignored if a channel's read timestamp ever moves
back. A channel whose websocket fallback filter failed (without a
deadline, so the batch still completes) is not recorded, so the next
catch-up asks for its whole window again. A deletion that removes an
unread event the app already recorded also drops the channel's record.
That event may have been the oldest of a full window of quiet agent
messages, standing in for older unread ones the window hides, and only a
whole-window catch-up finds them again. A catch-up that was already out
when that happened doesn't restore the record, since its response only
covers the narrower window. A channel muted or unmuted since its record
also catches up over its whole window, because muting changes which
messages count.

## Testing

- New test: a repeated catch-up starts 1860s before the previous one was
sent, and still records a message that arrived late with a timestamp
1500s before it. Confirmed it fails without the drift window (`since`
starts at the previous send time) and with the earlier 960s window.
- New test: a rebuilt channel list catches up over the whole window
again.
- New test: a channel whose batch and websocket fallback both failed
catches up over its whole window on the next refresh. Confirmed it fails
without the fix.
- Extended test: deleting the quiet-window marker while a catch-up is
out makes the next catch-up ask for the whole window, and the next quiet
message stands in. Confirmed it fails without each fix.
- New test: a channel unmuted since its last catch-up asks for its whole
window and records its ordinary backlog. Confirmed it fails without the
fix.
- The test fake now honors `since` and `until` like the relay. The
deletion cases now use recent timestamps, because a relay never stores a
1970 event late.
- `just mobile-check` and `just mobile-test` pass.
- Device: resumed Buzz on the 12 mini on Wi-Fi with the fix;
backgrounded for a minute, the catch-up requests were 22KB and 0.5KB
instead of 3.2MB and 287KB (f71c6c7600).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 18:53:39 -08:00
..