Files
buzz/mobile/lib/features/channels/reading_marks.dart
Tom Brow 9f88eb9c45 mobile: hide self-joins and leaves, collapse membership runs like desktop (#8962)
Busy channels fill the mobile timeline with page after page of small "X
joined the channel along with Y and N others" and "X added Y to the
channel" rows. Desktop now hides most of that
([buzz-app#2258](https://github.com/block/buzz-app/pull/2258)). This
brings mobile's membership notices in line with desktop's current rules
(`membershipRows` / `membershipDescription` in buzz-app
`src/features/messages/membership-rows.ts`).

### Behavior

- **Only explicit changes show.** Self-joins and voluntary leaves are
hidden. That covers both relay leave shapes: a normal Leave (kind 9022,
actor only) and removing yourself (kind 9001, with a target). Additions
by someone else and removals still show.
- **One row per run.** Each uninterrupted run of additions and removals
becomes one row, whatever the elapsed time, local day or actor. Messages
and other channel events (topic, archive, huddles) still split runs.
- **Each person appears once**, under their latest change in the run.
- **Wording:**
- One adder: "Alice added Bob, Carol and N others to the channel. See
all" (unchanged)
  - One remover: "Alice removed Bob ... from the channel" (unchanged)
- Several adders: "Bob, Carol and N others were added to the channel".
It no longer says they "joined".
- Additions and removals together, or several removers: "Bob and 4
others were added · Dan was removed". Each "N others" opens that list.

Mobile keeps its "See all" sheet in place of desktop's hover popover.

### Validation

- Added tests:
  - unit tests for hiding self-joins and both leave shapes
  - grouping across hours, days and actors
  - runs split by messages and other channel events
- a channel page journey with a self-join, a 9022 leave, a 9001 leave,
additions by two people hours apart and a removal: it renders one mixed
row, and the "1 other" sheet works
  - a several-adders wording test
- Updated tests that previously relied on visible self-joins or leaves.
- Full mobile suite (`just mobile-test`) and `flutter analyze` pass.
- Automated full-app run on an iPhone 17 Pro simulator, signed in to a
hosted community:
- The home list and a 33-member channel with membership history load.
Self-joins are gone, messages flow without them, and a two-adder run
reads "X and Y were added to the channel".
- Scrolling back through history and opening and closing a thread work.
  - Captures are not attached because they show internal names.
- Limitation: the internal community now rejects the simulator's
websocket without hosted sign-in, so the 1,500-member channel wasn't
re-checked on this head.
- Human testing is waived per the owner's standing policy.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
2026-11-03 15:50:33 -08:00

189 lines
7.1 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)) &&
// 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;
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]);
}