From 5ff75413140d917646afc37d5bb8b96f9f881de6 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 29 Oct 2026 10:16:54 -0700 Subject: [PATCH] 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 Signed-off-by: Tom Brow --- .../channels/channel_detail_page.dart | 16 +++---- .../channels/channel_typing_provider.dart | 42 ++++++++++++++++++- .../features/channels/thread_detail_page.dart | 18 ++++---- .../channel_typing_provider_test.dart | 33 +++++++++++++++ 4 files changed, 93 insertions(+), 16 deletions(-) diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 427cc27e34..2d88a15ddc 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -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; diff --git a/mobile/lib/features/channels/channel_typing_provider.dart b/mobile/lib/features/channels/channel_typing_provider.dart index cf299eaaa6..8664cbcf22 100644 --- a/mobile/lib/features/channels/channel_typing_provider.dart +++ b/mobile/lib/features/channels/channel_typing_provider.dart @@ -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 entries; + + VisibleTyping( + List 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> { 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> { 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; diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 8b36284940..9261724625 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -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. diff --git a/mobile/test/features/channels/channel_typing_provider_test.dart b/mobile/test/features/channels/channel_typing_provider_test.dart index b26079c2fd..052a9856ba 100644 --- a/mobile/test/features/channels/channel_typing_provider_test.dart +++ b/mobile/test/features/channels/channel_typing_provider_test.dart @@ -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(); + }), + ); }