mirror of
https://github.com/block/buzz.git
synced 2026-10-30 14:01:37 +00:00
mobile: stop typing prune ticks from rebuilding channel pages
The typing prune timer published a new list every second while anyone was typing, and the channel and thread pages watched the whole list, so each tick rebuilt the page and its message list. Publish only when the visible entries change, and have pages select a value-equal view that excludes the current user's own echoed indicator. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -365,14 +365,16 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget {
|
||||
// Me flattens replies into this timeline, so include their typing signals.
|
||||
// Ordinary channels keep thread activity inside the thread view.
|
||||
final typingEntries = ref
|
||||
.watch(channelTypingProvider(channel.id))
|
||||
.where((e) => personalChat || e.threadHeadId == null)
|
||||
.where(
|
||||
(e) =>
|
||||
currentPubkey == null ||
|
||||
e.pubkey.toLowerCase() != currentPubkey.toLowerCase(),
|
||||
.watch(
|
||||
channelTypingProvider(channel.id).select(
|
||||
(all) => VisibleTyping(
|
||||
all,
|
||||
selfPubkey: currentPubkey,
|
||||
include: (e) => personalChat || e.threadHeadId == null,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
.entries;
|
||||
final baseChannel = listedChannel ?? channel;
|
||||
final resolvedChannel =
|
||||
detailsAsync.whenData(baseChannel.mergeDetails).value ?? baseChannel;
|
||||
|
||||
@@ -17,6 +17,43 @@ class TypingEntry {
|
||||
this.threadHeadId,
|
||||
required this.expiresAtMs,
|
||||
});
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is TypingEntry &&
|
||||
other.pubkey == pubkey &&
|
||||
other.threadHeadId == threadHeadId &&
|
||||
other.expiresAtMs == expiresAtMs;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(pubkey, threadHeadId, expiresAtMs);
|
||||
}
|
||||
|
||||
/// The [TypingEntry]s other than [selfPubkey]'s that [include] keeps,
|
||||
/// compared by value, so a widget that selects them rebuilds only when
|
||||
/// they change rather than on every update to the channel's list.
|
||||
@immutable
|
||||
class VisibleTyping {
|
||||
final List<TypingEntry> entries;
|
||||
|
||||
VisibleTyping(
|
||||
List<TypingEntry> all, {
|
||||
required String? selfPubkey,
|
||||
required bool Function(TypingEntry entry) include,
|
||||
}) : entries = List.unmodifiable([
|
||||
for (final entry in all)
|
||||
if ((selfPubkey == null ||
|
||||
entry.pubkey.toLowerCase() != selfPubkey.toLowerCase()) &&
|
||||
include(entry))
|
||||
entry,
|
||||
]);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is VisibleTyping && listEquals(other.entries, entries);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hashAll(entries);
|
||||
}
|
||||
|
||||
/// Tracks who is currently typing in a specific channel.
|
||||
@@ -195,7 +232,7 @@ class ChannelTypingNotifier extends Notifier<List<TypingEntry>> {
|
||||
void _prune() {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
_activity.removeWhere((_, value) => value.retainUntil <= now);
|
||||
state = [
|
||||
final next = [
|
||||
for (final entry in _activity.entries)
|
||||
if (entry.value.visibleUntil > now)
|
||||
TypingEntry(
|
||||
@@ -204,6 +241,9 @@ class ChannelTypingNotifier extends Notifier<List<TypingEntry>> {
|
||||
expiresAtMs: entry.value.visibleUntil,
|
||||
),
|
||||
];
|
||||
// The timer ticks every second while anyone is active. Publishing an
|
||||
// unchanged list would rebuild every watcher, including channel pages.
|
||||
if (!listEquals(next, state)) state = next;
|
||||
if (_activity.isEmpty) {
|
||||
_pruneTimer?.cancel();
|
||||
_pruneTimer = null;
|
||||
|
||||
@@ -784,15 +784,17 @@ class ThreadDetailPage extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
// Thread-scoped typing indicators (exclude self).
|
||||
final allTyping = ref.watch(channelTypingProvider(channelId));
|
||||
final threadTyping = allTyping
|
||||
.where((e) => e.threadHeadId == threadHead.id)
|
||||
.where(
|
||||
(e) =>
|
||||
currentPubkey == null ||
|
||||
e.pubkey.toLowerCase() != currentPubkey.toLowerCase(),
|
||||
final threadTyping = ref
|
||||
.watch(
|
||||
channelTypingProvider(channelId).select(
|
||||
(all) => VisibleTyping(
|
||||
all,
|
||||
selfPubkey: currentPubkey,
|
||||
include: (e) => e.threadHeadId == threadHead.id,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
.entries;
|
||||
|
||||
// The root of the entire thread chain. If the current thread head is
|
||||
// itself a root message its rootId is null, so fall back to its own id.
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../helpers/thread_ids.dart';
|
||||
|
||||
class _Relay extends RelaySessionNotifier {
|
||||
@@ -214,4 +215,36 @@ void main() {
|
||||
expect(container.read(provider), hasLength(2));
|
||||
},
|
||||
);
|
||||
test(
|
||||
'prune ticks publish only when the visible entries change',
|
||||
() => fakeAsync((async) {
|
||||
final relay = _Relay();
|
||||
final container = ProviderContainer(
|
||||
overrides: [relaySessionProvider.overrideWith(() => relay)],
|
||||
);
|
||||
final provider = channelTypingProvider('chat');
|
||||
var notifications = 0;
|
||||
container.listen(provider, (_, _) => notifications++);
|
||||
_settle(container, async);
|
||||
relay.receive(
|
||||
NostrEvent(
|
||||
id: 'typing',
|
||||
pubkey: 'agent',
|
||||
kind: 20002,
|
||||
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
content: '',
|
||||
sig: '',
|
||||
tags: [
|
||||
['h', 'chat'],
|
||||
],
|
||||
),
|
||||
);
|
||||
expect(container.read(provider), hasLength(1));
|
||||
expect(notifications, 1);
|
||||
async.elapse(const Duration(milliseconds: 5500));
|
||||
expect(container.read(provider), hasLength(1));
|
||||
expect(notifications, 1);
|
||||
container.dispose();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user