Files
buzz/mobile/lib/shared/read_state/read_state_provider.dart
Logan Johnson 9ade5d1ad1 feat(mobile): write catch-up and visible-message read marks like the web app (#8106)
🤖
## Summary
When you read a channel or thread on the phone, the web and desktop app
(buzz-app) should show it as read too, and the reverse. buzz-app no
longer saves a "read" mark for each ordinary message. It saves one
"caught up to here" mark per channel and per thread, and keeps
per-message marks only for mentions, DMs, broadcasts and replies. Mobile
already reads those marks. This PR makes mobile write them, so reading
on the phone clears unread on other devices and saved read state stays
small.

What changes on the phone:
- Reaching the bottom of a channel, and staying there for 300 ms, marks
it caught up. Reaching the end of a thread does the same for that
thread.
- A message you see fully on screen, away from the bottom, is marked
read on its own.
- Mentions, DMs, broadcasts and thread replies you see keep their own
read marks, as in buzz-app.
- Automatic reading never marks the whole channel read. Only the
explicit **Mark read** action does that. DMs and forums are the
exception: opening one marks it all read, as mobile does today.
- Reaching the bottom of a channel you marked unread ends that manual
unread. Unseen mentions and replies stay unread.
- Activity rows clear when you have read every message in the row, not
just the newest.


### Related issue
None found. buzz-app makes the same DM and manual-unread changes in
https://github.com/block/buzz-app/pull/616.

### Testing
- Unit tests cover the new write rules, Activity rows and the size limit
on saved read state.
- Widget tests cover reading on the channel page and the thread page,
opening a DM, and reaching the bottom of a channel you marked unread.
- No visual changes.

## Details
**Read marks.** "Caught up" is saved as `activity:<channel>` for a
channel and `thread-activity:<root>` for a thread. A caught-up mark
reads only ordinary top-level messages (channel) or that thread's
replies (thread). A per-message mark is `msg:<id>`.

**Caught-up time.** The mark is set to the time of the bottom row, and
never later than the current time. The time of a newer reply is not
used. So a top-level message that arrives late, with an earlier
timestamp, stays unread. A clock that runs fast, on this phone or the
sender's, cannot mark messages read before they arrive.

**Nested threads.** A thread opened on a nested reply shows only one
branch. Reaching its end writes per-message marks only, so unread
messages in other branches stay unread.

**Reading delay.** The 300 ms reading delay now restarts only when
messages arrive or leave. Before, any page rebuild restarted it, for
example a typing indicator.

**Activity rows.** A row that groups several events is done only when
each event is read. Each event is checked against the channel mark, its
own `msg:` mark, and its thread marks if it is a reply. Opening a row
goes to the oldest unread event.

**Publishing.** Mobile no longer republishes per-message marks it got
from other devices. buzz-app removes those once a caught-up mark covers
them. Broad marks (channel, thread, caught-up) are still republished, so
they stay available after the fetch window. The published blob is capped
at buzz-app's 40 KiB limit. Channel marks are kept first, then thread
marks, then caught-up marks, then the newest per-message marks. Before,
a blob over the encryption limit (about 64 KiB) turned off sync for the
rest of the session.

**DMs and manual unread.** Opening a DM reads it through the newest
loaded message, replies included, as mobile does today. Reaching the
bottom of a channel ends a manual unread. Manual unread on mobile is
local to this session, so ending it does not change what other devices
see.

**Compatibility.** Older desktop builds read only channel and
per-message marks. They may show extra unread for channels read on the
phone.

**Follow-ups, not in this PR**
- Unread catch-up fetches start after the channel mark and are capped at
1,000 events. In a busy channel, an old unread mention can drop out of
the badge. The fetch should also start from the caught-up mark, or load
more pages.
- Each mark triggers its own save and rebuild. They could be batched.
- Broadcast replies are counted slightly differently from buzz-app.
- buzz-app can also move its caught-up mark past the current time.

---------

Signed-off-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-10-14 17:04:57 -07:00

257 lines
7.6 KiB
Dart

import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../relay/relay.dart';
import '../theme/theme_provider.dart';
import '../community/community_provider.dart';
import 'read_state_manager.dart';
class ReadStateState {
final bool isReady;
final String? pubkey;
final Map<String, int> contexts;
final int version;
/// Session-local forced-unread flags, keyed by the forced context id
/// (a channel id from the channel tile, or a `msg:` key from the message
/// actions sheet), each mapped to the channel it belongs to so channel
/// tiles and badges can surface message-level forces.
final Map<String, String> forcedUnreadContexts;
const ReadStateState({
required this.isReady,
required this.pubkey,
required this.contexts,
required this.version,
this.forcedUnreadContexts = const {},
});
const ReadStateState.inert()
: isReady = false,
pubkey = null,
contexts = const {},
version = 0,
forcedUnreadContexts = const {};
/// Channels that should surface as unread because of a forced-unread flag —
/// either forced directly, or containing a forced message.
Set<String> get locallyForcedChannelIds =>
forcedUnreadContexts.values.toSet();
/// Whether this exact context (channel id or `msg:` key) is forced unread.
bool isForcedUnread(String contextId) =>
forcedUnreadContexts.containsKey(contextId);
int? effectiveTimestamp(String contextId) => contexts[contextId];
ReadStateState copyWithContext(String contextId, int timestamp) {
final current = contexts[contextId] ?? 0;
if (timestamp <= current) {
return this;
}
return ReadStateState(
isReady: isReady,
pubkey: pubkey,
contexts: Map.unmodifiable({...contexts, contextId: timestamp}),
version: version + 1,
forcedUnreadContexts: forcedUnreadContexts,
);
}
}
class ReadStateNotifier extends Notifier<ReadStateState> {
ReadStateManager? _manager;
bool _isInitialized = false;
final Map<String, String> _forcedUnreadContexts = {};
@override
ReadStateState build() {
_manager?.dispose(flushPending: false);
_manager = null;
_isInitialized = false;
_forcedUnreadContexts.clear();
final relayConfig = ref.watch(relayConfigProvider);
ref.watch(relaySessionProvider);
final activeCommunity = ref.watch(activeCommunityProvider).value;
final nsec = relayConfig.nsec?.trim();
if (nsec == null || nsec.isEmpty) {
return const ReadStateState.inert();
}
final signedRelay = SignedEventRelay(
session: ref.read(relaySessionProvider.notifier),
nsec: nsec,
);
final pubkey =
_normalizePubkey(activeCommunity?.pubkey) ??
_safeDerivedPubkey(signedRelay);
if (pubkey == null) {
return const ReadStateState.inert();
}
final crypto = ReadStateCrypto.tryCreate(nsec: nsec, pubkey: pubkey);
if (crypto == null) {
return const ReadStateState.inert();
}
final prefs = ref.read(savedPrefsProvider);
late final ReadStateManager manager;
manager = ReadStateManager(
pubkey: pubkey,
prefs: prefs,
crypto: crypto,
relaySession: ref.read(relaySessionProvider.notifier),
signedEventRelay: signedRelay,
remoteEnabled: true,
onChanged: () => _emitManagerState(manager),
);
_manager = manager;
ref.onDispose(() {
manager.dispose();
if (_manager == manager) {
_manager = null;
}
});
ref.listen(appLifecycleProvider, (_, next) {
if (next == AppLifecycleState.paused ||
next == AppLifecycleState.detached ||
next == AppLifecycleState.hidden) {
unawaited(manager.flush());
}
});
ref.listen(relaySessionProvider, (prev, next) {
if (prev?.status != SessionStatus.connected &&
next.status == SessionStatus.connected) {
unawaited(manager.reinitializeRemote());
}
});
Future.microtask(() async {
await manager.initialize();
if (_manager != manager) return;
_isInitialized = true;
_emitManagerState(manager);
});
return _stateFromManager(manager, isReady: false);
}
/// Advance a context's read marker. Clears the forced-unread flag for
/// exactly this context, if any. An explicit channel-level "Mark read"
/// (channel tile/menu) should pass [clearForcedMessages] so message-level
/// forces inside the channel are released too; the automatic read on
/// channel open must not, so a message deliberately marked unread stays
/// unread until acted on.
void markContextRead(
String contextId,
int unixTimestamp, {
bool clearForcedMessages = false,
}) {
var removed = _forcedUnreadContexts.remove(contextId) != null;
if (clearForcedMessages) {
final before = _forcedUnreadContexts.length;
_forcedUnreadContexts.removeWhere(
(_, channelId) => channelId == contextId,
);
removed = removed || _forcedUnreadContexts.length != before;
}
_manager?.markContextRead(contextId, unixTimestamp);
if (removed) {
_refreshForcedState();
}
}
/// Force a context unread for the rest of the session. [contextId] is a
/// channel id (channel-tile action) or a `msg:` key (message actions
/// sheet); [channelId] is the channel the context belongs to, so tiles and
/// badges can surface message-level forces. Read markers are monotonic,
/// so this is the only way to move a context back to unread.
void markContextUnread(String contextId, {required String channelId}) {
if (_manager == null) return;
_forcedUnreadContexts[contextId] = channelId;
_refreshForcedState();
}
/// Clear a forced-unread flag without moving any read marker. Reading to
/// the bottom of a channel the reader marked unread uses this: it ends the
/// manual unread but must not read mentions or replies the reader has not
/// seen.
void clearForcedUnread(String contextId) {
if (_forcedUnreadContexts.remove(contextId) != null) {
_refreshForcedState();
}
}
void _refreshForcedState() {
final manager = _manager;
if (manager == null) return;
state = _stateFromManager(
manager,
isReady: _isInitialized,
previousVersion: state.version,
);
}
void seedContextRead(String contextId, int unixTimestamp) {
_manager?.seedContextRead(contextId, unixTimestamp);
}
void _emitManagerState(ReadStateManager manager) {
if (_manager != manager) return;
final advances = manager.drainSyncedAdvances();
for (final contextId in advances) {
_forcedUnreadContexts.remove(contextId);
}
state = _stateFromManager(
manager,
isReady: _isInitialized,
previousVersion: state.version,
);
}
ReadStateState _stateFromManager(
ReadStateManager manager, {
required bool isReady,
int? previousVersion,
}) {
return ReadStateState(
isReady: isReady,
pubkey: manager.pubkey,
contexts: manager.effectiveContexts,
version: (previousVersion ?? 0) + 1,
forcedUnreadContexts: Map.unmodifiable(
Map<String, String>.from(_forcedUnreadContexts),
),
);
}
}
final readStateProvider = NotifierProvider<ReadStateNotifier, ReadStateState>(
ReadStateNotifier.new,
);
String? _normalizePubkey(String? value) {
final normalized = value?.trim().toLowerCase();
if (normalized == null || normalized.isEmpty) {
return null;
}
return normalized;
}
String? _safeDerivedPubkey(SignedEventRelay relay) {
try {
return _normalizePubkey(relay.pubkey);
} catch (e) {
debugPrint('[ReadStateManager] pubkey derivation failed: $e');
return null;
}
}