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
208 changes: 163 additions & 45 deletions mobile/lib/features/channels/compose_bar/compose_bar_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ class ComposeBar extends HookConsumerWidget {
final draftKey = composeDraftKey(channelId, threadHeadId: threadHeadId);
final draftRevision = useRef(0);
final draftIdentity = _composerDraftIdentity(ref);
final authorizationVisit = useRef<Object?>(null);
final authorizationAttempt = useRef<Object?>(null);
useEffect(() {
final visit = Object();
authorizationVisit.value = visit;
return () {
if (authorizationVisit.value == visit) authorizationVisit.value = null;
};
}, [draftKey, draftIdentity]);
final isComposerExpanded = useState(false);
final androidImeTransitionStarted = useState(
defaultTargetPlatform != TargetPlatform.android,
Expand Down Expand Up @@ -464,51 +473,115 @@ class ComposeBar extends HookConsumerWidget {
uploadingCount.value > 0) {
return;
}
final submittedDraftRevision = draftRevision.value;
// Resolved before any await: see
// `_reportSendCancelledByCommunitySwitch`.
final messenger = ScaffoldMessenger.maybeOf(context);

// Extract pubkeys for mentions present in the final text.
final selectedMentions = <MentionCandidate>[
for (final entry in mentionMap.value.entries)
if (hasMention(text, entry.key)) entry.value,
];
final outgoing = _OutgoingMentions(selectedMentions);
final scan = await _scanNonMemberMentions(
ref,
channelId: channelId,
selectedMentions: selectedMentions,
currentPubkey: currentPubkey,
);
final attempt = Object();
authorizationAttempt.value = attempt;
isSending.value = true;
void Function()? checkPreparationCurrent;
try {
final submittedDraftRevision = draftRevision.value;
var authorizationRevision = submittedDraftRevision;
final visit = authorizationVisit.value;
final config = ref.read(relayConfigProvider);
final readAuthorization = ref.read(agentAuthorizationReaderProvider);
bool isAuthorizationCurrent() =>
context.mounted &&
visit == authorizationVisit.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 ||
authorizationRevision != draftRevision.value) {
throw const _ComposeAuthorizationCancelled();
}
}

// 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) {
if (!context.mounted) return;
final choice = await _promptNonMemberMention(
context,
names: [for (final candidate in scan.humans) candidate.label],
canInvite: scan.canAddMembers,
checkPreparationCurrent = ensureAuthorizationCurrent;

Future<void> authorize(Set<String> keys, {bool prepare = false}) async {
if (keys.isEmpty) return;
ensureAuthorizationCurrent();
try {
await authorizeAgentMentions(
readAuthorization,
keys,
currentPubkey,
channelId,
isAuthorizationCurrent,
prepare: prepare,
);
} catch (_) {
// A stale request is cancellation, not an access decision.
ensureAuthorizationCurrent();
rethrow;
}
ensureAuthorizationCurrent();
}

// Resolved before any await: see
// `_reportSendCancelledByCommunitySwitch`.
final messenger = ScaffoldMessenger.maybeOf(context);

// Extract pubkeys for mentions present in the final text.
final selectedMentions = <MentionCandidate>[
for (final entry in mentionMap.value.entries)
if (hasMention(text, entry.key)) entry.value,
];
final outgoing = _OutgoingMentions(selectedMentions);
final intendedAgentKeys = {
for (final mention in selectedMentions)
if (mention.isAgent) mention.pubkey.toLowerCase(),
};
final scan = await _scanNonMemberMentions(
ref,
channelId: channelId,
selectedMentions: selectedMentions,
currentPubkey: currentPubkey,
);
if (choice == null) return; // Dismissed β€” keep the draft, send nothing.
outgoing.resolveHumanChoice(choice, scan.humans);
}

final queuedAttachments = List<_PendingAttachment>.of(attachments.value);
final channelActions = ref.read(channelActionsProvider);
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) {
if (!context.mounted) return;
final choice = await _promptNonMemberMention(
context,
names: [for (final candidate in scan.humans) candidate.label],
canInvite: scan.canAddMembers,
);
if (intendedAgentKeys.isNotEmpty) ensureAuthorizationCurrent();
if (choice == null) {
return; // Dismissed β€” keep the draft, send nothing.
}
outgoing.resolveHumanChoice(choice, scan.humans);
}

final queuedAttachments = List<_PendingAttachment>.of(
attachments.value,
);
final channelActions = ref.read(channelActionsProvider);

// An add that was refused doesn't block the message: it is reported and
// the un-added mentions are demoted to reference tags so the send lands.
Future<void> addMentionedNonMembers() => outgoing.addNonMembers(
channelActions,
scan: scan,
messenger: messenger,
);
// Agent failures stop publication; the original draft keeps its keys.
Future<void> addMentionedNonMembers() async {
final keys = intendedAgentKeys.intersection(outgoing.pubkeys.toSet());
await authorize(keys, prepare: true);
await outgoing.addNonMembers(
channelActions,
scan: scan,
messenger: messenger,
);
if (!outgoing.pubkeys.toSet().containsAll(keys)) {
throw Exception(
'Mention invitation failed. Draft kept; retry or remove the mention.',
);
}
await authorize(keys);
}

isSending.value = true;
try {
if (queuedAttachments.isEmpty) {
if (!context.mounted) return;
await _sendTextOnlyDraft(
Expand All @@ -532,13 +605,17 @@ class ComposeBar extends HookConsumerWidget {
return;
}

if (intendedAgentKeys.isNotEmpty) ensureAuthorizationCurrent();
final draftText = controller.value;
final draftAttachments = List<_PendingAttachment>.of(attachments.value);
final draftMentions = Map<String, MentionCandidate>.of(
mentionMap.value,
);
clearComposer();
// Agent authorization is preparation, not a detached background send.
final preparingAgents = intendedAgentKeys.isNotEmpty;
if (!preparingAgents) clearComposer();
final clearedDraftRevision = draftRevision.value;
authorizationRevision = clearedDraftRevision;
uploadingCount.value += 1;
uploadProgress.value = 0;
isSending.value = false;
Expand Down Expand Up @@ -578,17 +655,28 @@ class ComposeBar extends HookConsumerWidget {
if (queueGeneration != uploadGeneration.value) return;
await addMentionedNonMembers();
if (queueGeneration != uploadGeneration.value) return;
if (preparingAgents) {
ensureAuthorizationCurrent();
clearComposer();
authorizationRevision = draftRevision.value;
}
await delivery(
payload.content,
outgoing.pubkeys,
mediaTags: [...payload.mediaTags, ...outgoing.referenceTags],
);
} on _ComposeAuthorizationCancelled {
// Keep the newer draft without displaying a false access error.
} catch (error) {
if (cancellation.isCancelled) return;
if (context.mounted) uploadError.value = _formatUploadError(error);
if (error is StateError) {
_reportSendCancelledByCommunitySwitch(messenger);
} else if (context.mounted) {
uploadError.value = _formatUploadError(error);
}
if (context.mounted &&
queueGeneration == uploadGeneration.value &&
draftRevision.value == clearedDraftRevision) {
draftRevision.value == authorizationRevision) {
controller.value = draftText;
attachments.value = draftAttachments;
retainedForRetry = true;
Expand All @@ -598,7 +686,13 @@ class ComposeBar extends HookConsumerWidget {
focusNode.requestFocus();
}
} finally {
if (!retainedForRetry) {
final sourceRetainsFiles =
preparingAgents &&
context.mounted &&
attachments.value.any(
(item) => queuedAttachments.contains(item),
);
if (!retainedForRetry && !sourceRetainsFiles) {
await _deleteOwnedAttachments(queuedAttachments);
}
if (activeUploadCancellation.value == cancellation) {
Expand All @@ -609,8 +703,32 @@ class ComposeBar extends HookConsumerWidget {
}
}
}());
} catch (error) {
var communityChanged = false;
// Failed awaits need the same scope/edit classification as success.
try {
checkPreparationCurrent?.call();
if (error is _ComposeAuthorizationCancelled) return;
} on _ComposeAuthorizationCancelled {
return;
} on StateError {
communityChanged = true;
}
if (context.mounted) {
final messenger = ScaffoldMessenger.maybeOf(context);
if (communityChanged) {
_reportSendCancelledByCommunitySwitch(messenger);
} else {
messenger?.showSnackBar(
SnackBar(content: Text(_composeSendErrorMessage(error))),
);
}
}
} finally {
if (context.mounted && isSending.value) isSending.value = false;
if (context.mounted && authorizationAttempt.value == attempt) {
authorizationAttempt.value = null;
isSending.value = false;
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
part of '../compose_bar.dart';

class _ComposeAuthorizationCancelled implements Exception {
const _ComposeAuthorizationCancelled();
}

Future<void> _sendTextOnlyDraft({
required BuildContext context,
required _MarkdownEditingController controller,
Expand Down Expand Up @@ -49,6 +53,8 @@ Future<void> _sendTextOnlyDraft({
outgoing.pubkeys,
mediaTags: [...payload.mediaTags, ...outgoing.referenceTags],
);
} on _ComposeAuthorizationCancelled {
restoreClearedDraft();
} on StateError {
restoreClearedDraft();
_reportSendCancelledByCommunitySwitch(messenger);
Expand Down
1 change: 1 addition & 0 deletions mobile/lib/shared/mentions/agent_identity_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import '../../shared/relay/relay.dart';

part 'agent_policy.dart';
part 'agent_authorization.dart';
part 'agent_publication.dart';

/// A relay agent parsed from its kind:10100 agent-profile event.
///
Expand Down
69 changes: 69 additions & 0 deletions mobile/lib/shared/mentions/agent_publication.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
part of 'agent_identity_provider.dart';

/// Fresh reader used by the composer; suggestions never authorize publication.
typedef AgentAuthorizationReader =
Future<List<AgentDirectoryEntry>> Function(
Set<String> keys,
String? viewer,
String channelId,
bool Function() isCurrent,
);

/// Session-bound fresh authorization reader for exact recipient keys and one
/// destination. Re-reads verified ownership, policy and relay-signed membership;
/// cached directory suggestions are never publication authority. Query and
/// scope-change failures propagate to the caller, which must check currentness
/// before applying membership or publishing. This is not an atomic relay write.
final agentAuthorizationReaderProvider = Provider<AgentAuthorizationReader>((
ref,
) {
final session = ref.watch(relaySessionProvider.notifier);
return (keys, viewer, channelId, isCurrent) => readAgentAuthorization(
session,
keys,
viewer: viewer,
channelId: channelId,
isCurrent: isCurrent,
);
});

/// Verify every intended recipient; never silently shrink the notification set.
Future<void> authorizeAgentMentions(
AgentAuthorizationReader read,
Set<String> keys,
String? viewer,
String channelId,
bool Function() isCurrent, {
bool prepare = false,
}) async {
if (keys.isEmpty) return;
const message =
'Could not authorize a mentioned agent. Check its access and channel membership, then retry or remove the mention.';
try {
if (!isCurrent()) throw Exception(message);
final agents = await read(keys, viewer, channelId, isCurrent);
if (!isCurrent()) throw Exception(message);
for (final key in keys) {
final agent = agents.where((a) => a.pubkey == key).firstOrNull;
final owned = agent?.ownerPubkey != null && agent?.ownerPubkey == viewer;
final allowed =
agent != null &&
((owned &&
const [
'owner-only',
'allowlist',
'anyone',
].contains(agent.respondTo)) ||
(agent.respondTo == 'allowlist' &&
agent.respondToAllowlist.contains(viewer)) ||
(agent.respondTo == 'anyone' &&
agent.channelIds.contains(channelId)));
if (!allowed ||
(!(prepare && owned) && !agent.channelIds.contains(channelId))) {
throw Exception(message);
}
}
} catch (_) {
throw Exception(message);
}
}
Loading