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
5 changes: 3 additions & 2 deletions mobile/lib/features/activity/activity_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -253,8 +253,9 @@ class ActivityPage extends HookConsumerWidget {

// Deep-link to the represented message: oldest unread in the group,
// falling back to the latest event.
final readAt = resolveInboxItemReadAt(item, markerOf: markerOf);
final target = item.deepLinkTarget(readAt);
final target = item.deepLinkTarget(
(event) => isInboxEventRead(event, markerOf: markerOf),
);
final thread = threadReferenceOf(target.tags);
final threadRootId = isBroadcastReply(target.tags)
? null
Expand Down
22 changes: 10 additions & 12 deletions mobile/lib/features/activity/inbox_item.dart
Original file line number Diff line number Diff line change
Expand Up @@ -113,20 +113,18 @@ class InboxItem {
return null;
}

/// The event the row should deep-link to: the oldest event in the group
/// newer than [readAt] (oldest unread), falling back to the latest event.
FeedItem deepLinkTarget(int? readAt) {
if (readAt != null) {
FeedItem? oldestUnread;
for (final candidate in groupItems) {
if (candidate.createdAt <= readAt) continue;
if (oldestUnread == null ||
candidate.createdAt < oldestUnread.createdAt) {
oldestUnread = candidate;
}
/// The event the row should deep-link to: the oldest grouped event that
/// [isRead] rejects (oldest unread), falling back to the latest event.
FeedItem deepLinkTarget(bool Function(FeedItem event) isRead) {
FeedItem? oldestUnread;
for (final candidate in groupItems) {
if (isRead(candidate)) continue;
if (oldestUnread == null ||
candidate.createdAt < oldestUnread.createdAt) {
oldestUnread = candidate;
}
if (oldestUnread != null) return oldestUnread;
}
if (oldestUnread != null) return oldestUnread;
return item;
}
}
Expand Down
71 changes: 46 additions & 25 deletions mobile/lib/features/activity/inbox_read_state.dart
Original file line number Diff line number Diff line change
@@ -1,30 +1,48 @@
import '../../shared/read_state/read_state_format.dart';
import 'feed_item.dart';
import 'inbox_item.dart';

/// Resolves the effective NIP-RS read marker for one inbox row, mirroring
/// desktop's `resolveInboxItemReadAt`:
/// - thread rows use `max(thread:<root>, msg:<id>)`
/// - channel rows use the channel context marker
/// - rows with no channel have no marker (caller falls back to local state)
int? resolveInboxItemReadAt(
InboxItem item, {
/// The newest read marker that reads one grouped [event], following the
/// unread badge's `observedUnreadEventReadAt`: the channel mark, the
/// event's own `msg:` mark, and for a thread reply `thread:<root>` and
/// `thread-activity:<root>`. Returns null for an event with no channel.
///
/// `activity:<channel>` never counts here. Activity rows hold only mentions,
/// needs-action and agent events addressed to the reader, and DM messages,
/// and that catch-up mark reads none of those.
int? inboxEventReadAt(
FeedItem event, {
required int? Function(String contextId) markerOf,
}) {
final channelId = item.item.channelId;
final threadRootId = item.threadRootId;
if (threadRootId != null) {
return maxReadAt([
markerOf(threadContextKey(threadRootId)),
markerOf(msgContextKey(item.item.id)),
]);
}
return channelId == null ? null : markerOf(channelId);
final channelId = event.channelId;
if (channelId == null) return null;
final rootId = isThreadReply(event.tags)
? threadReferenceOf(event.tags).rootId
: null;
return maxReadAt([
markerOf(channelId),
markerOf(msgContextKey(event.id)),
if (rootId != null) ...[
markerOf(threadContextKey(rootId)),
markerOf(threadActivityContextKey(rootId)),
],
]);
}

/// Whether the marks read one grouped [event].
bool isInboxEventRead(
FeedItem event, {
required int? Function(String contextId) markerOf,
}) {
final readAt = inboxEventReadAt(event, markerOf: markerOf);
return readAt != null && event.createdAt <= readAt;
}

/// Whether the row is read ("done"), mirroring desktop's
/// `useHomeInboxReadState` projection: a local unread override always wins;
/// otherwise the row is done when no grouped activity is newer than the
/// shared read marker; channel-less rows fall back to the local done set.
/// Whether the row is read ("done"): a local unread override always wins;
/// otherwise the row is done when the marks read every grouped event. Each
/// event is checked on its own, because reading a channel marks mentions
/// with per-message marks, so reading the newest says nothing about the
/// rest. Channel-less rows fall back to the local done set.
bool isInboxItemDone(
InboxItem item, {
required int? Function(String contextId) markerOf,
Expand All @@ -34,13 +52,16 @@ bool isInboxItemDone(
final ids = groupedInboxItemIds(item);
if (ids.any(localUnreadOverrides.contains)) return false;

final readAt = resolveInboxItemReadAt(item, markerOf: markerOf);
if (readAt != null) return item.latestActivityAt <= readAt;

if (item.threadRootId != null || item.item.channelId != null) return false;
return localDoneSet.contains(item.id);
if (item.item.channelId == null) return localDoneSet.contains(item.id);
return _groupedEvents(
item,
).every((event) => isInboxEventRead(event, markerOf: markerOf));
}

Iterable<FeedItem> _groupedEvents(InboxItem item) => {
for (final event in [item.item, ...item.groupItems]) event.id: event,
}.values;

/// All event ids identified with the row — desktop's `getGroupedInboxItemIds`.
List<String> groupedInboxItemIds(InboxItem item) {
return {item.id, item.item.id, ...item.groupItems.map((i) => i.id)}.toList();
Expand Down
52 changes: 28 additions & 24 deletions mobile/lib/features/channels/channel_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import 'small_avatar.dart';
import 'sticky_date_header.dart';
import 'thread_detail_page.dart';
import 'thread_replies_provider.dart';
import 'reading_marks.dart';
import 'timeline_message.dart';

part 'channel_detail_page/message_list.dart';
Expand Down Expand Up @@ -206,29 +207,21 @@ Future<void Function()> _subscribeToDmIdentityUpdates(
}
}

int? _channelReadTimestamp({
/// The time that opening [channel] reads it through, or null when opening it
/// reads nothing. Forums read through their last activity. DMs read through
/// the newest loaded message, including replies, or the last activity before
/// messages load.
int? _openReadTimestamp({
required Channel channel,
required AsyncValue<List<NostrEvent>> messagesState,
}) {
if (channel.isForum) {
return dateTimeToUnixSeconds(channel.lastMessageAt);
if (channel.isForum) return dateTimeToUnixSeconds(channel.lastMessageAt);
if (!channel.isDm) return null;
var latest = 0;
for (final event in messagesState.value ?? const <NostrEvent>[]) {
if (event.createdAt > latest) latest = event.createdAt;
}

final events = messagesState.value;
if (events != null && events.isNotEmpty) {
var latest = 0;
for (final event in events) {
if (event.threadReference.parentId != null) continue;
if (event.createdAt > latest) {
latest = event.createdAt;
}
}
if (latest > 0) {
return latest;
}
}

return dateTimeToUnixSeconds(channel.lastMessageAt);
return latest > 0 ? latest : dateTimeToUnixSeconds(channel.lastMessageAt);
}

bool _isOneToOneAgentDm(Channel channel, Set<String> agentPubkeys) {
Expand Down Expand Up @@ -512,7 +505,11 @@ class ChannelDetailPage extends HookConsumerWidget {
!messagesNotifier.hasLoadedMessages;
final appBarTitleContentHeight = _twoLineAppBarTitleContentHeight(context);

final readTimestamp = _channelReadTimestamp(
// Opening a forum or a DM reads the whole channel: a forum has no
// timeline to read row by row, and a DM is all for the reader. Other
// timelines write their read marks while the reader looks at rows; see
// `readingMarks`.
final openReadTimestamp = _openReadTimestamp(
channel: resolvedChannel,
messagesState: messagesState,
);
Expand Down Expand Up @@ -545,19 +542,25 @@ class ChannelDetailPage extends HookConsumerWidget {
],
);

// Opening reads only while this page is in front and the app is in use.
// A covered or backgrounded DM must not read a reply that loads meanwhile;
// it reads through it when it is shown again.
final openReadActive =
isAppInUse(useAppLifecycleState()) &&
(ModalRoute.of(context)?.isCurrent ?? true);
useEffect(() {
if (!readState.isReady || readTimestamp == null) {
if (!readState.isReady || !openReadActive || openReadTimestamp == null) {
return null;
}
return deferReadStateUpdate(context, () {
ref
.read(readStateProvider.notifier)
.markContextRead(channel.id, readTimestamp);
.markContextRead(channel.id, openReadTimestamp);
ref
.read(channelsProvider.notifier)
.clearObservedUnreadCoveredByRead(channel.id, readTimestamp);
.clearObservedUnreadCoveredByRead(channel.id, openReadTimestamp);
});
}, [channel.id, readState.isReady, readTimestamp]);
}, [channel.id, readState.isReady, openReadActive, openReadTimestamp]);

final dmHeader = resolvedChannel.isDm
? _watchDmHeader(ref, resolvedChannel, currentPubkey)
Expand Down Expand Up @@ -766,6 +769,7 @@ class ChannelDetailPage extends HookConsumerWidget {
initialOldestOrdinaryUnreadMessageId !=
null),
channelId: channel.id,
isDm: resolvedChannel.isDm,
currentPubkey: currentPubkey,
isMember: resolvedChannel.isMember,
isArchived: resolvedChannel.isArchived,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class _MessageList extends HookConsumerWidget {
final Set<String> initialForcedUnreadMessageIds;
final bool hasInitialUnread;
final String channelId;
final bool isDm;
final String? currentPubkey;
final bool isMember;
final bool isArchived;
Expand All @@ -30,6 +31,7 @@ class _MessageList extends HookConsumerWidget {
required this.initialForcedUnreadMessageIds,
required this.hasInitialUnread,
required this.channelId,
required this.isDm,
required this.currentPubkey,
required this.isMember,
required this.isArchived,
Expand Down Expand Up @@ -539,6 +541,83 @@ class _MessageList extends HookConsumerWidget {
],
);

void readVisibleRows() {
final readState = ref.read(readStateProvider);
final viewportHeight = timelineViewportHeight.value;
if (!context.mounted ||
!readState.isReady ||
isAutoScrolling.value ||
viewportHeight <= 0 ||
entries.isEmpty) {
return;
}
// Rows hidden under the app bar, the composer or the keyboard are not
// read. The keyboard covers rows even when the list does not follow
// the latest message, so this uses the whole covered height, not the
// list's own bottom inset.
final bottomEdge =
(navigationBottomInset / viewportHeight).clamp(0.0, 1.0) - 0.01;
final topEdge =
1 -
frostedAppBarHeight(
context,
titleContentHeight: appBarTitleContentHeight,
) /
viewportHeight +
0.01;
final visible = <TimelineMessage>[];
for (final position in itemPositionsListener.itemPositions.value) {
if (position.index >= displayEntries.length ||
position.itemLeadingEdge < bottomEdge ||
position.itemTrailingEdge > topEdge) {
continue;
}
final group =
displayEntries[displayEntries.length - 1 - position.index];
visible.addAll(group.map((entry) => entry.message));
}
final latest = entries.last.message;
final atBottom =
latestIsAtBoundary() &&
itemPositionsListener.itemPositions.value.any(
(position) =>
position.index == 0 && position.itemLeadingEdge >= bottomEdge,
);
final marks = readingMarks(
readState: readState,
channelId: channelId,
isDm: isDm,
currentPubkey: currentPubkey,
loaded: allMessages,
visible: visible,
bottom: atBottom ? latest : null,
);
final notifier = ref.read(readStateProvider.notifier);
// Reading to the bottom ends a manual "mark unread" on the channel.
if (atBottom && readState.isForcedUnread(channelId)) {
notifier.clearForcedUnread(channelId);
}
for (final mark in marks.entries) {
notifier.markContextRead(mark.key, mark.value);
}
}

// Reading starts once the timeline holds still for the dwell while this
// page is in front and the app is in use.
final appInUse = isAppInUse(useAppLifecycleState());
final readStateReady = ref.watch(
readStateProvider.select((state) => state.isReady),
);
useReadingDwell(
positions: itemPositionsListener.itemPositions,
active:
readStateReady &&
appInUse &&
(ModalRoute.of(context)?.isCurrent ?? true),
onDwell: readVisibleRows,
keys: [channelId, readingContentKey(allMessages), navigationBottomInset],
);

useEffect(() {
stickyDateHeaderState.value = StickyDateHeaderState.hidden;
stickyDayTimestamp.value = null;
Expand Down
Loading
Loading