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
108 changes: 45 additions & 63 deletions mobile/lib/features/channels/compose_bar/compose_bar_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -485,42 +485,28 @@ class ComposeBar extends HookConsumerWidget {
var authorizationRevision = submittedDraftRevision;
final visit = authorizationVisit.value;
final config = ref.read(relayConfigProvider);
// Equivalent refreshes retain scope; destination/credentials do not.
bool isConfigScopeCurrent() {
final current = ref.read(relayConfigProvider);
return current.baseUrl == config.baseUrl &&
current.nsec == config.nsec;
}
bool isConfigScopeCurrent() => _isComposeConfigCurrent(ref, config);

final readSelected = ref.read(
selectedMentionAuthorizationReaderProvider,
);
final session = ref.read(relaySessionProvider.notifier);
final observedProfiles = <String, NostrEvent>{};
final observedKeys = <String>{};
bool profilesCurrent() => observedProfiles.entries.every((entry) {
final order = ref
.read(userCacheProvider.notifier)
.profileEventOrder(entry.key);
final event = entry.value;
return order == null ||
order.createdAt < event.createdAt ||
(order.createdAt == event.createdAt &&
order.eventId.compareTo(event.id) >= 0);
});
final evidenceChecks = <bool Function()>{};
bool profilesCurrent() => evidenceChecks.every((check) => check());
bool ownsSource() =>
context.mounted &&
visit == authorizationVisit.value &&
isConfigScopeCurrent();
bool isAuthorizationCurrent() =>
bool isAuthorizationScopeCurrent() =>
ownsSource() &&
identical(session, ref.read(relaySessionProvider.notifier)) &&
currentPubkey == ref.read(currentPubkeyProvider) &&
profilesCurrent() &&
submittedUploadGeneration == uploadGeneration.value &&
authorizationRevision == draftRevision.value &&
isConfigScopeCurrent();
void ensureAuthorizationCurrent() {
void ensureAuthorizationScopeCurrent() {
if (!context.mounted) throw const _ComposeAuthorizationCancelled();
if (!isConfigScopeCurrent()) {
throw const _ComposeCommunityChanged();
Expand All @@ -531,12 +517,26 @@ class ComposeBar extends HookConsumerWidget {
throw const _ComposeAuthorizationCancelled();
}
if (!identical(session, ref.read(relaySessionProvider.notifier)) ||
currentPubkey != ref.read(currentPubkeyProvider) ||
!profilesCurrent()) {
currentPubkey != ref.read(currentPubkeyProvider)) {
throw Exception('Mention evidence changed; retry the draft');
}
}

void ensureAuthorizationCurrent() {
ensureAuthorizationScopeCurrent();
if (!profilesCurrent()) {
throw Exception('Mention evidence changed; retry the draft');
}
}

Future<void> guardedDelivery(
String content,
List<String> keys, {
List<List<String>> mediaTags = const [],
}) => withRelayPublicationGuard(
ensureAuthorizationCurrent,
() => onSend(content, keys, mediaTags: mediaTags),
);
checkPreparationCurrent = ensureAuthorizationCurrent;

// Resolved before any await: see
Expand Down Expand Up @@ -566,10 +566,12 @@ class ComposeBar extends HookConsumerWidget {
priorAgentKeys: priorAgentKeys,
observedKeys: observedKeys,
observedProfiles: observedProfiles,
evidenceChecks: evidenceChecks,
currentPubkey: currentPubkey,
channelId: channelId,
ensureAuthorizationCurrent: ensureAuthorizationCurrent,
isAuthorizationCurrent: isAuthorizationCurrent,
isAuthorizationCurrent: isAuthorizationScopeCurrent,
ensureScopeCurrent: ensureAuthorizationScopeCurrent,
prepare: prepare,
);

Expand Down Expand Up @@ -610,44 +612,21 @@ class ComposeBar extends HookConsumerWidget {
);
final channelActions = ref.read(channelActionsProvider);

// Agent failures stop publication; the original draft keeps its keys.
Future<void> addMentionedNonMembers() async {
final keys = outgoing.pubkeys.toSet();
Future<bool> authorizeWrite(String key, String role) async {
final evidence = await authorize(keys, prepare: true);
final fresh = evidence[key]!;
if (fresh.invitationRole != role) {
throw Exception(
'Mention classification changed; retry invitation consent',
);
}
return !fresh.isMember;
}

await authorize(keys, prepare: true);
ensureAuthorizationCurrent();
invitationStarted.value = true;
await outgoing.addNonMembers(
channelActions,
scan: scan,
messenger: messenger,
ensureCurrent: ensureAuthorizationCurrent,
authorizeWrite: authorizeWrite,
);
if (!outgoing.pubkeys.toSet().containsAll(keys)) {
throw Exception(
'Mention invitation failed. Draft kept; retry or remove the mention.',
);
}
await authorize(keys);
if (queuedAttachments.isEmpty ||
(selectedKeys.isNotEmpty ||
scan.humans.isNotEmpty ||
scan.agentPubkeys.isNotEmpty)) {
ensureAuthorizationCurrent();
}
}

Future<void> addMentionedNonMembers() => _prepareMentionInvitations(
outgoing: outgoing,
scan: scan,
channelActions: channelActions,
messenger: messenger,
authorize: authorize,
ensureCurrent: ensureAuthorizationCurrent,
ensureScopeCurrent: ensureAuthorizationScopeCurrent,
onStarted: () => invitationStarted.value = true,
fenceAfterPreparation:
queuedAttachments.isEmpty ||
selectedKeys.isNotEmpty ||
scan.humans.isNotEmpty ||
scan.agentPubkeys.isNotEmpty,
);
if (queuedAttachments.isEmpty) {
if (!context.mounted) return;
await _sendTextOnlyDraft(
Expand All @@ -658,15 +637,18 @@ class ComposeBar extends HookConsumerWidget {
submittedDraftRevision: submittedDraftRevision,
ownsSource: ownsSource,
focusNode: focusNode,
clearComposer: clearComposer,
clearComposer: () {
clearComposer();
authorizationRevision = draftRevision.value;
},
addMentionedNonMembers: addMentionedNonMembers,
payload: _ComposeDraftPayload.fromDraft(
text: text,
attachments: const [],
customEmoji: customEmoji,
),
outgoing: outgoing,
onSend: onSend,
onSend: guardedDelivery,
messenger: messenger,
);
return;
Expand All @@ -691,7 +673,7 @@ class ComposeBar extends HookConsumerWidget {
final cancellation = UploadCancellationToken();
final uploadService = ref.read(mediaUploadServiceProvider);
activeUploadCancellation.value = cancellation;
final delivery = onSend;
final delivery = guardedDelivery;
unawaited(() async {
var retainedForRetry = false;
var delivered = false;
Expand Down
22 changes: 14 additions & 8 deletions mobile/lib/features/channels/compose_bar/helpers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,7 @@ Future<_NonMemberAddOutcome> _addMentionedNonMembers(
required List<String> humanPubkeys,
required bool canAddMembers,
required VoidCallback ensureCurrent,
required VoidCallback ensureScopeCurrent,
required VoidCallback onAccepted,
required Future<bool> Function(String, String) authorizeWrite,
}) async {
Expand All @@ -500,23 +501,26 @@ Future<_NonMemberAddOutcome> _addMentionedNonMembers(
final notAdded = <String>[];
final errors = <String>[];
for (final (pubkeys, role) in pending) {
ensureCurrent();
ensureScopeCurrent();
if (!await authorizeWrite(pubkeys.single, role)) continue;
ensureCurrent();
try {
await channelActions.addMembers(
channelId: channelId,
pubkeys: pubkeys,
role: role,
onAccepted: (_) => onAccepted(),
await withRelayPublicationGuard(
ensureCurrent,
() => channelActions.addMembers(
channelId: channelId,
pubkeys: pubkeys,
role: role,
onAccepted: (_) => onAccepted(),
),
);
ensureCurrent();
ensureScopeCurrent();
} on _ComposeAuthorizationCancelled {
rethrow;
} on _ComposeCommunityChanged {
rethrow;
} on StateError {
ensureCurrent();
ensureScopeCurrent();
rethrow;
} catch (error) {
notAdded.addAll(
Expand Down Expand Up @@ -669,6 +673,7 @@ class _OutgoingMentions {
required _NonMemberMentionScan scan,
required ScaffoldMessengerState? messenger,
required VoidCallback ensureCurrent,
required VoidCallback ensureScopeCurrent,
required Future<bool> Function(String, String) authorizeWrite,
}) async {
final outcome = await _addMentionedNonMembers(
Expand All @@ -680,6 +685,7 @@ class _OutgoingMentions {
.toList(),
canAddMembers: scan.canAddMembers,
ensureCurrent: ensureCurrent,
ensureScopeCurrent: ensureScopeCurrent,
onAccepted: () => acceptedInvitations++,
authorizeWrite: authorizeWrite,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,19 @@ Future<Map<String, SelectedMentionAuthorization>> _authorizeSelectedMentions(
required Set<String> priorAgentKeys,
required Set<String> observedKeys,
required Map<String, NostrEvent> observedProfiles,
required Set<bool Function()> evidenceChecks,
required String? currentPubkey,
required String channelId,
required VoidCallback ensureAuthorizationCurrent,
required VoidCallback ensureScopeCurrent,
required bool Function() isAuthorizationCurrent,
bool prepare = false,
}) async {
ensureAuthorizationCurrent();
if (keys.isEmpty) return const {};
ensureScopeCurrent();
if (keys.isEmpty) {
evidenceChecks.clear();
return const {};
}
try {
final evidence = await readSelected(
keys,
Expand All @@ -34,11 +39,15 @@ Future<Map<String, SelectedMentionAuthorization>> _authorizeSelectedMentions(
observedProfiles.addAll(profiles);
},
);
ensureAuthorizationCurrent();
ensureScopeCurrent();
if (evidence.length != keys.length ||
!evidence.keys.toSet().containsAll(keys)) {
throw Exception('Incomplete selected mention evidence');
}
evidenceChecks
..clear()
..addAll(evidence.values.map((value) => value.isCurrent));
ensureAuthorizationCurrent();
final agents = {
for (final key in keys)
if (evidence[key]!.requiresAgentAuthorization) key,
Expand All @@ -55,7 +64,62 @@ Future<Map<String, SelectedMentionAuthorization>> _authorizeSelectedMentions(
ensureAuthorizationCurrent();
return evidence;
} catch (_) {
ensureAuthorizationCurrent();
ensureScopeCurrent();
rethrow;
}
}

// Failures preserve the exact draft audience; consent never licenses a changed
// role. Kept beside the reader owner so both boundaries use one evaluator.
Future<void> _prepareMentionInvitations({
required _OutgoingMentions outgoing,
required _NonMemberMentionScan scan,
required ChannelActions channelActions,
required ScaffoldMessengerState? messenger,
required Future<Map<String, SelectedMentionAuthorization>> Function(
Set<String> keys, {
bool prepare,
})
authorize,
required VoidCallback ensureCurrent,
required VoidCallback ensureScopeCurrent,
required VoidCallback onStarted,
required bool fenceAfterPreparation,
}) async {
final keys = outgoing.pubkeys.toSet();
Future<bool> authorizeWrite(String key, String role) async {
final evidence = await authorize(keys, prepare: true);
final fresh = evidence[key]!;
if (fresh.invitationRole != role) {
throw Exception(
'Mention classification changed; retry invitation consent',
);
}
return !fresh.isMember;
}

await authorize(keys, prepare: true);
ensureCurrent();
onStarted();
await outgoing.addNonMembers(
channelActions,
scan: scan,
messenger: messenger,
ensureCurrent: ensureCurrent,
ensureScopeCurrent: ensureScopeCurrent,
authorizeWrite: authorizeWrite,
);
if (!outgoing.pubkeys.toSet().containsAll(keys)) {
throw Exception(
'Mention invitation failed. Draft kept; retry or remove the mention.',
);
}
await authorize(keys);
if (fenceAfterPreparation) ensureCurrent();
}

// Equivalent refreshes retain scope; destination/credentials do not.
bool _isComposeConfigCurrent(WidgetRef ref, RelayConfig config) {
final current = ref.read(relayConfigProvider);
return current.baseUrl == config.baseUrl && current.nsec == config.nsec;
}
Loading
Loading