Files
buzz/mobile/lib/features/channels/reading_marks.dart
klopez4212 42b395cdef Polish mobile bubbles, mentions, galleries, and huddle cards (#8359)
Builds on the merged themes work in #8358 and inherits its appearance
and chat-color settings.

Polishes mobile conversations with uniform rounded bubbles,
desktop-style text-only mentions and agent icon, aligned gallery counts,
fading gallery avatars, and standard iOS scrolling. Huddles use rounded
cards with attached reaction trays, without duplicate start/end notices;
other activity rows stay centered with smaller text.

Validation: mobile checks, the full mobile test suite, and the gallery
regression test passed. Release build installed on iPhone. Full `just
ci` could not finish: desktop compilation exhausted local disk space.

### Snapshots
Flutter-rendered light and dark previews using seeded sample messages.

| Light | Dark |
| --- | --- |
|
![light](https://raw.githubusercontent.com/block/buzz/62346699199dd707579f46d21608c2a49a8095ef/pr-8359--light.png)
|
![dark](https://raw.githubusercontent.com/block/buzz/62346699199dd707579f46d21608c2a49a8095ef/pr-8359--dark.png)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-10-15 12:39:21 -04:00

187 lines
7.0 KiB
Dart

import 'huddle_timeline.dart';
import 'dart:async';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import '../../shared/read_state/read_state_format.dart';
import '../../shared/read_state/read_state_provider.dart';
import 'timeline_message.dart';
import 'unread_badge/is_high_priority_event.dart';
/// How long a row must stay fully visible before it counts as read. This is
/// the same dwell the web and desktop app (`buzz-app`) uses.
const readingDwell = Duration(milliseconds: 300);
/// The read marks to write after a reading dwell, following `buzz-app`'s
/// rules (its `docs/unread.md`):
///
/// - When the newest message is at the bottom, a channel writes
/// `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.
/// - 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
/// thread only while the root is loaded, so `buzz-app` keeps reply marks.
/// - Automatic reading never writes the channel mark. Only an explicit
/// 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.
/// [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
/// the clock. The result maps each read-state context to its new time.
Map<String, int> readingMarks({
required ReadStateState readState,
required String channelId,
required bool isDm,
required String? currentPubkey,
required Iterable<TimelineMessage> loaded,
required Iterable<TimelineMessage> visible,
TimelineMessage? bottom,
String? threadRootId,
bool isRootThread = true,
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;
final contextId = msgContextKey(message.id);
// Automatic reading keeps a message the reader marked unread.
if (readState.isForcedUnread(contextId)) continue;
final ordinary = readByChannelCatchUp(
isDm: isDm,
isReply: message.parentId != null && !_isBroadcast(message),
highPriority: self == null || isHighPriorityEvent(message.tags, self),
);
final readAt = maxReadAt([
markerOf(contextId),
markerOf(channelId),
if (ordinary) markerOf(activityContextKey(channelId)),
]);
if (readAt != null && readAt >= message.createdAt) continue;
marks[contextId] = message.createdAt;
}
return marks;
}
/// 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)) &&
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;
final key = threadRootId != null
? threadActivityContextKey(threadRootId)
: activityContextKey(channelId);
final covered = maxReadAt([
markerOf(key),
markerOf(channelId),
if (threadRootId != null) markerOf(threadContextKey(threadRootId)),
]);
if (covered != null && covered >= cut) return null;
return MapEntry(key, cut);
}
String? _threadRootOf(TimelineMessage message) =>
message.parentId == null ? null : message.rootId ?? message.parentId;
bool _isBroadcast(TimelineMessage message) => message.tags.any(
(tag) => tag.length >= 2 && tag[0] == 'broadcast' && tag[1] == '1',
);
/// A stable dwell key for [messages]. Pages rebuild a new message list on
/// every frame that touches them, such as a typing indicator, so the list
/// itself would restart the dwell each time. This changes only when a
/// message arrives, leaves, or moves the newest time.
(int, int, String?) readingContentKey(Iterable<TimelineMessage> messages) {
var count = 0;
TimelineMessage? newest;
for (final message in messages) {
count++;
if (newest == null || message.createdAt > newest.createdAt) {
newest = message;
}
}
return (count, newest?.createdAt ?? 0, newest?.id);
}
/// Whether the app is in the foreground. Null means the platform has not
/// reported a state yet, which only happens before the first frame and in
/// tests.
bool isAppInUse(AppLifecycleState? state) =>
state == null || state == AppLifecycleState.resumed;
/// Runs [onDwell] once the list's item positions have stayed still for
/// [readingDwell]. Any position change restarts the wait. Changing [keys]
/// restarts it too, for changes that move no rows, such as new content or
/// the route coming back to the front. The wait is cancelled while
/// [active] is false. [onDwell] reads the current state when it runs.
void useReadingDwell({
required ValueListenable<Object?> positions,
required bool active,
required VoidCallback onDwell,
List<Object?> keys = const [],
}) {
final callback = useRef(onDwell);
callback.value = onDwell;
useEffect(() {
if (!active) return null;
Timer? timer;
void restart() {
timer?.cancel();
timer = Timer(readingDwell, () {
timer = null;
callback.value();
});
}
positions.addListener(restart);
restart();
return () {
timer?.cancel();
positions.removeListener(restart);
};
}, [positions, active, ...keys]);
}