From ea4fadea4bb159b14b558e6636f5fd3c22b60d3d Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Wed, 28 Oct 2026 04:55:29 -0700 Subject: [PATCH] fix(mobile): check mentions against the route's channel A DM opened on the fast path is on screen before the channel list has it, but the composer's mention check looked the destination up in that list, so a selected mention failed or waited on the full reload. The channel page now hands the composer the channel it resolved, and the check uses it before falling back to the list. Signed-off-by: Tom Brow --- .../channels/channel_detail_page.dart | 1 + .../compose_bar/compose_bar_widget.dart | 6 ++ .../channels/compose_bar/helpers.dart | 13 ++-- .../features/channels/compose_bar_test.dart | 70 ++++++++++++++++++- 4 files changed, 83 insertions(+), 7 deletions(-) diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 427cc27e34..5d2d525f7a 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -888,6 +888,7 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget { ?assistant.pendingNotice, ComposeBar( channelId: channel.id, + channel: resolvedChannel, focusNode: composerFocusNode, onFocusRestorerChanged: (restoreFocus) => restoreComposerFocus.value = restoreFocus, diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index 591bcdd2e1..2ad74f2c96 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -18,9 +18,14 @@ class ComposeBar extends HookConsumerWidget { /// Optional thread IDs for thread-scoped typing indicators. final String? threadHeadId; final String? rootId; + + /// The destination as the route resolved it. Sends check mentions against + /// it, so a DM opened before the channel list reloads can still send them. + final Channel? channel; const ComposeBar({ super.key, required this.channelId, + this.channel, this.channelName = '', this.hintText, this.threadHeadId, @@ -575,6 +580,7 @@ class ComposeBar extends HookConsumerWidget { scan = await _scanNonMemberMentions( ref, channelId: channelId, + routeChannel: channel, selectedMentions: selectedMentions, currentPubkey: currentPubkey, ); diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index 45c57d2615..6b767ef129 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -510,10 +510,13 @@ class _NonMemberMentionScan { /// Resolves which mentioned identities are non-members, and whether this /// identity may add them (see [Channel.canAddMembers]). A DM's participant set -/// is fixed at creation, so nobody outside it can be added. +/// is fixed at creation, so nobody outside it can be added. [routeChannel], +/// when given, stands in for the channel list, which may not have a just +/// opened DM yet. Future<_NonMemberMentionScan> _scanNonMemberMentions( WidgetRef ref, { required String channelId, + Channel? routeChannel, required List selectedMentions, required String? currentPubkey, }) async { @@ -525,9 +528,11 @@ Future<_NonMemberMentionScan> _scanNonMemberMentions( ); if (selectedMentions.isEmpty) return none; - final channel = (await ref.read( - channelsProvider.future, - )).firstWhere((candidate) => candidate.id == channelId); + final channel = + routeChannel ?? + (await ref.read( + channelsProvider.future, + )).firstWhere((candidate) => candidate.id == channelId); // A DM decides who is inside it the way its send does: by current // membership, or by the channel metadata when membership is unavailable. diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 9abb9caddb..47d474e149 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -187,6 +187,8 @@ Widget _buildComposeBar({ Future> Function()? loadMembers, List relayAgents = const [], List channels = const [], + bool stallChannels = false, + Channel? channel, List cachedMembers = const [], String? currentPubkey, bool? supportsShowingSystemContextMenu, @@ -240,7 +242,11 @@ Widget _buildComposeBar({ if (appLifecycle != null) appLifecycleProvider.overrideWith(appLifecycle), savedPrefsProvider.overrideWithValue(_testPrefs), channelsProvider.overrideWith( - () => _FakeChannelsNotifier(channels, cachedMembers: cachedMembers), + () => _FakeChannelsNotifier( + channels, + cachedMembers: cachedMembers, + stall: stallChannels, + ), ), ], child: MaterialApp( @@ -272,6 +278,7 @@ Widget _buildComposeBar({ final composeBar = ComposeBar( key: ValueKey(composeBarKey), channelId: 'channel-1', + channel: channel, threadHeadId: threadHeadId, focusNode: focusNode, onFocusRestorerChanged: onFocusRestorerChanged, @@ -638,17 +645,23 @@ class _FakeChannelsNotifier extends ChannelsNotifier { final List _channels; final List _cachedMembers; + /// Keeps the channel list loading, like a slow full reload. + final bool _stall; + _FakeChannelsNotifier( this._channels, { List cachedMembers = const [], - }) : _cachedMembers = cachedMembers; + bool stall = false, + }) : _cachedMembers = cachedMembers, + _stall = stall; @override List cachedMembersForChannel(String channelId) => channelId == 'channel-1' ? _cachedMembers : const []; @override - Future> build() async => _channels; + Future> build() => + _stall ? Completer>().future : Future.value(_channels); @override Future refresh({bool fetchDirectory = false}) async { @@ -4698,6 +4711,57 @@ void main() { expect(sentTags, contains(orderedEquals(['mention', maryJane.pubkey]))); }); + testWidgets('a DM opened before the channel list loads sends mentions', ( + tester, + ) async { + final signer = nostr.Keys.generate(); + final pat = '1' * 64; + List? sentRecipients; + List>? sentTags; + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(signer.nsec), + currentPubkey: signer.public, + members: [ + member(signer.public), + ChannelMember( + pubkey: pat, + role: 'member', + joinedAt: DateTime.fromMillisecondsSinceEpoch(1000), + displayName: 'Pat Peer', + ), + ], + // The route opened the DM from its own metadata and roster while + // the full channel list is still loading. + stallChannels: true, + channel: _makeCurrentChannel( + channelType: 'dm', + participantPubkeys: [signer.public, pat], + ), + onSend: + (_, recipients, {mediaTags = const >[]}) async { + sentRecipients = recipients; + sentTags = mediaTags; + }, + ), + ); + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), '@Pat'); + await tester.pump(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Pat Peer')); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(BuzzIcons.arrowUp)); + await tester.pumpAndSettle(); + + expect( + find.text('Message not sent: could not check who is in this channel'), + findsNothing, + ); + expect(sentRecipients, contains(pat)); + expect(sentTags, isNot(contains(orderedEquals(['mention', pat])))); + }); + testWidgets('a failed member check keeps the draft and Retry sends', ( tester, ) async {