Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 32 additions & 12 deletions mobile/lib/features/channels/compose_bar/compose_bar_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -479,21 +479,27 @@ class ComposeBar extends HookConsumerWidget {
void Function()? checkPreparationCurrent;
try {
final submittedDraftRevision = draftRevision.value;
final submittedUploadGeneration = uploadGeneration.value;
var authorizationRevision = submittedDraftRevision;
final visit = authorizationVisit.value;
final config = ref.read(relayConfigProvider);
final readAuthorization = ref.read(agentAuthorizationReaderProvider);
bool isAuthorizationCurrent() =>
bool ownsSource() =>
context.mounted &&
visit == authorizationVisit.value &&
identical(config, ref.read(relayConfigProvider));
bool isAuthorizationCurrent() =>
ownsSource() &&
submittedUploadGeneration == uploadGeneration.value &&
authorizationRevision == draftRevision.value &&
identical(config, ref.read(relayConfigProvider));
void ensureAuthorizationCurrent() {
if (!context.mounted) throw const _ComposeAuthorizationCancelled();
if (!identical(config, ref.read(relayConfigProvider))) {
throw StateError('Community changed during authorization');
}
if (visit != authorizationVisit.value ||
if (submittedUploadGeneration != uploadGeneration.value ||
visit != authorizationVisit.value ||
authorizationRevision != draftRevision.value) {
throw const _ComposeAuthorizationCancelled();
}
Expand Down Expand Up @@ -542,22 +548,27 @@ class ComposeBar extends HookConsumerWidget {
currentPubkey: currentPubkey,
);

if (intendedAgentKeys.isNotEmpty) ensureAuthorizationCurrent();
// Mentioning humans outside the channel prompts "Invite" / "Do
// nothing" (send without inviting) — mirrors desktop's
// NonMemberMentionDialog. Agents keep the existing silent auto-add.
if (scan.humans.isNotEmpty) {
ensureAuthorizationCurrent();
// Agents and humans both require deliberate invitation intent.
final nonMembers = [
...scan.humans,
...selectedMentions.where(
(candidate) =>
scan.agentPubkeys.contains(candidate.pubkey.toLowerCase()),
),
];
if (nonMembers.isNotEmpty) {
if (!context.mounted) return;
final choice = await _promptNonMemberMention(
context,
names: [for (final candidate in scan.humans) candidate.label],
names: [for (final candidate in nonMembers) candidate.label],
canInvite: scan.canAddMembers,
);
if (intendedAgentKeys.isNotEmpty) ensureAuthorizationCurrent();
ensureAuthorizationCurrent();
if (choice == null) {
return; // Dismissed — keep the draft, send nothing.
}
outgoing.resolveHumanChoice(choice, scan.humans);
outgoing.resolveChoice(choice, nonMembers);
}

final queuedAttachments = List<_PendingAttachment>.of(
Expand All @@ -573,13 +584,20 @@ class ComposeBar extends HookConsumerWidget {
channelActions,
scan: scan,
messenger: messenger,
ensureCurrent: ensureAuthorizationCurrent,
);
if (!outgoing.pubkeys.toSet().containsAll(keys)) {
throw Exception(
'Mention invitation failed. Draft kept; retry or remove the mention.',
);
}
await authorize(keys);
if (queuedAttachments.isEmpty ||
(intendedAgentKeys.isNotEmpty ||
scan.humans.isNotEmpty ||
scan.agentPubkeys.isNotEmpty)) {
ensureAuthorizationCurrent();
}
}

if (queuedAttachments.isEmpty) {
Expand All @@ -590,6 +608,7 @@ class ComposeBar extends HookConsumerWidget {
mentionMap: mentionMap,
draftRevision: draftRevision,
submittedDraftRevision: submittedDraftRevision,
ownsSource: ownsSource,
focusNode: focusNode,
clearComposer: clearComposer,
addMentionedNonMembers: addMentionedNonMembers,
Expand All @@ -605,14 +624,15 @@ class ComposeBar extends HookConsumerWidget {
return;
}

if (intendedAgentKeys.isNotEmpty) ensureAuthorizationCurrent();
ensureAuthorizationCurrent();
final draftText = controller.value;
final draftAttachments = List<_PendingAttachment>.of(attachments.value);
final draftMentions = Map<String, MentionCandidate>.of(
mentionMap.value,
);
// Agent authorization is preparation, not a detached background send.
final preparingAgents = intendedAgentKeys.isNotEmpty;
final preparingAgents =
intendedAgentKeys.isNotEmpty || scan.humans.isNotEmpty;
if (!preparingAgents) clearComposer();
final clearedDraftRevision = draftRevision.value;
authorizationRevision = clearedDraftRevision;
Expand Down
13 changes: 11 additions & 2 deletions mobile/lib/features/channels/compose_bar/draft_lifecycle.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Future<void> _sendTextOnlyDraft({
required ObjectRef<Map<String, MentionCandidate>> mentionMap,
required ObjectRef<int> draftRevision,
required int submittedDraftRevision,
required bool Function() ownsSource,
required FocusNode focusNode,
required VoidCallback clearComposer,
required Future<void> Function() addMentionedNonMembers,
Expand All @@ -23,7 +24,7 @@ Future<void> _sendTextOnlyDraft({
int? clearedDraftRevision;

void restoreClearedDraft() {
if (!context.mounted ||
if (!ownsSource() ||
clearedDraftText == null ||
clearedDraftMentions == null ||
clearedDraftRevision == null ||
Expand All @@ -39,6 +40,7 @@ Future<void> _sendTextOnlyDraft({

try {
await addMentionedNonMembers();
if (!ownsSource()) return;
// Clear before optimistic insertion so the outgoing row and draft never
// appear simultaneously during the send transition. If the user edited
// while membership changes were pending, preserve that newer draft.
Expand Down Expand Up @@ -86,12 +88,18 @@ void _useComposeDraftLifecycle({
required _IOSAttachmentPopoverController iosAttachmentPopover,
required VoidCallback onDraftIdentityChanged,
}) {
// Fence old listeners before effects restore an incoming draft.
final owner = useMemoized(Object.new, [draftKey, draftIdentity]);
final currentOwner = useRef(owner)..value = owner;
final lastDraftIdentity = useRef<String?>(null);
final lastDraftKey = useRef<String?>(null);
useEffect(() {
final identityChanged =
lastDraftIdentity.value != null &&
lastDraftIdentity.value != draftIdentity;
(lastDraftIdentity.value != draftIdentity ||
lastDraftKey.value != draftKey);
lastDraftIdentity.value = draftIdentity;
lastDraftKey.value = draftKey;
final saved = ref.read(composeDraftsProvider.notifier).textFor(draftKey);
if (identityChanged) {
draftRevision.value += 1;
Expand All @@ -114,6 +122,7 @@ void _useComposeDraftLifecycle({

var lastPersistedText = controller.text;
void persistDraft() {
if (!identical(currentOwner.value, owner)) return;
final text = controller.text;
if (text == lastPersistedText) return;
lastPersistedText = text;
Expand Down
64 changes: 35 additions & 29 deletions mobile/lib/features/channels/compose_bar/helpers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ String _composeSendErrorMessage(Object error) {
return error.toString().replaceFirst('Exception: ', '');
}

/// Ask whether to invite mentioned humans who aren't channel members, or
/// Ask whether to invite mentioned identities who aren't channel members, or
/// send without inviting them. Mirrors desktop's `NonMemberMentionDialog`.
/// [canInvite] false (a private channel the sender doesn't own/administer)
/// drops the Invite action — the relay rejects that add, so offering it would
Expand All @@ -338,7 +338,7 @@ Future<_NonMemberMentionChoice?> _promptNonMemberMention(
return showBuzzDialog<_NonMemberMentionChoice>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Mention people outside this channel?'),
title: const Text('Invite mentioned people or agents?'),
content: Text(
canInvite
? '${names.join(', ')} $verb not in this channel. Invite them to '
Expand All @@ -352,7 +352,7 @@ Future<_NonMemberMentionChoice?> _promptNonMemberMention(
onPressed: () => Navigator.of(
dialogContext,
).pop(_NonMemberMentionChoice.sendWithoutInviting),
child: Text(canInvite ? 'Do nothing' : 'Send anyway'),
child: const Text('Send without inviting'),
),
if (canInvite)
TextButton(
Expand Down Expand Up @@ -435,8 +435,8 @@ class _NonMemberAddOutcome {

/// Adds mentioned non-members to the channel before a send.
///
/// Agents are added silently with the `bot` role; humans are only passed here
/// after they have been explicitly invited from the mention prompt.
/// Agents use the `bot` role, humans the `member` role. Both require an explicit
/// invitation choice before reaching this helper.
///
/// A rejection is reported, never thrown: the send is fire-and-forget, so an
/// escaping error would drop the message with nothing shown. [StateError] still
Expand All @@ -447,10 +447,11 @@ Future<_NonMemberAddOutcome> _addMentionedNonMembers(
required List<String> agentPubkeys,
required List<String> humanPubkeys,
required bool canAddMembers,
required VoidCallback ensureCurrent,
}) async {
final pending = [
if (agentPubkeys.isNotEmpty) (agentPubkeys, 'bot'),
if (humanPubkeys.isNotEmpty) (humanPubkeys, 'member'),
for (final pubkey in agentPubkeys) ([pubkey], 'bot'),
for (final pubkey in humanPubkeys) ([pubkey], 'member'),
];
if (pending.isEmpty) return _NonMemberAddOutcome.empty;

Expand All @@ -467,11 +468,15 @@ Future<_NonMemberAddOutcome> _addMentionedNonMembers(
final errors = <String>[];
for (final (pubkeys, role) in pending) {
try {
ensureCurrent();
await channelActions.addMembers(
channelId: channelId,
pubkeys: pubkeys,
role: role,
);
ensureCurrent();
} on _ComposeAuthorizationCancelled {
rethrow;
} on StateError {
rethrow;
} catch (error) {
Expand Down Expand Up @@ -518,12 +523,13 @@ Future<_NonMemberMentionScan> _scanNonMemberMentions(
);
if (selectedMentions.isEmpty) return none;

final channel = (await ref.read(
channelsProvider.future,
)).firstWhere((candidate) => candidate.id == channelId);
// Capture both reads before yielding: WidgetRef may be disposed while waiting.
final (channels, members) = await (
ref.read(channelsProvider.future),
ref.read(channelMembersProvider(channelId).future),
).wait;
final channel = channels.firstWhere((candidate) => candidate.id == channelId);
if (channel.isDm) return none;

final members = await ref.read(channelMembersProvider(channelId).future);
final memberPubkeys = {
for (final member in members) member.pubkey.toLowerCase(),
};
Expand Down Expand Up @@ -561,13 +567,13 @@ Future<_NonMemberMentionScan> _scanNonMemberMentions(

/// The p-tags and `mention` reference tags an outgoing message should carry.
///
/// Anyone who ends up *not* added is demoted from a p-tag to a reference tag so
/// their name still renders without notifying a non-member — mirrors desktop's
/// `mergeOutgoingTagsWithReferenceMentions`.
/// Only an explicit reference-only choice demotes p-tags. Failed invitation
/// preparation must preserve intent and prevent publication.
class _OutgoingMentions {
List<String> pubkeys;
final List<List<String>> referenceTags = [];
List<String> _invitedHumanPubkeys = const [];
bool _inviteAgents = false;

_OutgoingMentions(List<MentionCandidate> selectedMentions)
: pubkeys = LinkedHashSet<String>.from(
Expand All @@ -587,39 +593,39 @@ class _OutgoingMentions {
}

/// Applies the mention prompt's outcome: invite them, or send without.
void resolveHumanChoice(
void resolveChoice(
_NonMemberMentionChoice choice,
List<MentionCandidate> humans,
List<MentionCandidate> nonMembers,
) {
final humanPubkeys = [
for (final candidate in humans) candidate.pubkey.toLowerCase(),
];
switch (choice) {
case _NonMemberMentionChoice.invite:
_invitedHumanPubkeys = humanPubkeys;
_inviteAgents = true;
_invitedHumanPubkeys = [
for (final candidate in nonMembers)
if (!candidate.isAgent) candidate.pubkey.toLowerCase(),
];
case _NonMemberMentionChoice.sendWithoutInviting:
demote(humanPubkeys);
demote(nonMembers.map((candidate) => candidate.pubkey));
}
}

/// Adds the scanned non-members, demoting and reporting whatever didn't land.
/// Adds explicitly invited non-members, failing the send on any refusal.
Future<void> addNonMembers(
ChannelActions channelActions, {
required _NonMemberMentionScan scan,
required ScaffoldMessengerState? messenger,
required VoidCallback ensureCurrent,
}) async {
final outcome = await _addMentionedNonMembers(
channelActions,
channelId: scan.channelId,
agentPubkeys: scan.agentPubkeys,
agentPubkeys: _inviteAgents ? scan.agentPubkeys : const [],
humanPubkeys: _invitedHumanPubkeys,
canAddMembers: scan.canAddMembers,
ensureCurrent: ensureCurrent,
);
demote(outcome.notAdded);
if (outcome.errors.isNotEmpty) {
messenger?.showSnackBar(
SnackBar(content: Text(outcome.errors.join(' '))),
);
if (outcome.notAdded.isNotEmpty) {
throw Exception('Message not sent. ${outcome.errors.join(' ')}');
}
}
}
Loading
Loading