From 3764a2e4d500bf36c434670b2597b02b289354ee Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 13 Nov 2026 14:40:17 -0800 Subject: [PATCH] perf(mobile): ask typing subscriptions only for the typing window (#9454) Every channel that shows a typing indicator (the open channel, and each Solo row on the home list) subscribes for typing and message events with `limit: 10` and no `since`, so each subscribe replays that channel's last 10 stored messages. The handler then ignores every one of them that is older than the 8 second typing window, so the replay only costs bytes. On an iPhone 12 mini signed in to Builderlab, seven Solo rows made this about 66 KB of kind 9 messages on each cold start, and again on each resubscribe. The subscription now asks only for events inside the typing window (`since` = now minus 8 s), which are the only ones the handler can act on. ### Validation New test: the typing subscription asks for history starting 8 s before it subscribes. It fails without this change. Full mobile test suite and `flutter analyze` pass. On the iPhone 12 mini (profile build with this change, signed in to Builderlab), stored and live events on the typing subscriptions in the first minute or more after a cold start: | | Subscriptions | Kind 9 messages | Typing events | | --- | --- | --- | --- | | Before (60 s) | 7 | 74 (66.6 KB) | 26 (15.6 KB) | | After (80 s) | 8 | 2 (1.5 KB) | 29 (17.8 KB) | Typing events are mostly live ones from agents working in Solo chats, so they stay about the same. The automated run covered a cold start to the home list and its Solo rows. ### Regressions - A device clock running more than 8 s fast can make the relay skip a message that would have ended an indicator. That indicator still clears when its 8 s window ends, and the handler already drops events it considers older than the window, so the same skew loses the same events today. Signed-off-by: Tom Brow Co-authored-by: Tom Brow Co-authored-by: studio --- .../channels/channel_typing_provider.dart | 3 +++ .../channels/channel_typing_provider_test.dart | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/mobile/lib/features/channels/channel_typing_provider.dart b/mobile/lib/features/channels/channel_typing_provider.dart index bd11d3e448..57141b428c 100644 --- a/mobile/lib/features/channels/channel_typing_provider.dart +++ b/mobile/lib/features/channels/channel_typing_provider.dart @@ -187,6 +187,9 @@ class ChannelTypingNotifier extends Notifier> { tags: { '#h': [channelId], }, + // Typing is ephemeral, and stored messages only matter while they + // can still end a visible indicator (see [_handleTypingEvent]). + since: DateTime.now().millisecondsSinceEpoch ~/ 1000 - _ttlMs ~/ 1000, limit: 10, ), (event) { diff --git a/mobile/test/features/channels/channel_typing_provider_test.dart b/mobile/test/features/channels/channel_typing_provider_test.dart index df6e0d6103..6a65dc2114 100644 --- a/mobile/test/features/channels/channel_typing_provider_test.dart +++ b/mobile/test/features/channels/channel_typing_provider_test.dart @@ -9,6 +9,7 @@ import '../../helpers/thread_ids.dart'; class _Relay extends RelaySessionNotifier { late void Function(NostrEvent) receive; int calls = 0; + NostrFilter? filter; bool reject = false; void Function(String)? close; int active = 0; @@ -24,6 +25,7 @@ class _Relay extends RelaySessionNotifier { }) async { expect(filter.kinds, containsAll([20002, 9, 40002, 40008])); calls++; + this.filter = filter; close = onClosed; if (reject) throw StateError('rejected'); receive = onEvent; @@ -43,6 +45,21 @@ void _settle(ProviderContainer container, FakeAsync async) { } void main() { + test('asks only for stored history inside the typing window', () async { + final relay = _Relay(); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => relay)], + ); + addTearDown(container.dispose); + final before = DateTime.now().millisecondsSinceEpoch ~/ 1000; + container.listen(channelTypingProvider('chat'), (_, _) {}); + await Future.delayed(Duration.zero); + final after = DateTime.now().millisecondsSinceEpoch ~/ 1000; + // Older messages can no longer end an indicator, so replaying them on + // every subscribe only downloads them. + expect(relay.filter!.since, inInclusiveRange(before - 8, after - 8)); + }); + test( 'initial rejection retries with a bound and no uncaught error', () => fakeAsync((async) {