mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
mobile: stop typing prune ticks from rebuilding channel pages (#8695)
### 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 at8b8a16845. - Device trace on an iPhone 12 mini: 30 keys typed into a channel composer, Profile builds with build tracing on both sides. Control is6661d594a, 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 <tomb@block.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<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);
|
||||
}
|
||||
|
||||
/// 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<VisibleTyping> 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<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 +253,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,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.
|
||||
|
||||
@@ -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 = <List<String>>[];
|
||||
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();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user