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
2 changes: 2 additions & 0 deletions mobile/lib/features/channels/channel_actions_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/shared/theme/buzz_icons.dart';

import '../../shared/clipboard_utils.dart';
import '../../shared/community/community_membership_provider.dart';
import '../../shared/identity_names/identity_names_provider.dart';
import 'dm_channel_labels.dart';
import '../../shared/mentions/agent_identity_provider.dart';
Expand Down Expand Up @@ -37,6 +38,7 @@ import '../../shared/profile/user_cache_provider.dart';
import '../../shared/read_state/read_state_provider.dart';
import '../../shared/read_state/read_state_time.dart';

part 'channel_admission_card.dart';
part 'channel_details_page.dart';

/// Opens the mobile channel actions sheet and returns whether its parent page
Expand Down
230 changes: 230 additions & 0 deletions mobile/lib/features/channels/channel_admission_card.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
part of 'channel_actions_sheet.dart';

/// Who joins an open channel and how: the role newcomers get, and whether the
/// relay joins every new community member to it. Every value comes from fresh
/// relay metadata, and a change shows only after the relay confirms it. A
/// change the relay may have applied blocks further changes until a read-only
/// check confirms it, or until a read shows it unapplied and the same signed
/// change is sent again.
class _ChannelAdmissionCard extends HookConsumerWidget {
const _ChannelAdmissionCard({
required this.channel,
required this.canEditJoinRole,
});

final Channel channel;

/// Channel owners and admins choose the role newcomers get.
final bool canEditJoinRole;

@override
Widget build(BuildContext context, WidgetRef ref) {
// Only community owners and admins choose default channels, and only an
// open, unarchived stream can be one.
final mayBeDefault = channel.isStream && !channel.isArchived;
final communityRole = ref.watch(currentCommunityRoleProvider);
final managesDefault =
mayBeDefault && managesCommunity(communityRole.value);
// Without the role the setting cannot be offered, so say why and retry.
final roleUnknown =
mayBeDefault && !managesDefault && communityRole.hasError;
final admissionAsync = ref.watch(channelAdmissionProvider(channel.id));
final uncertain = ref.watch(uncertainAdmissionChangeProvider(channel.id));
final saving = useState(false);
if (!canEditJoinRole && !managesDefault && !roleUnknown) {
return const SizedBox.shrink();
}

final admission = admissionAsync.asData?.value;
final ready =
admission != null &&
!admissionAsync.isLoading &&
uncertain == null &&
!saving.value;
final canToggleDefault = managesDefault && ready && admission.canBeDefault;

void showError(Object error) {
if (!context.mounted) return;
// Replace, not queue, so the message always matches the latest result.
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showFeedbackSnackBar(
SnackBar(
content: Text(
error is ChannelChangeUnconfirmed
? 'Buzz couldn’t confirm the change yet. Check its status '
'before trying again.'
: 'Couldn’t change channel access. Try again.',
),
),
kind: ToastKind.error,
);
}

Future<void> save(Future<void> Function(ChannelActions) change) async {
if (saving.value) return;
saving.value = true;
try {
await change(ref.read(channelActionsProvider));
} catch (error) {
showError(error);
} finally {
if (context.mounted) saving.value = false;
}
}

Future<void> chooseJoinRole() async {
final current = admission?.joinRole;
final role = await showBuzzModalBottomSheet<String>(
context: context,
title: 'New people join as',
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: SheetActionSection(
children: [
for (final (value, title, subtitle) in const [
('member', 'Member', 'Can read and post'),
('guest', 'Guest', 'Can read but not post'),
])
ListTile(
key: ValueKey('channel-join-role-$value'),
title: Text(title),
subtitle: Text(subtitle),
selected: current == value,
trailing: current == value
? Icon(
BuzzIcons.check,
color: sheetContext.colors.primary,
)
: null,
onTap: () => Navigator.of(sheetContext).pop(value),
),
],
),
),
),
);
if (role == null || role == current) return;
await save((actions) => actions.setChannelJoinRole(channel.id, role));
}

void setDefault(bool enabled) => unawaited(
save(
(actions) => actions.setDefaultChannel(channel.id, enabled: enabled),
),
);

return AppListCard(
key: const ValueKey('channel-details-access-card'),
label: 'General access',
verticalPadding: _channelDetailsSectionPadding,
children: [
if (roleUnknown)
AppListRow(
key: const ValueKey('channel-community-role-retry'),
icon: BuzzIcons.triangleAlert,
title: 'Default channel unavailable',
subtitle: 'Couldn’t check your community role. Tap to try again.',
onTap: () => ref.invalidate(communityMembershipProvider),
),
if (uncertain != null)
AppListRow(
key: const ValueKey('channel-access-check'),
icon: BuzzIcons.triangleAlert,
title: 'Change not confirmed',
subtitle: 'It may have saved. Tap to check its status.',
trailing: saving.value
? const BuzzLoadingIndicator(
size: 20,
semanticLabel: 'Checking save status',
)
: null,
onTap: saving.value
? null
: () => save(
(actions) => actions.checkAdmissionChange(channel.id),
),
),
if (uncertain != null && uncertain.unseen)
AppListRow(
key: const ValueKey('channel-access-resend'),
icon: BuzzIcons.refreshCw,
title: 'Send again',
subtitle: 'The relay doesn’t show the change yet.',
onTap: saving.value
? null
: () => save(
(actions) => actions.resendAdmissionChange(channel.id),
),
),
if (admissionAsync.hasError && admission == null)
AppListRow(
key: const ValueKey('channel-access-retry'),
icon: BuzzIcons.triangleAlert,
title: 'Access settings unavailable',
subtitle: 'Tap to try again.',
onTap: () => ref.invalidate(channelAdmissionProvider(channel.id)),
)
else ...[
if (canEditJoinRole)
AppListRow(
key: const ValueKey('channel-join-role-row'),
icon: BuzzIcons.users,
title: 'New people join as',
value: switch (admission?.joinRole) {
'guest' => 'Guest',
'member' => 'Member',
_ => null,
},
trailing: ready
? const _ChannelDetailsChevron()
: uncertain != null
? null
: const BuzzLoadingIndicator(
size: 20,
semanticLabel: 'Loading access settings',
),
onTap: ready ? chooseJoinRole : null,
),
if (managesDefault)
MergeSemantics(
key: const ValueKey('channel-default-setting'),
child: Stack(
alignment: Alignment.centerRight,
children: [
AppListRow(
key: const ValueKey('channel-default-row'),
icon: BuzzIcons.userPlus,
title: 'Default channel',
subtitle: 'New community members join automatically',
// Reserve room for the switch without growing the row.
trailing: const SizedBox(width: 60),
onTap: canToggleDefault
? () => setDefault(!admission.autoJoin)
: null,
),
Positioned.fill(
right: Grid.xs,
child: Align(
alignment: Alignment.centerRight,
child: Switch.adaptive(
value: admission?.autoJoin ?? false,
onChanged: canToggleDefault ? setDefault : null,
),
),
),
],
),
),
],
],
);
}
}
5 changes: 5 additions & 0 deletions mobile/lib/features/channels/channel_details_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,11 @@ class ChannelDetailsPage extends HookConsumerWidget {
],
],
),
if (!resolvedChannel.isDm && resolvedChannel.visibility == 'open')
_ChannelAdmissionCard(
channel: resolvedChannel,
canEditJoinRole: canEdit,
),
AppListCard(
key: const ValueKey('channel-details-channel-card'),
verticalPadding: _channelDetailsSectionPadding,
Expand Down
107 changes: 107 additions & 0 deletions mobile/lib/features/channels/channel_management_actions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,113 @@ class ChannelActions {
await _refreshChannelState(channelId);
}

/// Sets the role people get when they join [channelId]: `member` lets them
/// participate, `guest` lets them only view. Existing members keep theirs.
Future<void> setChannelJoinRole(String channelId, String role) =>
_setAdmission(channelId, ['join_role', role]);

/// Makes [channelId] a default channel, which the relay joins every new
/// community member to. Only community owners and admins may change it.
Future<void> setDefaultChannel(String channelId, {required bool enabled}) =>
_setAdmission(channelId, ['auto_join', enabled.toString()]);

/// Sends one partial kind:9002 change, then confirms it from fresh relay
/// metadata. A change the relay may have applied without showing it, or
/// whose acknowledgement was lost, is held as uncertain and reported as
/// [ChannelChangeUnconfirmed]; no other change is published for the channel
/// until [checkAdmissionChange] confirms it.
Future<void> _setAdmission(String channelId, List<String> tag) async {
final uncertain = _ref.read(
uncertainAdmissionChangeProvider(channelId).notifier,
);
if (_ref.read(uncertainAdmissionChangeProvider(channelId)) != null) {
throw const ChannelChangeUnconfirmed();
}
_ensureCommunityValid();
var signed = false;
try {
await _signedEventRelay.submit(
kind: 9002,
content: '',
tags: [
['h', channelId],
tag,
],
// From here the relay may apply the change even if its answer is lost.
onSigned: (event) {
signed = true;
uncertain.hold(tag, event);
},
);
} on RelayEventRejected {
uncertain.clear();
rethrow;
} on RelayNotConnected {
// Nothing was sent, so nothing can have been applied.
uncertain.clear();
rethrow;
} catch (_) {
if (!signed) rethrow;
_ref.invalidate(channelAdmissionProvider(channelId));
throw const ChannelChangeUnconfirmed();
}
await checkAdmissionChange(channelId);
}

/// Reads [channelId]'s metadata, without publishing, to resolve an
/// uncertain admission change. Throws [ChannelChangeUnconfirmed] while the
/// relay still does not show it; once a read shows it unapplied, the change
/// may be sent again with [resendAdmissionChange].
Future<void> checkAdmissionChange(String channelId) async {
final uncertain = _ref.read(
uncertainAdmissionChangeProvider(channelId).notifier,
);
final held = _ref.read(uncertainAdmissionChangeProvider(channelId));
if (held == null) return;
try {
_ensureCommunityValid();
final admission = await _fetchChannelAdmission(_session, channelId);
if (!admissionShows(admission, held.tag)) {
uncertain.markUnseen(true);
throw const ChannelChangeUnconfirmed();
}
uncertain.clear();
} on ChannelChangeUnconfirmed {
rethrow;
} catch (_) {
throw const ChannelChangeUnconfirmed();
} finally {
_ref.invalidate(channelAdmissionProvider(channelId));
}
}

/// Sends an uncertain change the relay does not show again, as the exact
/// event first signed, then confirms it. A relay that already stored that
/// event ignores it, so this can never apply the change twice.
Future<void> resendAdmissionChange(String channelId) async {
final uncertain = _ref.read(
uncertainAdmissionChangeProvider(channelId).notifier,
);
final held = _ref.read(uncertainAdmissionChangeProvider(channelId));
if (held == null || !held.unseen) return;
_ensureCommunityValid();
uncertain.markUnseen(false);
try {
await _session.publish(held.event);
} on RelayEventRejected {
uncertain.clear();
rethrow;
} on RelayNotConnected {
// Nothing new was sent; the last read still stands.
uncertain.markUnseen(true);
rethrow;
} catch (_) {
_ref.invalidate(channelAdmissionProvider(channelId));
throw const ChannelChangeUnconfirmed();
}
await checkAdmissionChange(channelId);
}

/// Archives the channel and refreshes its cached state.
Future<void> archiveChannel(String channelId) =>
_setChannelArchived(channelId, archived: true);
Expand Down
Loading
Loading