Files
buzz/mobile/lib/shared/read_state/message_read_state.dart
Logan Johnson 166d383ca7 feat(mobile): read web app catch-up marks for unread state (#8103)
🤖

## Summary
The web and desktop app (`buzz-app`) saves a "caught up" mark when you
scroll to the bottom of a channel and stay there, and another one for a
thread. Mobile did not understand these marks. It relied on the separate
per-message read marks that `buzz-app` also kept. `buzz-app` is about to
stop keeping those per-message marks under the "caught up" mark
(block/buzz-app#590), to save space in the synced read state.

With this change, mobile reads the "caught up" marks with the same rules
as `buzz-app`:
- `activity:<channel>` reads ordinary top-level messages up to its time.
It does not read thread replies, mentions, broadcasts or DM messages,
because reaching the bottom of the channel does not prove you saw those.
- `thread-activity:<root>` reads the replies in that thread up to its
time.

So a message you read in `buzz-app` stays read on mobile: in the app
badge, the channel list unread state, the "new messages" divider when
you open a channel, and the Mark read / Mark unread action.

Mobile does not write these marks. It only reads them.

### Related issue
None found. Companion to block/buzz-app#590. The old desktop app in this
repo is intentionally not changed.

### Testing
- New badge tests: `activity:` clears an ordinary top-level message, and
does not clear a mention, a thread reply or a DM. `thread-activity:`
clears replies in its own thread only. The two "clears" tests fail
without this change.
- New tests for the per-message read check and the "ordinary message"
rule.
- `flutter analyze`, `dart format` check and the full mobile test suite
pass locally.
- Not yet exercised on a device.

---------

Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-05 18:52:59 -04:00

53 lines
1.9 KiB
Dart

import 'read_state_format.dart';
import 'read_state_provider.dart';
/// Effective read timestamp for a single message: the newest of the channel
/// marker, the message's own `msg:` marker, for thread replies the thread's
/// `thread:` and `thread-activity:` markers, and for messages that channel
/// catch-up reads (see `readByChannelCatchUp`) the `activity:` marker — the
/// same precedence the unread badge uses via `observedUnreadEventReadAt`.
int? effectiveMessageReadAt(
ReadStateState readState, {
required String channelId,
required String messageId,
String? threadRootId,
bool channelCatchUp = false,
}) {
return maxReadAt([
readState.effectiveTimestamp(channelId),
readState.effectiveTimestamp(msgContextKey(messageId)),
if (threadRootId != null) ...[
readState.effectiveTimestamp(threadContextKey(threadRootId)),
readState.effectiveTimestamp(threadActivityContextKey(threadRootId)),
],
if (channelCatchUp)
readState.effectiveTimestamp(activityContextKey(channelId)),
]);
}
/// Whether a message should currently show as unread in the actions menu.
///
/// A message-level forced unread (from this menu) wins; otherwise the
/// timestamp precedence decides. A channel-level forced unread (from the
/// channel tile) is deliberately not consulted: it is a channel-scoped
/// choice, and letting it leak in here would make the message-level toggle
/// unable to round-trip without clearing the channel-level choice.
bool isMessageUnread(
ReadStateState readState, {
required String channelId,
required String messageId,
required int createdAt,
String? threadRootId,
bool channelCatchUp = false,
}) {
if (readState.isForcedUnread(msgContextKey(messageId))) return true;
final readAt = effectiveMessageReadAt(
readState,
channelId: channelId,
messageId: messageId,
threadRootId: threadRootId,
channelCatchUp: channelCatchUp,
);
return readAt == null || createdAt > readAt;
}