From ff284d00d38777930a0524f86152abf8ea201e24 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 29 Oct 2026 12:14:55 -0700 Subject: [PATCH] mobile: stop typing prune ticks from rebuilding channel pages (#8695) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Summary Typing in the mobile composer felt slower than a native iOS text field. A device trace on an iPhone 12 mini showed the cause: while anyone in the channel is typing, the whole channel page rebuilds once per second, including the visible message list. - `ChannelTypingNotifier._prune()` runs on a 1 s timer and assigned a new list to `state` on every tick, even when nothing had changed. - `_ResolvedChannelDetailPage` watched the whole list and only filtered out the current user afterwards, so every tick rebuilt the page and its `PositionedList` (12 to 35 ms per frame, 8 to 19 ms for the message list alone). - Your own typing indicator echoes back from the relay, so the timer kept running the whole time you typed. In a 30-key trace, 33 of the 35 frames over 12 ms were these rebuilds, 1.00 s apart. ### Fix - `_prune()` publishes only when the visible entries change. `TypingEntry` now compares by value. - The channel and thread pages watch `visibleTypingProvider`, a `VisibleTyping` value that already excludes the current user and applies the page's scope. Your own echo refreshes its expiry every 3 s, which used to rebuild the page. Now it doesn't. Other `channelTypingProvider` consumers are unchanged. ### Related issue None found. ### Testing - Provider tests: a typing entry held across five prune ticks notifies listeners once, and the pages' shared `visibleTypingProvider` is not notified by the user's own refreshed echo but is by another user. Breaking either guard fails its test. - `just mobile-check` and `just mobile-test` pass at 8b8a16845. - Device trace on an iPhone 12 mini: 30 keys typed into a channel composer, Profile builds with build tracing on both sides. Control is 6661d594a, which has the same page code as main. | | control | this PR (5ff754131) | |---|---|---| | Channel page rebuilds while typing | about 1 per second (44 in 40 s) | 0 | | Largest UI build per key, median / p90 | 4.9 / 29.3 ms | 1.0 / 1.4 ms | | Keys with a build over 16.7 ms | 9/30 | 0/30 | | Frames with UI work over 12 ms | 35 (33 page rebuilds) | 1 (a semantics update from the test driver) | | ComposeBar frame, median | 10.2 ms | 3.0 ms | The test driver turns on accessibility, so semantics runs on every frame in both traces. About 1 key in 4 still has a slow frame with about 1 ms of build and 2 ms of raster. A Time Profiler capture put that time in the driver's accessibility queries on the main thread (52% of main-thread samples while typing, none before the first automated tap), so it is a test-harness artifact rather than app work. - Dictation on an iPhone SE (2nd gen): a 15 s paragraph dictated into a channel composer, Profile builds with build tracing. | | main | this PR (5ff754131) | |---|---|---| | UI frame, median / p99 / max | 0.9 / 10.4 / 26.1 ms | 0.9 / 5.3 / 10.8 ms | | Frames over 16.7 ms | 10 | 0 | | Channel page rebuilds | 26 (once a second) | 6 (composer growth, none over budget) | Each dictation update costs about 1 ms. The caret stayed at the end and nothing flickered on either build. buzz-review-completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow Co-authored-by: Claude Opus 5.5 --- .../channels/channel_detail_page.dart | 14 ++-- .../channels/channel_typing_provider.dart | 54 ++++++++++++- .../features/channels/thread_detail_page.dart | 16 ++-- .../channel_typing_provider_test.dart | 76 +++++++++++++++++++ 4 files changed, 144 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..20e5bfa96c 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -365,14 +365,14 @@ 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( + visibleTypingProvider( + channel.id, + 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..bd11d3e448 100644 --- a/mobile/lib/features/channels/channel_typing_provider.dart +++ b/mobile/lib/features/channels/channel_typing_provider.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:hooks_riverpod/misc.dart' show ProviderListenable; import '../../shared/relay/relay.dart'; @@ -17,8 +18,56 @@ 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); +} + +/// What a page watches to show typing in [channelId]: everyone but +/// [selfPubkey], filtered by [include]. The user's own echoed indicator +/// refreshes its expiry every few seconds, and this skips those updates. +ProviderListenable visibleTypingProvider( + String channelId, { + required String? selfPubkey, + required bool Function(TypingEntry entry) include, +}) => channelTypingProvider( + channelId, +).select((all) => VisibleTyping(all, selfPubkey: selfPubkey, include: include)); + /// Tracks who is currently typing in a specific channel. /// /// Subscribes to typing and message events so a reply ends its typing signal. @@ -195,7 +244,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 +253,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..b13fa8447e 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -784,15 +784,15 @@ 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( + visibleTypingProvider( + channelId, + 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..df6e0d6103 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,79 @@ 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(); + }), + ); + test( + 'pages ignore the user\'s own typing echo but see others', + () => fakeAsync((async) { + final relay = _Relay(); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => relay)], + ); + final seen = >[]; + container.listen( + visibleTypingProvider( + 'chat', + selfPubkey: 'ME', + include: (e) => e.threadHeadId == null, + ), + (_, next) => seen.add([for (final e in next.entries) e.pubkey]), + ); + _settle(container, async); + void typing(String id, String pubkey) => relay.receive( + NostrEvent( + id: id, + pubkey: pubkey, + kind: 20002, + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + content: '', + sig: '', + tags: [ + ['h', 'chat'], + ], + ), + ); + for (var i = 0; i < 3; i++) { + typing('self-$i', 'me'); + async.elapse(const Duration(seconds: 3)); + } + expect(container.read(channelTypingProvider('chat')), hasLength(1)); + expect(seen, isEmpty); + typing('other', 'agent'); + expect(seen, [ + ['agent'], + ]); + container.dispose(); + }), + ); }