Skip to content
Draft
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
58 changes: 43 additions & 15 deletions mobile/ios/Runner/NativeConfirmationDialog.swift
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import Flutter
import UIKit

/// Owns a UIKit confirmation alert until dismissal and replies exactly once.
/// Owns a UIKit confirmation or choice alert until dismissal and replies
/// exactly once.
final class NativeConfirmationDialogCoordinator: NSObject {
private let channel: FlutterMethodChannel
private weak var parent: UIViewController?
private weak var alert: UIAlertController?
private var pendingResult: FlutterResult?
private var isFinishing = false
/// What dismissing answers: false for a confirmation, nothing for a choice.
private var cancelValue: Any? = false

init(messenger: FlutterBinaryMessenger, parentViewController: UIViewController?) {
channel = FlutterMethodChannel(name: "buzz/confirmation_dialog", binaryMessenger: messenger)
Expand All @@ -20,17 +23,36 @@ final class NativeConfirmationDialogCoordinator: NSObject {
}

private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
guard call.method == "present" else { result(FlutterMethodNotImplemented); return }
guard call.method == "present" || call.method == "choose" else {
result(FlutterMethodNotImplemented)
return
}
let choosing = call.method == "choose"
guard let data = call.arguments as? [String: Any],
let title = data["title"] as? String,
let message = data["message"] as? String,
let confirmLabel = data["confirmLabel"] as? String,
let cancelLabel = data["cancelLabel"] as? String else {
result(FlutterError(code: "invalid_arguments", message: "Expected confirmation labels.", details: nil))
return
}
// Repeated taps must not create stacked alerts or multiple removals.
guard pendingResult == nil else { result(false); return }
// A confirmation has one action; a choice has its options,
// the last one preferred, and answers with the chosen index.
let labels: [String]
if choosing {
guard let options = data["options"] as? [String], !options.isEmpty else {
result(FlutterError(code: "invalid_arguments", message: "Expected choice options.", details: nil))
return
}
labels = options
} else {
guard let confirmLabel = data["confirmLabel"] as? String else {
result(FlutterError(code: "invalid_arguments", message: "Expected confirmation labels.", details: nil))
return
}
labels = [confirmLabel]
}
// Repeated taps must not create stacked alerts or multiple actions.
guard pendingResult == nil else { result(choosing ? nil : false); return }
var presenter = parent ?? UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.filter { $0.activationState == .foregroundActive }
Expand All @@ -43,36 +65,42 @@ final class NativeConfirmationDialogCoordinator: NSObject {
}
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.overrideUserInterfaceStyle = data["dark"] as? Bool == true ? .dark : .light
cancelValue = choosing ? nil : false
alert.addAction(UIAlertAction(title: cancelLabel, style: .cancel) { [weak self] _ in
self?.cancel()
})
let destructive = data["destructive"] as? Bool ?? true
alert.addAction(UIAlertAction(title: confirmLabel, style: destructive ? .destructive : .default) { [weak self] _ in
self?.finish(true)
})
for (index, label) in labels.enumerated() {
let style: UIAlertAction.Style = choosing || !destructive ? .default : .destructive
let action = UIAlertAction(title: label, style: style) { [weak self] _ in
self?.finish(choosing ? index : true)
}
alert.addAction(action)
if choosing && index == labels.count - 1 { alert.preferredAction = action }
}
pendingResult = result
self.alert = alert
presenter.present(alert, animated: !UIAccessibility.isReduceMotionEnabled)
}

/// Dismisses an outstanding confirmation without authorizing the action.
func cancel() { finish(false) }
/// Dismisses an outstanding alert without authorizing any action.
func cancel() { finish(cancelValue) }

private func finish(_ confirmed: Bool) {
private func finish(_ value: Any?) {
guard pendingResult != nil, !isFinishing else { return }
isFinishing = true
guard let alert else { resolve(confirmed); return }
guard let alert else { resolve(value); return }
// Finish native dismissal before Flutter removes the underlying route.
alert.dismiss(animated: !UIAccessibility.isReduceMotionEnabled) { [weak self] in
self?.resolve(confirmed)
self?.resolve(value)
}
}

private func resolve(_ confirmed: Bool) {
private func resolve(_ value: Any?) {
let result = pendingResult
pendingResult = nil
isFinishing = false
alert = nil
result?(confirmed)
result?(value)
}
}
50 changes: 49 additions & 1 deletion mobile/lib/features/channels/add_members_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ import '../../shared/theme/theme.dart';
import '../../shared/widgets/app_list_card_item.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/confirmation_dialog.dart';
import '../../shared/identity_names/identity_names_provider.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../../shared/relay/relay.dart';
import 'channel.dart';
import 'channel_management_provider.dart';
import 'ios_add_members_sheet.dart';
import 'mentions/mention_candidates.dart';
Expand All @@ -25,10 +27,15 @@ import '../../shared/widgets/modal_presentation.dart';
bool _nativePickerOpen = false;

/// Opens the platform member picker, retaining the Android sheet presentation.
///
/// For a DM, [isDm] asks whether added people see its history, and
/// [onMovedTo] receives the DM they ended up in when it is another one.
Future<bool?> showAddChannelMembersSheet({
required BuildContext context,
required String channelId,
required Set<String> existingPubkeys,
bool isDm = false,
void Function(Channel dm)? onMovedTo,
}) async {
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) {
if (_nativePickerOpen) return null;
Expand All @@ -41,6 +48,8 @@ Future<bool?> showAddChannelMembersSheet({
pageBuilder: (_, _, _) => AddChannelMembersSheet(
channelId: channelId,
existingPubkeys: existingPubkeys,
isDm: isDm,
onMovedTo: onMovedTo,
nativePresentation: true,
),
);
Expand All @@ -61,6 +70,8 @@ Future<bool?> showAddChannelMembersSheet({
builder: (_) => AddChannelMembersSheet(
channelId: channelId,
existingPubkeys: existingPubkeys,
isDm: isDm,
onMovedTo: onMovedTo,
),
);
}
Expand All @@ -71,11 +82,15 @@ class AddChannelMembersSheet extends HookConsumerWidget {
super.key,
required this.channelId,
required this.existingPubkeys,
this.isDm = false,
this.onMovedTo,
this.nativePresentation = false,
});

final String channelId;
final Set<String> existingPubkeys;
final bool isDm;
final void Function(Channel dm)? onMovedTo;
final bool nativePresentation;

@override
Expand Down Expand Up @@ -177,12 +192,45 @@ class AddChannelMembersSheet extends HookConsumerWidget {
submitError.value = null;
}

// Agents join a DM with its history, as on desktop; adding a person asks.
// The relay joins anyone to this DM only with the history tag. A DM of at
// most one person and their agents never gains a person: adding one
// starts a new DM without asking.
Future<bool?> askIncludeHistory(List<MentionCandidate> users) async {
if (users.every((user) => user.isAgent)) return true;
final people = existingPubkeys
.where((pubkey) => userCache[pubkey]?.isAgent != true)
.length;
if (people < 2) return false;
return showDmHistoryChoice(
context: context,
title: users.length == 1
? 'Add ${labelFor(users.single)}?'
: 'Add ${users.length} members?',
);
}

Future<void> addSelectedMembers() async {
if (selectedUsers.value.isEmpty || isSubmitting.value) return;
final submittedUsers = List<MentionCandidate>.of(selectedUsers.value);
final includeHistory = isDm
? await askIncludeHistory(submittedUsers)
: null;
if (isDm && (includeHistory == null || !context.mounted)) return;
isSubmitting.value = true;
submitError.value = null;
try {
if (includeHistory != null) {
final dm = await actions.addToDm(
channelId: channelId,
pubkeys: submittedUsers.map((user) => user.pubkey).toList(),
includeHistory: includeHistory,
);
if (!context.mounted) return;
Navigator.of(context).pop(true);
if (dm.id != channelId) onMovedTo?.call(dm);
return;
}
await actions.addMembers(
channelId: channelId,
pubkeys: submittedUsers.map((user) => user.pubkey).toList(),
Expand All @@ -206,7 +254,7 @@ class AddChannelMembersSheet extends HookConsumerWidget {
submitError.value = error.message;
} catch (error) {
if (!context.mounted) return;
submitError.value = error.toString();
submitError.value = error.toString().replaceFirst('Exception: ', '');
} finally {
if (context.mounted) isSubmitting.value = false;
}
Expand Down
3 changes: 3 additions & 0 deletions mobile/lib/features/channels/channel_actions_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import '../../shared/widgets/ios_glass_navigation_button.dart';
import '../../shared/widgets/tabler_star_icon.dart';
import '../../shared/widgets/modal_presentation.dart';
import 'channel.dart';
import 'channel_detail_page.dart';
import 'add_members_sheet.dart';
import 'channel_identity_names_provider.dart';
import 'channel_management_provider.dart';
Expand All @@ -36,6 +37,8 @@ import 'members_sheet.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../../shared/read_state/read_state_provider.dart';
import '../../shared/read_state/read_state_time.dart';
import '../../shared/community/community_provider.dart';
import '../../shared/relay/relay_author_provider.dart';
import '../../shared/widgets/covered_route_keyboard_insets.dart';

part 'channel_details_page.dart';
Expand Down
60 changes: 52 additions & 8 deletions mobile/lib/features/channels/channel_details_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,25 @@ class ChannelDetailsPage extends HookConsumerWidget {
!membersAsync.hasError &&
!resolvedChannel.isArchived &&
resolvedChannel.canAddMembers(currentMember?.role);
// The relay's moderation DM only carries its notices; no one joins it.
final relayUrl = ref.watch(activeCommunityProvider).asData?.value?.relayUrl;
final relayAuthor = relayUrl == null
? null
: ref.watch(relayAuthorProvider(relayUrl)).asData?.value;
final isModerationDm =
resolvedChannel.isDm &&
relayAuthor != null &&
(resolvedChannel.participantPubkeys.any(
(pubkey) => pubkey.toLowerCase() == relayAuthor,
) ||
members.any(
(member) => member.pubkey.toLowerCase() == relayAuthor,
));
// People join a DM only through the DM add, which any member can use.
final canAddToDm =
resolvedChannel.isDm &&
currentMember != null &&
!resolvedChannel.isArchived;
final canArchive = !resolvedChannel.isArchived && canManageLifecycle;
final canUnarchive = resolvedChannel.isArchived && canManageLifecycle;
final canDelete =
Expand Down Expand Up @@ -219,6 +238,21 @@ class ChannelDetailsPage extends HookConsumerWidget {
existingPubkeys: {
for (final member in members) member.pubkey.toLowerCase(),
},
isDm: resolvedChannel.isDm,
// They ended up in another DM: leave these details for that DM.
onMovedTo: (dm) {
if (!context.mounted) return;
Navigator.of(context)
..pop()
..push(
MaterialPageRoute<void>(
builder: (_) => ChannelDetailPage(
channel: dm,
placement: ChannelRoutePlacement.ordinary,
),
),
);
},
);
}

Expand Down Expand Up @@ -382,16 +416,26 @@ class ChannelDetailsPage extends HookConsumerWidget {
onTap: toggleMute,
),
),
if (!resolvedChannel.isDm) ...[
if (!isModerationDm) ...[
const SizedBox(width: Grid.xxs),
Expanded(
child: BuzzActionTile(
key: const ValueKey('channel-details-edit-action'),
icon: BuzzIcons.pencil,
label: 'Edit',
isEnabled: canEdit,
onTap: openManageChannel,
),
child: resolvedChannel.isDm
? BuzzActionTile(
key: const ValueKey('channel-details-add-action'),
icon: BuzzIcons.userPlus,
label: 'Add',
isEnabled: canAddToDm,
onTap: openAddMembers,
)
: BuzzActionTile(
key: const ValueKey(
'channel-details-edit-action',
),
icon: BuzzIcons.pencil,
label: 'Edit',
isEnabled: canEdit,
onTap: openManageChannel,
),
),
],
],
Expand Down
43 changes: 43 additions & 0 deletions mobile/lib/features/channels/channel_management_actions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,49 @@ class ChannelActions {
}
}

/// Adds [pubkeys] to the DM [channelId] (kind:41011) and returns the DM
/// they ended up in. With [includeHistory] they join this DM and can read
/// it, unless the grown group already has a DM, which is returned instead.
/// Without it, or when a person joins a DM of at most one person, they
/// start a new DM of everyone.
Future<Channel> addToDm({
required String channelId,
required List<String> pubkeys,
required bool includeHistory,
}) async {
_ensureCommunityValid();
final NostrEvent result;
try {
result = await _signedEventRelay.submit(
kind: 41011,
content: '',
tags: [
['h', channelId],
for (final pubkey in pubkeys) ['p', pubkey],
if (includeHistory) ['history', 'include'],
],
);
} on StateError {
rethrow;
} catch (_) {
// Retrying cannot help the most common refusal, a DM that is full.
throw Exception(dmAdditionFailedMessage);
}
_ensureCommunityValid();
final id = parseCommandResponse(result.content)?['channel_id'] as String?;
if (id == null || id.isEmpty) {
throw Exception('Relay did not return a DM channel id');
}
await _refreshChannelState(channelId);
if (id == channelId) {
return (await _ref.read(channelsProvider.future)).firstWhere(
(channel) => channel.id == channelId,
orElse: () => throw Exception('DM is not visible yet'),
);
}
return _refreshChannelsAndRead(id);
}

void _ensureCommunityValid() {
if (_isCommunityValid?.call() == false) {
throw StateError(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ part 'channel_management_actions.dart';
String _relayErrorMessage(Object error) =>
error.toString().replaceFirst('Exception: ', '');

/// Why adding to a DM failed. The most common refusal is a full DM.
const dmAdditionFailedMessage =
'Couldn’t add them. A DM holds up to 9 people, not counting agents.';

/// Raised when one or more kind:9000 adds were rejected, keyed by pubkey.
///
/// Callers surface [message] to the user — a relay rejection here (e.g. a plain
Expand Down
Loading
Loading