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
1 change: 1 addition & 0 deletions mobile/lib/features/channels/compose_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import 'voice_note_composer_recorder.dart';
import 'voice_note_recording.dart';

part 'compose_bar/helpers.dart';
part 'compose_bar/selected_mention_preparation.dart';
part 'compose_bar/agent_mention_labels.dart';
part 'compose_bar/markdown_editing_controller.dart';
part 'compose_bar/draft_lifecycle.dart';
Expand Down
125 changes: 76 additions & 49 deletions mobile/lib/features/channels/compose_bar/compose_bar_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -485,50 +485,60 @@ class ComposeBar extends HookConsumerWidget {
var authorizationRevision = submittedDraftRevision;
final visit = authorizationVisit.value;
final config = ref.read(relayConfigProvider);
final readAuthorization = ref.read(agentAuthorizationReaderProvider);
// Equivalent refreshes retain scope; destination/credentials do not.
bool isConfigScopeCurrent() {
final current = ref.read(relayConfigProvider);
return current.baseUrl == config.baseUrl &&
current.nsec == config.nsec;
}

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);
});
bool ownsSource() =>
context.mounted &&
visit == authorizationVisit.value &&
identical(config, ref.read(relayConfigProvider));
isConfigScopeCurrent();
bool isAuthorizationCurrent() =>
ownsSource() &&
identical(session, ref.read(relaySessionProvider.notifier)) &&
currentPubkey == ref.read(currentPubkeyProvider) &&
profilesCurrent() &&
submittedUploadGeneration == uploadGeneration.value &&
authorizationRevision == draftRevision.value &&
identical(config, ref.read(relayConfigProvider));
isConfigScopeCurrent();
void ensureAuthorizationCurrent() {
if (!context.mounted) throw const _ComposeAuthorizationCancelled();
if (!identical(config, ref.read(relayConfigProvider))) {
throw StateError('Community changed during authorization');
if (!isConfigScopeCurrent()) {
throw const _ComposeCommunityChanged();
}
if (submittedUploadGeneration != uploadGeneration.value ||
visit != authorizationVisit.value ||
authorizationRevision != draftRevision.value) {
throw const _ComposeAuthorizationCancelled();
}
if (!identical(session, ref.read(relaySessionProvider.notifier)) ||
currentPubkey != ref.read(currentPubkeyProvider) ||
!profilesCurrent()) {
throw Exception('Mention evidence changed; retry the draft');
}
}

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);
Expand All @@ -542,15 +552,34 @@ class ComposeBar extends HookConsumerWidget {
selectedMentions,
'${ref.read(relayConfigProvider).baseUrl} / $channelId',
);
final intendedAgentKeys = {
final selectedKeys = outgoing.pubkeys.toSet();
final priorAgentKeys = {
for (final mention in selectedMentions)
if (mention.isAgent) mention.pubkey.toLowerCase(),
};
Future<Map<String, SelectedMentionAuthorization>> authorize(
Set<String> keys, {
bool prepare = false,
}) => _authorizeSelectedMentions(
keys,
readSelected: readSelected,
priorAgentKeys: priorAgentKeys,
observedKeys: observedKeys,
observedProfiles: observedProfiles,
currentPubkey: currentPubkey,
channelId: channelId,
ensureAuthorizationCurrent: ensureAuthorizationCurrent,
isAuthorizationCurrent: isAuthorizationCurrent,
prepare: prepare,
);

final initialEvidence = await authorize(selectedKeys, prepare: true);
final scan = await _scanNonMemberMentions(
ref,
channelId: channelId,
selectedMentions: selectedMentions,
currentPubkey: currentPubkey,
evidence: initialEvidence,
);

ensureAuthorizationCurrent();
Expand Down Expand Up @@ -583,7 +612,18 @@ class ComposeBar extends HookConsumerWidget {

// Agent failures stop publication; the original draft keeps its keys.
Future<void> addMentionedNonMembers() async {
final keys = intendedAgentKeys.intersection(outgoing.pubkeys.toSet());
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;
Expand All @@ -592,6 +632,7 @@ class ComposeBar extends HookConsumerWidget {
scan: scan,
messenger: messenger,
ensureCurrent: ensureAuthorizationCurrent,
authorizeWrite: authorizeWrite,
);
if (!outgoing.pubkeys.toSet().containsAll(keys)) {
throw Exception(
Expand All @@ -600,7 +641,7 @@ class ComposeBar extends HookConsumerWidget {
}
await authorize(keys);
if (queuedAttachments.isEmpty ||
(intendedAgentKeys.isNotEmpty ||
(selectedKeys.isNotEmpty ||
scan.humans.isNotEmpty ||
scan.agentPubkeys.isNotEmpty)) {
ensureAuthorizationCurrent();
Expand Down Expand Up @@ -639,7 +680,7 @@ class ComposeBar extends HookConsumerWidget {
);
// Agent authorization is preparation, not a detached background send.
final preparingAgents =
intendedAgentKeys.isNotEmpty || scan.humans.isNotEmpty;
selectedKeys.isNotEmpty || scan.humans.isNotEmpty;
if (!preparingAgents) clearComposer();
final clearedDraftRevision = draftRevision.value;
authorizationRevision = clearedDraftRevision;
Expand Down Expand Up @@ -697,8 +738,11 @@ class ComposeBar extends HookConsumerWidget {
} on _ComposeAuthorizationCancelled {
// Keep the newer draft without displaying a false access error.
} catch (error) {
if (cancellation.isCancelled) return;
if (error is StateError) {
if (cancellation.isCancelled &&
error is! _ComposeCommunityChanged) {
return;
}
if (error is _ComposeCommunityChanged) {
_reportSendCancelledByCommunitySwitch(messenger);
} else if (context.mounted) {
uploadError.value = _formatUploadError(error);
Expand Down Expand Up @@ -735,25 +779,8 @@ 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))),
);
}
_reportComposeSendError(context, error, checkPreparationCurrent);
}
} finally {
if (context.mounted && authorizationAttempt.value == attempt) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ class _ComposeAuthorizationCancelled implements Exception {
const _ComposeAuthorizationCancelled();
}

class _ComposeCommunityChanged implements Exception {
const _ComposeCommunityChanged();
}

Future<void> _sendTextOnlyDraft({
required BuildContext context,
required _MarkdownEditingController controller,
Expand Down Expand Up @@ -59,7 +63,7 @@ Future<void> _sendTextOnlyDraft({
delivered = true;
} on _ComposeAuthorizationCancelled {
restoreClearedDraft();
} on StateError {
} on _ComposeCommunityChanged {
restoreClearedDraft();
_reportSendCancelledByCommunitySwitch(messenger);
} catch (error) {
Expand Down
56 changes: 47 additions & 9 deletions mobile/lib/features/channels/compose_bar/helpers.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
part of '../compose_bar.dart';

void _reportComposeSendError(
BuildContext context,
Object error,
VoidCallback? checkPreparationCurrent,
) {
var displayError = 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 _ComposeCommunityChanged {
communityChanged = true;
} on Exception catch (currentError) {
displayError = currentError;
}
if (context.mounted) {
final messenger = ScaffoldMessenger.maybeOf(context);
if (communityChanged) {
_reportSendCancelledByCommunitySwitch(messenger);
} else {
messenger?.showSnackBar(
SnackBar(content: Text(_composeSendErrorMessage(displayError))),
);
}
}
}

String _composerDraftIdentity(WidgetRef ref) =>
'${ref.watch(relayConfigProvider).baseUrl}'
':${ref.watch(myPubkeyProvider) ?? 'anon'}';
Expand Down Expand Up @@ -450,6 +480,7 @@ Future<_NonMemberAddOutcome> _addMentionedNonMembers(
required bool canAddMembers,
required VoidCallback ensureCurrent,
required VoidCallback onAccepted,
required Future<bool> Function(String, String) authorizeWrite,
}) async {
final pending = [
for (final pubkey in agentPubkeys) ([pubkey], 'bot'),
Expand All @@ -469,8 +500,10 @@ Future<_NonMemberAddOutcome> _addMentionedNonMembers(
final notAdded = <String>[];
final errors = <String>[];
for (final (pubkeys, role) in pending) {
ensureCurrent();
if (!await authorizeWrite(pubkeys.single, role)) continue;
ensureCurrent();
try {
ensureCurrent();
await channelActions.addMembers(
channelId: channelId,
pubkeys: pubkeys,
Expand All @@ -480,7 +513,10 @@ Future<_NonMemberAddOutcome> _addMentionedNonMembers(
ensureCurrent();
} on _ComposeAuthorizationCancelled {
rethrow;
} on _ComposeCommunityChanged {
rethrow;
} on StateError {
ensureCurrent();
rethrow;
} catch (error) {
notAdded.addAll(
Expand Down Expand Up @@ -517,6 +553,7 @@ Future<_NonMemberMentionScan> _scanNonMemberMentions(
required String channelId,
required List<MentionCandidate> selectedMentions,
required String? currentPubkey,
required Map<String, SelectedMentionAuthorization> evidence,
}) async {
final none = _NonMemberMentionScan(
channelId: channelId,
Expand All @@ -533,9 +570,6 @@ Future<_NonMemberMentionScan> _scanNonMemberMentions(
).wait;
final channel = channels.firstWhere((candidate) => candidate.id == channelId);
if (channel.isDm) return none;
final memberPubkeys = {
for (final member in members) member.pubkey.toLowerCase(),
};
String? selfRole;
if (currentPubkey != null) {
final self = currentPubkey.toLowerCase();
Expand All @@ -552,8 +586,9 @@ Future<_NonMemberMentionScan> _scanNonMemberMentions(
final seen = <String>{};
for (final candidate in selectedMentions) {
final pubkey = candidate.pubkey.toLowerCase();
if (memberPubkeys.contains(pubkey) || !seen.add(pubkey)) continue;
if (candidate.isAgent) {
final fresh = evidence[pubkey]!;
if (fresh.isMember || !seen.add(pubkey)) continue;
if (fresh.invitationRole == 'bot') {
agentPubkeys.add(pubkey);
} else {
humans.add(candidate);
Expand Down Expand Up @@ -621,8 +656,7 @@ class _OutgoingMentions {
case _NonMemberMentionChoice.invite:
_inviteAgents = true;
_invitedHumanPubkeys = [
for (final candidate in nonMembers)
if (!candidate.isAgent) candidate.pubkey.toLowerCase(),
for (final candidate in nonMembers) candidate.pubkey.toLowerCase(),
];
case _NonMemberMentionChoice.sendWithoutInviting:
demote(nonMembers.map((candidate) => candidate.pubkey));
Expand All @@ -635,15 +669,19 @@ class _OutgoingMentions {
required _NonMemberMentionScan scan,
required ScaffoldMessengerState? messenger,
required VoidCallback ensureCurrent,
required Future<bool> Function(String, String) authorizeWrite,
}) async {
final outcome = await _addMentionedNonMembers(
channelActions,
channelId: scan.channelId,
agentPubkeys: _inviteAgents ? scan.agentPubkeys : const [],
humanPubkeys: _invitedHumanPubkeys,
humanPubkeys: _invitedHumanPubkeys
.where((key) => !scan.agentPubkeys.contains(key))
.toList(),
canAddMembers: scan.canAddMembers,
ensureCurrent: ensureCurrent,
onAccepted: () => acceptedInvitations++,
authorizeWrite: authorizeWrite,
);
if (outcome.notAdded.isNotEmpty) {
throw Exception('Message not sent. ${outcome.errors.join(' ')}');
Expand Down
Loading
Loading