Files
buzz/mobile/lib/features/channels/thread_summary_refresh_queue.dart
Tom Brow a61239f0d8 fix(mobile): avoid opening empty threads on message tap (#7756)
Tapping a mobile channel message with no replies currently opens an
empty thread. Only enable message-row thread navigation when the
existing thread summary reports replies, including replies known to the
relay but not loaded locally. Long-press → Reply still starts a new
thread.

Added widget tests for zero replies, loaded replies, relay-only replies,
and starting a thread through Reply. The zero-reply test failed before
the fix and passes afterward.

Validation: all 227 channel-page tests, the full mobile pre-push checks,
and all PR CI checks passed. The full repository gate failed in
unchanged Rust ACP timing tests (idle reset, keepalive, and steering
deadlines); this PR changes only mobile Dart files.

No duplicate open PR or issue found.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-09-21 08:51:05 -07:00

144 lines
4.5 KiB
Dart

import 'dart:async';
import 'dart:math' as math;
import '../../shared/relay/relay.dart';
import 'channel_window.dart';
/// Coalesces visible-thread recounts, with at most two concurrent scans.
class ThreadSummaryRefreshQueue {
/// Builds a queue whose owner handles refresh errors and lifecycle validity.
ThreadSummaryRefreshQueue({
required this.refresh,
required this.canRun,
required this.onSettled,
});
/// Loads and publishes one root's complete summary.
final Future<void> Function(String rootId) refresh;
/// Whether the owner is connected and still mounted.
final bool Function() canRun;
/// Notifies the owner after an active request releases its slot.
final void Function() onSettled;
final Set<String> _queued = {};
final Set<String> _active = {};
Timer? _timer;
/// Marks a root dirty. Repeated arrivals collapse into one pending scan.
void enqueue(String rootId) {
_queued.remove(rootId);
_queued.add(rootId);
// Dropped requests retain their lower-bound display until the thread opens.
while (_queued.length > 256) {
_queued.remove(_queued.first);
}
resume();
}
/// Removes queued work when a relay summary already supplied the answer.
void cancel(String rootId) => _queued.remove(rootId);
/// Whether another scan was requested after the active scan began.
bool isDirty(String rootId) => _queued.contains(rootId);
/// Cancels a scheduled wake-up while preserving dirty roots for reconnect.
void pause() {
_queued.addAll(_active);
_timer?.cancel();
_timer = null;
}
/// Resumes queued work after the owning connection recovers.
void resume() {
if (_timer != null || _queued.isEmpty || !canRun()) return;
_timer = Timer(const Duration(milliseconds: 200), _drain);
}
void _drain() {
_timer = null;
if (!canRun()) return;
for (final root in _queued.toList()) {
if (_active.length >= 2) break;
if (_active.contains(root)) continue;
_queued.remove(root);
_active.add(root);
unawaited(_run(root));
}
}
Future<void> _run(String root) async {
try {
await refresh(root);
} finally {
_active.remove(root);
if (canRun()) {
onSettled();
resume();
}
}
}
}
/// Conservatively adjusts known totals after explicit deletions, including
/// targets whose payloads have been evicted. Unknown ownership preserves navigation
/// and hides the stale number until a complete recount restores exactness.
Map<String, ChannelWindowThreadSummary> lowerBoundSummariesAfterDeletion(
Map<String, ChannelWindowThreadSummary> summaries,
List<NostrEvent> events,
Set<String> targets,
Map<String, String> owners,
) {
final byId = {for (final event in events) event.id: event};
final knownRoots = owners.values.toSet();
final knownTargetsPerRoot = <String, int>{};
for (final root in owners.values) {
knownTargetsPerRoot[root] = (knownTargetsPerRoot[root] ?? 0) + 1;
}
final hasUnknownTarget = targets.any(
(id) => !owners.containsKey(id) && !byId.containsKey(id),
);
final deleted = {
...targets,
for (final event in events)
if (event.kind == EventKind.deletion ||
event.kind == EventKind.nip29DeleteEvent)
for (final tag in event.tags)
if (tag.length > 1 && tag[0] == 'e') tag[1],
};
final cached = <String?, int>{};
final remaining = <String?, int>{};
for (final event in byId.values) {
if (!EventKind.channelTimelineContentKinds.contains(event.kind) ||
event.threadReference.parentId == null) {
continue;
}
final root = event.threadReference.rootId;
cached[root] = (cached[root] ?? 0) + 1;
if (!deleted.contains(event.id)) {
remaining[root] = (remaining[root] ?? 0) + 1;
}
}
return {
for (final entry in summaries.entries)
if (knownRoots.contains(entry.key) ||
(hasUnknownTarget &&
math.max(entry.value.replyCount, entry.value.descendantCount) >
(cached[entry.key] ?? 0)))
entry.key: ChannelWindowThreadSummary(
replyCount: math.max(
0,
entry.value.replyCount - (knownTargetsPerRoot[entry.key] ?? 0),
),
descendantCount: math.max(
remaining[entry.key] ?? 0,
entry.value.descendantCount - (knownTargetsPerRoot[entry.key] ?? 0),
),
lastReplyAt: entry.value.lastReplyAt,
participantPubkeys: entry.value.participantPubkeys,
isLowerBound: true,
isCountPending: hasUnknownTarget || entry.value.isCountPending,
),
};
}