mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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>
144 lines
4.5 KiB
Dart
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,
|
|
),
|
|
};
|
|
}
|