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 {