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
25 changes: 17 additions & 8 deletions mobile/lib/features/channels/channel_management_actions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,14 @@ class ChannelActions {
return _refreshChannelsAndRead(channelId);
}

/// Reports each acknowledged write before checking continuation scope.
/// [onAccepted] records irreversible outcomes; it must not mutate scope caches.
/// The community and enclosing operation scope also fence queued writes.
Future<void> addMembers({
required String channelId,
required List<String> pubkeys,
String role = 'member',
ValueChanged<String>? onAccepted,
}) async {
final normalizedRole = role.trim();
if (normalizedRole.isEmpty) {
Expand All @@ -100,18 +104,23 @@ class ChannelActions {
// add, not be recorded as this pubkey's rejection.
_ensureCommunityValid();
try {
await _signedEventRelay.submit(
kind: 9000,
content: '',
tags: [
['h', channelId],
['p', pubkey],
['role', normalizedRole],
],
await withRelayPublicationGuard(
_ensureCommunityValid,
() => _signedEventRelay.submit(
kind: 9000,
content: '',
tags: [
['h', channelId],
['p', pubkey],
['role', normalizedRole],
],
),
);
} catch (error) {
failures[pubkey] = _relayErrorMessage(error);
continue;
}
onAccepted?.call(pubkey);
}
_ensureCommunityValid();
_ref.invalidate(channelMembersProvider(channelId));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ class ComposeBar extends HookConsumerWidget {
final uploadingCount = useState(0);
final uploadProgress = useState(0.0);
final uploadGeneration = useRef(0);
final invitationStarted = useState(false);
final activeUploadCancellation = useRef<UploadCancellationToken?>(null);
final voiceNote = _useComposerVoiceNote(
context: context,
Expand Down Expand Up @@ -473,6 +474,7 @@ class ComposeBar extends HookConsumerWidget {
uploadingCount.value > 0) {
return;
}
invitationStarted.value = false;
final attempt = Object();
authorizationAttempt.value = attempt;
isSending.value = true;
Expand Down Expand Up @@ -536,7 +538,10 @@ class ComposeBar extends HookConsumerWidget {
for (final entry in mentionMap.value.entries)
if (hasMention(text, entry.key)) entry.value,
];
final outgoing = _OutgoingMentions(selectedMentions);
final outgoing = _OutgoingMentions(
selectedMentions,
'${ref.read(relayConfigProvider).baseUrl} / $channelId',
);
final intendedAgentKeys = {
for (final mention in selectedMentions)
if (mention.isAgent) mention.pubkey.toLowerCase(),
Expand Down Expand Up @@ -580,6 +585,8 @@ class ComposeBar extends HookConsumerWidget {
Future<void> addMentionedNonMembers() async {
final keys = intendedAgentKeys.intersection(outgoing.pubkeys.toSet());
await authorize(keys, prepare: true);
ensureAuthorizationCurrent();
invitationStarted.value = true;
await outgoing.addNonMembers(
channelActions,
scan: scan,
Expand Down Expand Up @@ -646,6 +653,7 @@ class ComposeBar extends HookConsumerWidget {
final delivery = onSend;
unawaited(() async {
var retainedForRetry = false;
var delivered = false;
try {
final uploaded = <BlobDescriptor>[];
for (var index = 0; index < queuedAttachments.length; index++) {
Expand Down Expand Up @@ -685,6 +693,7 @@ class ComposeBar extends HookConsumerWidget {
outgoing.pubkeys,
mediaTags: [...payload.mediaTags, ...outgoing.referenceTags],
);
delivered = true;
} on _ComposeAuthorizationCancelled {
// Keep the newer draft without displaying a false access error.
} catch (error) {
Expand All @@ -706,6 +715,8 @@ class ComposeBar extends HookConsumerWidget {
focusNode.requestFocus();
}
} finally {
if (!delivered) outgoing.reportIncomplete(messenger);
if (ownsSource()) invitationStarted.value = false;
final sourceRetainsFiles =
preparingAgents &&
context.mounted &&
Expand Down Expand Up @@ -747,6 +758,7 @@ class ComposeBar extends HookConsumerWidget {
} finally {
if (context.mounted && authorizationAttempt.value == attempt) {
authorizationAttempt.value = null;
if (uploadingCount.value == 0) invitationStarted.value = false;
isSending.value = false;
}
}
Expand Down Expand Up @@ -1065,6 +1077,7 @@ class ComposeBar extends HookConsumerWidget {
visible: hasPendingUploads,
progress: uploadProgress.value,
reducedMotion: reducedMotion,
cancelLabel: invitationStarted.value ? 'Stop remaining' : 'Cancel',
onCancel: () {
activeUploadCancellation.value?.cancel();
uploadGeneration.value += 1;
Expand Down
12 changes: 9 additions & 3 deletions mobile/lib/features/channels/compose_bar/draft_lifecycle.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Future<void> _sendTextOnlyDraft({
required ComposeBarOnSend onSend,
required ScaffoldMessengerState? messenger,
}) async {
var delivered = false;
TextEditingValue? clearedDraftText;
Map<String, MentionCandidate>? clearedDraftMentions;
int? clearedDraftRevision;
Expand Down Expand Up @@ -55,6 +56,7 @@ Future<void> _sendTextOnlyDraft({
outgoing.pubkeys,
mediaTags: [...payload.mediaTags, ...outgoing.referenceTags],
);
delivered = true;
} on _ComposeAuthorizationCancelled {
restoreClearedDraft();
} on StateError {
Expand All @@ -64,9 +66,13 @@ Future<void> _sendTextOnlyDraft({
// The caller runs unawaited, so surface publish failures and restore the
// sent draft unless the user has already started a new one.
restoreClearedDraft();
messenger?.showSnackBar(
SnackBar(content: Text(_composeSendErrorMessage(error))),
);
if (outgoing.acceptedInvitations == 0) {
messenger?.showSnackBar(
SnackBar(content: Text(_composeSendErrorMessage(error))),
);
}
} finally {
if (!delivered) outgoing.reportIncomplete(messenger);
}
}

Expand Down
27 changes: 24 additions & 3 deletions mobile/lib/features/channels/compose_bar/helpers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,8 @@ Future<_NonMemberMentionChoice?> _promptNonMemberMention(
content: Text(
canInvite
? '${names.join(', ')} $verb not in this channel. Invite them to '
'the channel, or send without inviting them.'
'the channel, or send without inviting them. Invitations take effect '
'immediately and remain if the message is stopped or fails.'
: '${names.join(', ')} $verb not in this channel. '
'$privateChannelAddDeniedMessage You can still send without '
'inviting them.',
Expand Down Expand Up @@ -448,6 +449,7 @@ Future<_NonMemberAddOutcome> _addMentionedNonMembers(
required List<String> humanPubkeys,
required bool canAddMembers,
required VoidCallback ensureCurrent,
required VoidCallback onAccepted,
}) async {
final pending = [
for (final pubkey in agentPubkeys) ([pubkey], 'bot'),
Expand All @@ -473,6 +475,7 @@ Future<_NonMemberAddOutcome> _addMentionedNonMembers(
channelId: channelId,
pubkeys: pubkeys,
role: role,
onAccepted: (_) => onAccepted(),
);
ensureCurrent();
} on _ComposeAuthorizationCancelled {
Expand Down Expand Up @@ -574,9 +577,26 @@ class _OutgoingMentions {
final List<List<String>> referenceTags = [];
List<String> _invitedHumanPubkeys = const [];
bool _inviteAgents = false;
int acceptedInvitations = 0;
final String sourceDestination;

void reportIncomplete(ScaffoldMessengerState? messenger) {
if (acceptedInvitations == 0) return;
messenger?.showSnackBar(
SnackBar(
content: Text(
'Message not sent. $acceptedInvitations invitation(s) completed and remain '
'in effect in $sourceDestination. Review channel members before retrying. '
'Check your draft; attachments may need reattaching after leaving.',
),
),
);
}

_OutgoingMentions(List<MentionCandidate> selectedMentions)
: pubkeys = LinkedHashSet<String>.from(
_OutgoingMentions(
List<MentionCandidate> selectedMentions,
this.sourceDestination,
) : pubkeys = LinkedHashSet<String>.from(
selectedMentions.map((candidate) => candidate.pubkey.toLowerCase()),
).toList();

Expand Down Expand Up @@ -623,6 +643,7 @@ class _OutgoingMentions {
humanPubkeys: _invitedHumanPubkeys,
canAddMembers: scan.canAddMembers,
ensureCurrent: ensureCurrent,
onAccepted: () => acceptedInvitations++,
);
if (outcome.notAdded.isNotEmpty) {
throw Exception('Message not sent. ${outcome.errors.join(' ')}');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ class _UploadProgressMotion extends StatelessWidget {
final double progress;
final bool reducedMotion;
final VoidCallback onCancel;
final String cancelLabel;

const _UploadProgressMotion({
required this.visible,
required this.progress,
required this.reducedMotion,
required this.onCancel,
this.cancelLabel = 'Cancel',
});

@override
Expand Down Expand Up @@ -47,6 +49,7 @@ class _UploadProgressMotion extends StatelessWidget {
progress: progress,
reducedMotion: reducedMotion,
onCancel: onCancel,
cancelLabel: cancelLabel,
),
)
: const SizedBox.shrink(
Expand All @@ -62,11 +65,13 @@ class _UploadProgressPill extends HookConsumerWidget {
final double progress;
final bool reducedMotion;
final VoidCallback onCancel;
final String cancelLabel;

const _UploadProgressPill({
required this.progress,
required this.reducedMotion,
required this.onCancel,
this.cancelLabel = 'Cancel',
});

@override
Expand Down Expand Up @@ -172,7 +177,7 @@ class _UploadProgressPill extends HookConsumerWidget {
vertical: Grid.quarter,
),
child: Text(
'Cancel',
cancelLabel,
style: context.textTheme.labelMedium
?.copyWith(
color: context.colors.onSurface,
Expand Down
21 changes: 12 additions & 9 deletions mobile/lib/features/channels/send_message_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,18 @@ class SendMessage {
_ensureDeliveryValid();
NostrEvent? localMessage;
try {
await _signedEventRelay.submit(
kind: EventKind.streamMessage,
content: content,
tags: tags,
onSigned: (event) {
localMessage = event;
_markLocalMessageForAnimation(channelId, event.id);
_addLocalMessage(channelId, event);
},
await withRelayPublicationGuard(
_ensureDeliveryValid,
() => _signedEventRelay.submit(
kind: EventKind.streamMessage,
content: content,
tags: tags,
onSigned: (event) {
localMessage = event;
_markLocalMessageForAnimation(channelId, event.id);
_addLocalMessage(channelId, event);
},
),
);
final event = localMessage;
if (event != null) _completeLocalMessage(channelId, event.id);
Expand Down
27 changes: 27 additions & 0 deletions mobile/lib/shared/mentions/agent_publication.dart
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,30 @@ Future<void> authorizeAgentMentions(
throw Exception(message);
}
}

/// Session-bound all-key evidence reader. Saved agent keys are denial-only taint.
typedef SelectedMentionAuthorizationReader =
Future<Map<String, SelectedMentionAuthorization>> Function(
Set<String> keys,
Set<String> priorAgentKeys,
String viewer,
String channelId,
bool Function() isCurrent,
void Function(Map<String, NostrEvent>) onProfileEvidence,
);

/// Read-only publication seam; does not populate suggestion/profile caches.
final selectedMentionAuthorizationReaderProvider =
Provider<SelectedMentionAuthorizationReader>((ref) {
final session = ref.watch(relaySessionProvider.notifier);
return (keys, prior, viewer, channel, current, observed) =>
readSelectedMentionAuthorization(
session,
keys,
viewer: viewer,
channelId: channel,
priorAgentKeys: prior,
isCurrent: current,
onProfileEvidence: observed,
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ class SelectedMentionAuthorization {
/// owner policy produces a deny-all entry, never runtime fallback.
final AgentDirectoryEntry? agent;

const SelectedMentionAuthorization._(this.kind, this.isMember, this.agent);
/// Construct evidence, not a publication or role-write capability.
const SelectedMentionAuthorization(this.kind, this.isMember, this.agent);

/// True when this key's evidence must be routed through the agent
/// authorization evaluator rather than the ordinary flow: agent or
Expand Down Expand Up @@ -70,6 +71,7 @@ readSelectedMentionAuthorization(
required String channelId,
Set<String> priorAgentKeys = const {},
required bool Function() isCurrent,
void Function(Map<String, NostrEvent>)? onProfileEvidence,
}) async {
void check() {
if (!isCurrent()) throw StateError('Mention authorization scope changed');
Expand Down Expand Up @@ -131,6 +133,7 @@ readSelectedMentionAuthorization(
checkCurrent: check,
onProfileEvidence: (value) => profiles = value,
);
onProfileEvidence?.call(profiles);
check();
final latestRuntime = <String, NostrEvent>{};
for (final event in runtime) {
Expand Down Expand Up @@ -186,7 +189,7 @@ readSelectedMentionAuthorization(
: const [],
);
}
result[key] = SelectedMentionAuthorization._(
result[key] = SelectedMentionAuthorization(
kind,
members.containsKey(key),
agent,
Expand Down
4 changes: 4 additions & 0 deletions mobile/lib/shared/profile/user_cache_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
return {};
}

/// Latest observed profile revision; read-only fencing for publication.
({int createdAt, String eventId})? profileEventOrder(String key) =>
_profileEventOrders[key];

/// Request a profile for [pubkey]. Returns immediately from cache if
/// available, otherwise schedules a batch fetch.
UserProfile? get(String pubkey) {
Expand Down
4 changes: 4 additions & 0 deletions mobile/lib/shared/relay/relay_rate_limit_gate.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ class RelayRateLimitGate {

final DateTime Function() _now;
final RelayTimerFactory _timerFactory;

/// Monotonic revision of admitted capacity pauses, including extensions.
int epoch = 0;
DateTime? _expiresAt;
Timer? _timer;
Completer<void>? _completer;
Expand Down Expand Up @@ -53,6 +56,7 @@ class RelayRateLimitGate {
final currentExpiry = _expiresAt;
if (currentExpiry != null && !newExpiry.isAfter(currentExpiry)) return;

epoch++;
_expiresAt = newExpiry;
_timer?.cancel();
_completer ??= Completer<void>();
Expand Down
Loading
Loading