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 <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-28 04:55:29 -07:00
parent 84444d214a
commit ea4fadea4b
4 changed files with 83 additions and 7 deletions

View File

@@ -888,6 +888,7 @@ class _ResolvedChannelDetailPage extends HookConsumerWidget {
?assistant.pendingNotice,
ComposeBar(
channelId: channel.id,
channel: resolvedChannel,
focusNode: composerFocusNode,
onFocusRestorerChanged: (restoreFocus) =>
restoreComposerFocus.value = restoreFocus,

View File

@@ -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,
);

View File

@@ -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<MentionCandidate> 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.

View File

@@ -187,6 +187,8 @@ Widget _buildComposeBar({
Future<List<ChannelMember>> Function()? loadMembers,
List<AgentDirectoryEntry> relayAgents = const <AgentDirectoryEntry>[],
List<Channel> channels = const <Channel>[],
bool stallChannels = false,
Channel? channel,
List<ChannelMember> cachedMembers = const <ChannelMember>[],
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<Channel> _channels;
final List<ChannelMember> _cachedMembers;
/// Keeps the channel list loading, like a slow full reload.
final bool _stall;
_FakeChannelsNotifier(
this._channels, {
List<ChannelMember> cachedMembers = const [],
}) : _cachedMembers = cachedMembers;
bool stall = false,
}) : _cachedMembers = cachedMembers,
_stall = stall;
@override
List<ChannelMember> cachedMembersForChannel(String channelId) =>
channelId == 'channel-1' ? _cachedMembers : const [];
@override
Future<List<Channel>> build() async => _channels;
Future<List<Channel>> build() =>
_stall ? Completer<List<Channel>>().future : Future.value(_channels);
@override
Future<void> 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<String>? sentRecipients;
List<List<String>>? 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 <List<String>>[]}) 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 {