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
1 change: 1 addition & 0 deletions mobile/lib/features/channels/channel_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -918,6 +918,7 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget {
null),
channelId: channel.id,
isDm: resolvedChannel.isDm,
flat: personalChat,
currentPubkey: currentPubkey,
isMember: resolvedChannel.isMember,
isArchived: resolvedChannel.isArchived,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class _MessageList extends HookConsumerWidget {
final String? welcomeAgent;
final ChannelMessagesNotifier notifier;
final bool isDm;
final bool flat;
final String? currentPubkey;
final bool isMember;
final bool isArchived;
Expand All @@ -46,6 +47,7 @@ class _MessageList extends HookConsumerWidget {
this.welcomeAgent,
required this.notifier,
required this.isDm,
required this.flat,
required this.currentPubkey,
required this.isMember,
required this.isArchived,
Expand Down Expand Up @@ -632,8 +634,7 @@ class _MessageList extends HookConsumerWidget {
final visible = <TimelineMessage>[];
for (final position in itemPositionsListener.itemPositions.value) {
if (position.index >= displayEntries.length ||
position.itemLeadingEdge < bottomEdge ||
position.itemTrailingEdge > topEdge) {
!isReadableRow(position, start: bottomEdge, end: topEdge)) {
continue;
}
final group =
Expand All @@ -655,6 +656,7 @@ class _MessageList extends HookConsumerWidget {
loaded: allMessages,
visible: visible,
bottom: atBottom ? latest : null,
flat: flat,
);
final notifier = ref.read(readStateProvider.notifier);
// Reading to the bottom ends a manual "mark unread" on the channel.
Expand Down
124 changes: 88 additions & 36 deletions mobile/lib/features/channels/reading_marks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'dart:async';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';

import '../../shared/read_state/read_state_format.dart';
import '../../shared/read_state/read_state_provider.dart';
Expand All @@ -21,6 +22,11 @@ const readingDwell = Duration(milliseconds: 300);
/// `activity:<channel>` and a thread writes `thread-activity:<root>`. These
/// catch-up marks read ordinary messages and that thread's replies. They do
/// not read mentions, broadcasts or DMs.
/// - The same channel catch-up also gives each loaded top-level mention and
/// broadcast through the bottom row its own `msg:` mark (in a [flat]
/// timeline, each mention through it, replies included), at most the newest
/// [catchUpMessageMarkLimit], so a mention taller than the screen is still
/// read. Messages the reader marked unread keep that intent.
/// - Each fully visible message gets its own `msg:` mark unless its own mark,
/// the channel mark, or (for an ordinary top-level message) `activity:`
/// already reads it. A thread mark never counts here: a reply finds its
Expand All @@ -29,11 +35,13 @@ const readingDwell = Duration(milliseconds: 300);
/// Mark read does that.
///
/// [loaded] is every message loaded for the channel or thread, including
/// replies. [visible] is the messages fully visible for the whole dwell.
/// replies. [visible] is the messages fully visible for the whole dwell; see
/// [isReadableRow].
/// [bottom] is the newest message when its bottom edge is visible at the
/// bottom of the list; otherwise null. Set [threadRootId] when reading a
/// thread, and [isRootThread] when its head is the thread root rather than a
/// nested reply. [now] is the current Unix time in seconds; it defaults to
/// nested reply. Set [flat] for a channel timeline that shows replies inline,
/// as a Solo chat does. [now] is the current Unix time in seconds; it defaults to
/// the clock. The result maps each read-state context to its new time.
Map<String, int> readingMarks({
required ReadStateState readState,
Expand All @@ -45,32 +53,19 @@ Map<String, int> readingMarks({
TimelineMessage? bottom,
String? threadRootId,
bool isRootThread = true,
bool flat = false,
int? now,
}) {
final marks = <String, int>{};
int? markerOf(String contextId) =>
maxReadAt([marks[contextId], readState.effectiveTimestamp(contextId)]);

// A nested thread view shows only one branch, so reaching its bottom does
// not read the whole thread.
if (bottom != null && (threadRootId == null || isRootThread)) {
final catchUp = _catchUpMark(
channelId: channelId,
loaded: loaded,
bottom: bottom,
threadRootId: threadRootId,
markerOf: markerOf,
now: now ?? DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
if (catchUp != null) marks[catchUp.key] = catchUp.value;
}

final self = currentPubkey?.toLowerCase();
for (final message in visible) {
if (message.isSystem || message.pubkey.toLowerCase() == self) continue;
bool needsMark(TimelineMessage message) {
if (message.isSystem || message.pubkey.toLowerCase() == self) return false;
final contextId = msgContextKey(message.id);
// Automatic reading keeps a message the reader marked unread.
if (readState.isForcedUnread(contextId)) continue;
if (readState.isForcedUnread(contextId)) return false;
final ordinary = readByChannelCatchUp(
isDm: isDm,
isReply: message.parentId != null && !_isBroadcast(message),
Expand All @@ -81,35 +76,92 @@ Map<String, int> readingMarks({
markerOf(channelId),
if (ordinary) markerOf(activityContextKey(channelId)),
]);
if (readAt != null && readAt >= message.createdAt) continue;
marks[contextId] = message.createdAt;
return readAt == null || readAt < message.createdAt;
}

void markMessage(TimelineMessage message) {
if (needsMark(message)) {
marks[msgContextKey(message.id)] = message.createdAt;
}
}

// A nested thread view shows only one branch, so reaching its bottom does
// not read the whole thread.
if (bottom != null &&
(threadRootId == null || isRootThread) &&
_showsNewest(
loaded: loaded,
bottom: bottom,
threadRootId: threadRootId,
flat: flat,
)) {
final catchUp = _catchUpMark(
channelId: channelId,
bottom: bottom,
threadRootId: threadRootId,
markerOf: markerOf,
now: now ?? DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
if (catchUp != null) marks[catchUp.key] = catchUp.value;
if (threadRootId == null && !isDm && self != null) {
final attention = [
for (final message in loaded)
if (message.createdAt <= bottom.createdAt &&
(flat || message.parentId == null || _isBroadcast(message)) &&
isHighPriorityEvent(message.tags, self) &&
needsMark(message))
message,
]..sort((a, b) => b.createdAt.compareTo(a.createdAt));
attention.take(catchUpMessageMarkLimit).forEach(markMessage);
}
}

visible.forEach(markMessage);
return marks;
}

/// The most mentions and broadcasts one channel catch-up marks, as in
/// `buzz-app`, so a backlog cannot crowd older marks out of the synced budget.
const catchUpMessageMarkLimit = 128;

/// Whether a row at [position] counts as read: it lies inside the reading
/// area from [start] to [end], or it is taller than that area and covers all
/// of it.
bool isReadableRow(
ItemPosition position, {
required double start,
required double end,
}) =>
(position.itemLeadingEdge >= start && position.itemTrailingEdge <= end) ||
(position.itemLeadingEdge <= start && position.itemTrailingEdge >= end);

/// Whether [bottom] is the live bottom. A loaded message newer than the
/// bottom row means the list does not show it, for example after a jump
/// into history.
bool _showsNewest({
required Iterable<TimelineMessage> loaded,
required TimelineMessage bottom,
required String? threadRootId,
required bool flat,
}) => !loaded.any(
(message) =>
(threadRootId != null || !isCollapsedHuddleEnding(message, loaded)) &&
// The main timeline never renders these, so they can't be its bottom.
!isHiddenMembershipNotice(message) &&
message.createdAt > bottom.createdAt &&
(threadRootId != null
? _threadRootOf(message) == threadRootId
: flat || message.parentId == null || _isBroadcast(message)),
);

/// The catch-up mark for [bottom], or null when it would read nothing new.
MapEntry<String, int>? _catchUpMark({
required String channelId,
required Iterable<TimelineMessage> loaded,
required TimelineMessage bottom,
required String? threadRootId,
required int? Function(String contextId) markerOf,
required int now,
}) {
// A loaded message newer than the bottom row means the list does not show
// the live bottom, for example after a jump into history.
final showsNewest = !loaded.any(
(message) =>
(threadRootId != null || !isCollapsedHuddleEnding(message, loaded)) &&
// The main timeline never renders these, so they can't be its bottom.
!isHiddenMembershipNotice(message) &&
message.createdAt > bottom.createdAt &&
(threadRootId != null
? _threadRootOf(message) == threadRootId
: message.parentId == null || _isBroadcast(message)),
);
if (!showsNewest) return null;

// Never read beyond the last rendered message: a message between that
// row and a hidden lifecycle event may still arrive out of order.
final cut = bottom.createdAt < now ? bottom.createdAt : now;
Expand Down
3 changes: 1 addition & 2 deletions mobile/lib/features/channels/thread_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -733,8 +733,7 @@ class ThreadDetailPage extends HookConsumerWidget {
1 - ((Grid.xs + navigationBottomInset) / viewportHeight) + 0.01;
final visible = <TimelineMessage>[];
for (final position in itemPositionsListener.itemPositions.value) {
if (position.itemLeadingEdge < topEdge ||
position.itemTrailingEdge > bottomEdge) {
if (!isReadableRow(position, start: topEdge, end: bottomEdge)) {
continue;
}
if (position.index == headIndex) {
Expand Down
Loading
Loading