Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions mobile/lib/features/channels/channel_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import '../forum/forum_posts_view.dart';
import '../forum/forum_provider.dart';
import 'android_ime_lift.dart';
import 'channel.dart';
import 'channel_unread.dart';
import 'channel_actions_sheet.dart';
import 'channel_identity_names_provider.dart';
import 'channel_member_profile_actions.dart';
Expand Down Expand Up @@ -652,6 +653,42 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget {
});
}, [channel.id, readState.isReady, openReadActive, openReadTimestamp]);

// While it is open and in front, the channel's row in the list and the
// app badge stay quiet: the page saves `seen:` row hints for what the row
// shows, reading nothing (`buzz-app`'s `docs/unread.md`). An arrival
// while the page is covered or the app is in the background still
// lights the row.
final observedRevision = ref.watch(
channelsProvider.select(
(_) => Object.hashAllUnordered(
channelsNotifier.observedUnreadEventsFor(channel.id).keys,
),
),
);
useEffect(
() {
if (!readState.isReady || !openReadActive) return null;
return deferReadStateUpdate(context, () {
final marks = channelRowSeenMarks(
channelId: channel.id,
events: ref
.read(channelsProvider.notifier)
.observedUnreadEventsFor(channel.id),
readState: ref.read(readStateProvider),
);
if (marks.isEmpty) return;
ref.read(readStateProvider.notifier).markContextsRead(marks);
});
},
[
channel.id,
readState.isReady,
readState.version,
openReadActive,
observedRevision,
],
);

final dmHeader = resolvedChannel.isDm
? _watchDmHeader(ref, resolvedChannel, currentPubkey)
: null;
Expand Down
74 changes: 73 additions & 1 deletion mobile/lib/features/channels/channel_unread.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import '../../shared/read_state/read_state_format.dart';
import '../../shared/read_state/read_state_provider.dart';
import 'channel.dart';
import 'channels_provider.dart';
Expand All @@ -21,7 +22,9 @@ Set<String> computeUnreadChannelIds({
/// Unread thread replies, mentions and broadcasts only add an attention dot
/// ([attention]), so a channel the reader caught up on stays regular. An
/// unread DM bolds and dots. Marking a channel unread bolds it without
/// fabricating attention, but keeps any real attention's dot.
/// fabricating attention, but keeps any real attention's dot. What the row
/// has seen ([rowSawObservedUnreadEvent]) neither bolds nor dots it, unless
/// the reader marked the channel or one of its messages unread.
({Set<String> unread, Set<String> attention}) computeChannelListHighlights({
required Iterable<Channel> channels,
required ReadStateState readState,
Expand All @@ -31,13 +34,15 @@ Set<String> computeUnreadChannelIds({
readState: readState,
channelsNotifier: channelsNotifier,
separateAttention: true,
rowCut: true,
);

({Set<String> unread, Set<String> attention}) _computeHighlights({
required Iterable<Channel> channels,
required ReadStateState readState,
required ChannelsNotifier channelsNotifier,
required bool separateAttention,
bool rowCut = false,
}) {
final unread = <String>{};
final attention = <String>{};
Expand All @@ -60,13 +65,23 @@ Set<String> computeUnreadChannelIds({

final observedEvents = observedEventsByChannel[channel.id];
if (observedEvents == null) continue;
final cut =
rowCut && !readState.locallyForcedChannelIds.contains(channel.id);
for (final event in observedEvents.values) {
final readAt = observedUnreadEventReadAt(
event,
channel.id,
readState.effectiveTimestamp,
);
if (readAt != null && event.createdAt <= readAt) continue;
if (cut &&
rowSawObservedUnreadEvent(
event,
channel.id,
(key) => readState.contexts[key],
)) {
continue;
}
if (!separateAttention || channel.isDm || event.channelCatchUp) {
unread.add(channel.id);
}
Expand All @@ -78,3 +93,60 @@ Set<String> computeUnreadChannelIds({

return (unread: unread, attention: attention);
}

/// The observed events [channelId]'s row and the app badge still show: the
/// unread ones its row has not seen, or all of them when the reader marked
/// the channel or one of its messages unread.
Map<String, ObservedUnreadEvent> rowObservedUnreadEvents(
Map<String, ObservedUnreadEvent> events,
String channelId,
ReadStateState readState,
) {
if (readState.locallyForcedChannelIds.contains(channelId)) return events;
return {
for (final MapEntry(key: id, value: event) in events.entries)
if (!rowSawObservedUnreadEvent(
event,
channelId,
(key) => readState.contexts[key],
))
id: event,
};
}

/// The row hints that quiet [channelId]'s row through what it shows now,
/// without reading anything: a receipt for each attention message and the
/// newest shown time for ordinary ones. Empty when the row shows nothing
/// the saved hints do not already hide.
Map<String, int> channelRowSeenMarks({
required String channelId,
required Map<String, ObservedUnreadEvent> events,
required ReadStateState readState,
}) {
final marks = <String, int>{};
var newest = -1;
var ordinary = false;
for (final event in events.values) {
final readAt = observedUnreadEventReadAt(
event,
channelId,
readState.effectiveTimestamp,
);
if (readAt != null && event.createdAt <= readAt) continue;
if (rowSawObservedUnreadEvent(
event,
channelId,
(key) => readState.contexts[key],
)) {
continue;
}
if (event.createdAt > newest) newest = event.createdAt;
if (event.channelCatchUp) {
ordinary = true;
Comment on lines +143 to +145

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Derive the seen frontier only from ordinary events

When the observed set contains an ordinary message plus a newer mention or reply, newest advances to the attention event's timestamp and that value is stored as seen:<channel>. If an ordinary event is subsequently delivered with a timestamp between those two values—such as after reconnect or from a sender with a slower clock—rowSawObservedUnreadEvent treats it as already seen, so the newly arrived activity never highlights the row. Update the channel frontier only while processing event.channelCatchUp; attention events already have their per-message receipts.

AGENTS.md reference: AGENTS.md:L266-L271

Useful? React with 👍 / 👎.

} else {
marks[seenMessageContextKey(event.id)] = event.createdAt;
Comment on lines +146 to +147

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound locally retained attention receipts

For every attention event observed while a channel is open, this creates a unique seen:msg:<id> context, with no count-based eviction. These contexts accumulate in ReadStateManager._effectiveState; serialization filters old timestamps but never removes them from the live map, while every receipt within the 30-day horizon is rewritten into the journal. Sustained mention/reply traffic therefore makes memory use and each JSON persist grow continuously during a long-running session, so the receipt set needs live pruning or a hard capacity bound.

AGENTS.md reference: AGENTS.md:L249-L256

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Prune expired row receipts from live state

_persistLocalState prunes into a separate observed map, leaving _effectiveState unchanged. As new attention IDs arrive, expired seen:msg: entries therefore remain in memory and participate in subsequent save scans. Please prune these obsolete row receipts from live state too. If adding capacity eviction, dropping a row hint should not advance the read floor—it quiets the row but does not mark anything read.

Posted by Codex (gpt-6.1-sol) on behalf of kylez

}
}
if (ordinary) marks[seenContextKey(channelId)] = newest;
return marks;
}
5 changes: 5 additions & 0 deletions mobile/lib/features/channels/channels_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,11 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
previous.complete();
}

/// [channelId]'s entry of [observedUnreadEventsByChannel], without copying
/// every other channel's.
Map<String, ObservedUnreadEvent> observedUnreadEventsFor(String channelId) =>
Map.unmodifiable(_observedUnreadEventsByChannel[channelId] ?? const {});

Map<String, Map<String, ObservedUnreadEvent>>
get observedUnreadEventsByChannel =>
Map<String, Map<String, ObservedUnreadEvent>>.unmodifiable({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,20 @@ int? observedUnreadEventReadAt(
if (event.channelCatchUp) markerOf(activityContextKey(channelId)),
]);
}

/// Whether [channelId]'s row has seen [event] (see [seenContextKey]): an
/// attention message by its own receipt, whatever its timestamp, and an
/// ordinary message by the channel's row hint. [storedOf] looks up a stored
/// read-state context, without the retention floor: a missing receipt must
/// stay missing.
bool rowSawObservedUnreadEvent(
ObservedUnreadEvent event,
String channelId,
int? Function(String contextId) storedOf,
) {
if (!event.channelCatchUp) {
return storedOf(seenMessageContextKey(event.id)) != null;
}
final seenAt = storedOf(seenContextKey(channelId));
return seenAt != null && event.createdAt <= seenAt;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';

import '../channel_unread.dart';
import '../channels_provider.dart';
import '../../../shared/read_state/read_state_provider.dart';
import 'observed_unread_event.dart';
Expand Down Expand Up @@ -37,7 +38,12 @@ final unreadBadgeProvider = Provider<UnreadBadgeState>((ref) {

if (!latestObservedByChannel.containsKey(channel.id)) continue;

final observedEvents = observedEventsByChannel[channel.id];
// The badge follows the channel list rows: what a row has seen
// does not count.
final observed = observedEventsByChannel[channel.id];
final observedEvents = observed == null
? null
: rowObservedUnreadEvents(observed, channel.id, readState);
int? readAtForObservedEvent(ObservedUnreadEvent event) =>
observedUnreadEventReadAt(
event,
Expand Down
16 changes: 16 additions & 0 deletions mobile/lib/shared/read_state/read_state_format.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,22 @@ String threadContextKey(String rootId) => '$threadContextPrefix$rootId';
String activityContextKey(String channelId) => 'activity:$channelId';
String threadActivityContextKey(String rootId) => 'thread-activity:$rootId';

/// Row hints, written by this app and `buzz-app` when the reader opens a
/// channel (`buzz-app`'s `docs/unread.md`). They quiet the channel's row in
/// the channel list and the app badge, and read nothing. `seen:<channel>` is
/// the newest ordinary message the row has seen, and syncs.
/// `seen:msg:<id>` is the receipt for one attention message (a mention,
/// broadcast, DM or reply), and stays on this device like `msg:`.
String seenContextKey(String channelId) => 'seen:$channelId';
const _seenMessagePrefix = 'seen:msg:';
String seenMessageContextKey(String messageId) =>
'$_seenMessagePrefix$messageId';

/// Whether [contextId] stays on this device and is never published.
bool isLocalOnlyContext(String contextId) =>
contextId.startsWith(msgContextPrefix) ||
contextId.startsWith(_seenMessagePrefix);

/// Whether `activity:<channel>` reads a message: an ordinary top-level
/// message, not a reply, mention, broadcast or DM.
bool readByChannelCatchUp({
Expand Down
35 changes: 23 additions & 12 deletions mobile/lib/shared/read_state/read_state_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -243,17 +243,28 @@ class ReadStateManager {
);
}

void markContextRead(String contextId, int unixTimestamp) {
if (_disposed || isOverrideContext(contextId) || unixTimestamp < 0) return;
if (_advanceContext(
contextId,
unixTimestamp,
publishable: !contextId.startsWith(msgContextPrefix),
)) {
_persistLocalState();
_onChanged();
if (!contextId.startsWith(msgContextPrefix)) _schedulePublish();
void markContextRead(String contextId, int unixTimestamp) =>
markContextsRead({contextId: unixTimestamp});

/// Advance several contexts together, saving and notifying once. Contexts
/// that [isLocalOnlyContext] are never published.
void markContextsRead(Map<String, int> contexts) {
if (_disposed) return;
var changed = false;
var publish = false;
for (final MapEntry(key: contextId, value: unixTimestamp)
in contexts.entries) {
if (isOverrideContext(contextId) || unixTimestamp < 0) continue;
final publishable = !isLocalOnlyContext(contextId);
if (_advanceContext(contextId, unixTimestamp, publishable: publishable)) {
changed = true;
publish = publish || publishable;
}
}
if (!changed) return;
_persistLocalState();
_onChanged();
if (publish) _schedulePublish();
}

void seedContextRead(String contextId, int unixTimestamp) {
Expand Down Expand Up @@ -432,7 +443,7 @@ class ReadStateManager {
}
// Historical own-slot values are recoverable local writes. Peer
// maxima never change the locally owned values or placement.
if (own && !entry.key.startsWith(msgContextPrefix)) {
if (own && !isLocalOnlyContext(entry.key)) {
_localContexts[entry.key] = max(
_localContexts[entry.key] ?? 0,
entry.value,
Expand Down Expand Up @@ -849,7 +860,7 @@ class ReadStateManager {
(entry) =>
stored.publishableContextIds.contains(entry.key) &&
!isOverrideContext(entry.key) &&
!entry.key.startsWith(msgContextPrefix),
!isLocalOnlyContext(entry.key),
),
);
_carriedOverrides.addAll(
Expand Down
7 changes: 7 additions & 0 deletions mobile/lib/shared/read_state/read_state_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,13 @@ class ReadStateNotifier extends Notifier<ReadStateState> {
}
}

/// Advance several contexts together, saving once. Clears no forced-unread
/// flag: the channel page writes row hints (`seen:`) this way, which read
/// nothing.
void markContextsRead(Map<String, int> contexts) {
_manager?.markContextsRead(contexts);
}

/// 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
Expand Down
Loading
Loading