diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index 43811dcb87..2898bbb95b 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -32,13 +32,19 @@ void _useComposerChannelNames( _MarkdownEditingController controller, AsyncValue> channelsAsync, ) { - final channelNames = { - for (final channel in channelsAsync.asData?.value ?? const []) - channel.name.toLowerCase(): channel.id, - }; - final channelNamesKey = channelNames.entries - .map((entry) => '${entry.key}\u0000${entry.value}') - .join('\u0001'); + // The composer rebuilds on every keystroke and keyboard frame, so build the + // name map and its key only when the channel list itself changes. + final channels = channelsAsync.asData?.value; + final (:channelNames, :channelNamesKey) = useMemoized(() { + final names = { + for (final channel in channels ?? const []) + channel.name.toLowerCase(): channel.id, + }; + final key = names.entries + .map((entry) => '${entry.key}\u0000${entry.value}') + .join('\u0001'); + return (channelNames: names, channelNamesKey: key); + }, [channels]); useEffect(() { controller.setChannelNames(channelNames); return null; diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 9afad8a85c..00d073a735 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/scheduler.dart'; import 'dart:async'; import 'package:buzz/features/channels/composer_send_origin.dart'; +import 'dart:collection'; import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; @@ -695,6 +696,37 @@ class _FakeChannelsNotifier extends ChannelsNotifier { void notifyWithCopy() { state = AsyncData([..._channels]); } + + void replaceChannels(List channels) { + state = AsyncData(channels); + } +} + +/// A channel list that counts how many times it is iterated. +class _IterationCountingChannels extends ListBase { + _IterationCountingChannels(this._channels); + + final List _channels; + int iterations = 0; + + @override + Iterator get iterator { + iterations++; + return _channels.iterator; + } + + @override + int get length => _channels.length; + + @override + set length(int value) => throw UnsupportedError('read-only'); + + @override + Channel operator [](int index) => _channels[index]; + + @override + void operator []=(int index, Channel value) => + throw UnsupportedError('read-only'); } void main() { @@ -3806,6 +3838,86 @@ void main() { ); }); + testWidgets('builds channel names once per channel list, not per build', ( + tester, + ) async { + final channels = _IterationCountingChannels([ + _makeChannel(name: 'engineering', channelType: 'stream'), + ]); + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + channels: channels, + onSend: (_, _, {mediaTags = const >[]}) async {}, + ), + ); + await _expandComposer(tester); + await tester.pump(); + expect(channels.iterations, greaterThan(0)); + final settled = channels.iterations; + + // Typing and keyboard frames rebuild the composer without changing the + // channel list. + addTearDown(tester.view.resetViewInsets); + for (var i = 1; i <= 5; i++) { + tester.view.viewInsets = FakeViewPadding(bottom: i * 40.0); + await tester.enterText(find.byType(TextField), 'draft $i'); + await tester.pump(); + } + + expect(channels.iterations, settled); + }); + + testWidgets('composer channel chips follow channel list changes', ( + tester, + ) async { + const channelId = '580ca78b-9dae-46f3-8854-bd671853ba32'; + Channel named(String name) => Channel( + id: channelId, + name: name, + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: 'creator', + createdAt: DateTime(2026), + memberCount: 1, + isMember: true, + ); + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + channels: [named('engineering')], + onSend: (_, _, {mediaTags = const >[]}) async {}, + ), + ); + await _expandComposer(tester); + await tester.enterText( + find.byType(TextField), + 'buzz://channel/$channelId', + ); + await tester.pump(); + expect( + find.byKey(const ValueKey('composer-buzz-link-chip:engineering')), + findsOneWidget, + ); + + final container = ProviderScope.containerOf( + tester.element(find.byType(ComposeBar)), + ); + (container.read(channelsProvider.notifier) as _FakeChannelsNotifier) + .replaceChannels([named('platform')]); + await tester.pump(); + + expect( + find.byKey(const ValueKey('composer-buzz-link-chip:platform')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('composer-buzz-link-chip:engineering')), + findsNothing, + ); + }); + testWidgets('renders all five permalink types as composer chips', ( tester, ) async {