feat(mobile): bring channel menus to desktop parity (#3940)

## Overview

**Category:** improvement  
**User Impact:** Mobile users can now access consistent channel and DM
actions from both the channel list and conversation header.
**Problem:** Mobile channel menus exposed a narrower, inconsistent set
of actions than desktop, and the available actions differed by entry
point.
**Solution:** This change introduces one reusable action sheet with a
clear quick-action hierarchy, role-aware lifecycle controls,
confirmations for consequential actions, and a deliberately narrower DM
menu.

## Changes

<details>
<summary>File changes</summary>

**mobile/lib/features/channels/channel_actions_sheet.dart**  
Adds the shared channel and DM action-sheet experience used by both
entry points, including Star/Unstar and Read/Unread quick actions for
channels, section movement, mute, management, inline copy actions,
guarded lifecycle actions, confirmations, and a compact DM menu without
quick actions.

**mobile/lib/features/channels/channel_detail_page.dart**  
Routes the header ellipsis through the shared action sheet so the
in-channel menu matches the channel-list experience, including for DMs.

**mobile/lib/features/channels/channel_management_provider.dart**  
Adds archive and delete operations using the desktop-compatible relay
event kinds and refreshes channel state after completion.

**mobile/lib/features/channels/channels_page.dart**  
Makes the shared channel action-sheet entry point available to the
channel-list implementation.

**mobile/lib/features/channels/channels_page/channel_tile.dart**  
Replaces the tile-specific long-press menu with the reusable action
sheet while preserving read state and section context.

**mobile/test/features/channels/channel_actions_sheet_test.dart**  
Covers action hierarchy, owner/admin/member capability guards, loading
and failure states, DM narrowing with no quick-action row, and inline
copy actions.

**mobile/test/features/channels/channel_detail_page_test.dart**  
Updates channel-header flows to exercise management through the new
shared action sheet.

**mobile/test/features/channels/channel_management_provider_test.dart**
Verifies archive and delete event tags stay compatible with desktop
behavior.

</details>

## Reproduction Steps

1. Run the mobile app and open a populated channel list.
2. Long-press a regular channel and verify the Star/Unstar and
Read/Unread quick actions appear above Move to section…, Mute, Manage,
Copy channel name, and Copy channel ID.
3. Choose either copy action and verify it copies the expected value.
4. Open a channel, tap the header ellipsis, and verify the same action
sheet appears.
5. As an admin or owner, verify Archive appears; as an owner, verify
Delete also appears. Confirm that lifecycle actions require
confirmation.
6. Long-press or open the header menu for a DM and verify it has no
quick-action row and starts with Mute, followed by Copy channel name and
Copy channel ID.

## Screenshots

### Channel menu

| Regular channel — Mark Unread | DM — no quick actions | Archive
confirmation |
|---|---|---|
| ![Regular channel actions with Mark
Unread](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3940/final-regular-channel-mark-unread.png)
| ![DM actions without quick
actions](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3940/final-dm-no-quick-actions.png)
| ![Archive
confirmation](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3940/final-archive-confirmation.png)
|

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
This commit is contained in:
Taylor Ho
2026-08-03 16:23:55 -07:00
committed by GitHub
co-authored by npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent 985cdcc6ea
commit ede8d22dd5
8 changed files with 1082 additions and 232 deletions
@@ -0,0 +1,559 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/clipboard_utils.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/sheet_divider.dart';
import 'channel.dart';
import 'channel_management_provider.dart';
import 'channel_mutes/channel_mutes_provider.dart';
import 'channel_sections/channel_sections_provider.dart';
import 'channel_stars/channel_stars_provider.dart';
import 'channels_provider.dart';
import 'manage_channel_sheet.dart';
import 'read_state/read_state_provider.dart';
import 'read_state/read_state_time.dart';
/// Opens the mobile channel actions sheet and returns whether its parent page
/// should close after a successful lifecycle action.
Future<bool?> showChannelActionsSheet({
required BuildContext context,
required Channel channel,
required bool isUnread,
VoidCallback? onMarkRead,
String? sectionId,
}) => showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
showDragHandle: true,
constraints: BoxConstraints(
maxWidth: 640,
maxHeight: MediaQuery.sizeOf(context).height * 0.7,
),
builder: (_) => ChannelActionsSheet(
channel: channel,
isUnread: isUnread,
onMarkRead: onMarkRead,
sectionId: sectionId,
),
);
/// Mobile action sheet for channel-level read, organization, and lifecycle
/// operations.
class ChannelActionsSheet extends ConsumerWidget {
const ChannelActionsSheet({
super.key,
required this.channel,
required this.isUnread,
this.onMarkRead,
this.sectionId,
});
final Channel channel;
final bool isUnread;
final VoidCallback? onMarkRead;
final String? sectionId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final isMuted =
ref.watch(channelMutesProvider).store.channels[channel.id]?.muted ==
true;
final isStarred =
!channel.isDm &&
ref.watch(channelStarsProvider).store.channels[channel.id]?.starred ==
true;
final membersAsync = channel.isDm
? const AsyncValue<List<ChannelMember>>.data([])
: ref.watch(channelMembersProvider(channel.id));
final agentOwnersAsync = channel.isDm
? const AsyncValue<Map<String, String>>.data({})
: ref.watch(agentOwnersProvider);
final currentPubkey = ref.watch(currentPubkeyProvider)?.toLowerCase();
final currentMember = membersAsync.value?.cast<ChannelMember?>().firstWhere(
(member) => member?.pubkey.toLowerCase() == currentPubkey,
orElse: () => null,
);
final ownsOwnerAgent =
currentPubkey != null &&
membersAsync.value?.any(
(member) =>
member.isOwner &&
agentOwnersAsync.value?[member.pubkey.toLowerCase()]
?.toLowerCase() ==
currentPubkey,
) ==
true;
final canManageLifecycle =
currentMember?.isElevated == true || ownsOwnerAgent;
final canArchive = !channel.isArchived && canManageLifecycle;
final canUnarchive = channel.isArchived && canManageLifecycle;
final canDelete =
!channel.isArchived &&
(currentMember?.isOwner == true || ownsOwnerAgent);
final lifecycleCapabilitiesLoading =
membersAsync.isLoading || agentOwnersAsync.isLoading;
final lifecycleCapabilitiesUnavailable =
membersAsync.hasError || agentOwnersAsync.hasError;
void close() => Navigator.of(context).pop();
return SafeArea(
top: false,
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!channel.isDm) ...[
_ChannelQuickActionsRow(
isStarred: isStarred,
isUnread: isUnread,
onToggleStar: () {
close();
final notifier = ref.read(channelStarsProvider.notifier);
isStarred
? notifier.unstarChannel(channel.id)
: notifier.starChannel(channel.id);
},
onToggleRead: () {
close();
final timestamp = dateTimeToUnixSeconds(
channel.lastMessageAt,
);
if (isUnread) {
onMarkRead?.call();
if (timestamp != null) {
ref
.read(readStateProvider.notifier)
.markContextRead(
channel.id,
timestamp,
clearForcedMessages: true,
);
ref
.read(channelsProvider.notifier)
.clearObservedUnreadCoveredByRead(
channel.id,
timestamp,
);
}
} else {
ref
.read(readStateProvider.notifier)
.markContextUnread(channel.id, channelId: channel.id);
}
},
),
const SizedBox(height: Grid.xs),
],
if (!channel.isDm)
ListTile(
leading: const Icon(LucideIcons.folderInput),
title: const Text('Move to section…'),
onTap: () async {
final pageContext = Navigator.of(
context,
rootNavigator: true,
).context;
close();
await _showMoveSectionSheet(
pageContext,
ref,
channel: channel,
sectionId: sectionId,
);
},
),
ListTile(
leading: Icon(isMuted ? LucideIcons.bell : LucideIcons.bellOff),
title: Text(isMuted ? 'Unmute channel' : 'Mute channel'),
onTap: () {
close();
final notifier = ref.read(channelMutesProvider.notifier);
isMuted
? notifier.unmuteChannel(channel.id)
: notifier.muteChannel(channel.id);
},
),
if (!channel.isDm)
ListTile(
leading: const Icon(LucideIcons.settings),
title: const Text('Manage channel'),
onTap: () async {
final shouldClose = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
showDragHandle: true,
constraints: BoxConstraints(
maxWidth: 640,
maxHeight: MediaQuery.sizeOf(context).height * 0.9,
),
builder: (_) => ManageChannelSheet(channel: channel),
);
if (shouldClose == true && context.mounted) {
Navigator.of(context).pop(true);
}
},
),
ListTile(
leading: const Icon(LucideIcons.copy),
title: const Text('Copy channel name'),
onTap: () {
close();
copyToClipboard(
context,
channel.name,
message: 'Channel name copied to clipboard',
);
},
),
ListTile(
leading: const Icon(LucideIcons.hash),
title: const Text('Copy channel ID'),
onTap: () {
close();
copyToClipboard(
context,
channel.id,
message: 'Channel ID copied to clipboard',
);
},
),
if (!channel.isDm) ...[
const SheetDivider(),
if (channel.isMember && !channel.isArchived)
_ActionTile(
icon: LucideIcons.logOut,
label: 'Leave channel',
destructive: true,
onTap: () => _confirmAndRun(
context,
ref,
title: 'Leave #${channel.name}?',
body: 'Youll stop receiving messages from this channel.',
confirmLabel: 'Leave',
action: () => ref
.read(channelActionsProvider)
.leaveChannel(channel.id),
),
),
if (lifecycleCapabilitiesLoading)
const ListTile(
enabled: false,
leading: BuzzLoadingIndicator(
size: 20,
semanticLabel: 'Loading channel actions',
),
title: Text('Loading channel actions…'),
)
else if (lifecycleCapabilitiesUnavailable)
const ListTile(
enabled: false,
leading: Icon(LucideIcons.triangleAlert),
title: Text('Channel actions unavailable'),
)
else ...[
if (canArchive)
_ActionTile(
icon: LucideIcons.archive,
label: 'Archive channel',
onTap: () => _confirmAndRun(
context,
ref,
title: 'Archive #${channel.name}?',
body: 'The channel will become read-only.',
confirmLabel: 'Archive',
action: () => ref
.read(channelActionsProvider)
.archiveChannel(channel.id),
),
),
if (canUnarchive)
_ActionTile(
icon: LucideIcons.archiveRestore,
label: 'Unarchive channel',
onTap: () => _confirmAndRun(
context,
ref,
title: 'Unarchive #${channel.name}?',
body: 'The channel will become active again.',
confirmLabel: 'Unarchive',
action: () => ref
.read(channelActionsProvider)
.unarchiveChannel(channel.id),
),
),
if (canDelete)
_ActionTile(
icon: LucideIcons.trash2,
label: 'Delete channel',
destructive: true,
onTap: () => _confirmAndRun(
context,
ref,
title: 'Delete #${channel.name}?',
body:
'This permanently deletes the channel and cannot be undone.',
confirmLabel: 'Delete',
action: () => ref
.read(channelActionsProvider)
.deleteChannel(channel.id),
),
),
],
],
],
),
),
);
}
}
class _ChannelQuickActionsRow extends StatelessWidget {
const _ChannelQuickActionsRow({
required this.isStarred,
required this.isUnread,
required this.onToggleStar,
required this.onToggleRead,
});
final bool isStarred;
final bool isUnread;
final VoidCallback onToggleStar;
final VoidCallback onToggleRead;
@override
Widget build(BuildContext context) => Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_ChannelQuickAction(
icon: isStarred ? LucideIcons.starOff : LucideIcons.star,
label: isStarred ? 'Unstar' : 'Star',
onTap: onToggleStar,
),
_ChannelQuickAction(
icon: isUnread ? LucideIcons.checkCheck : LucideIcons.circleDot,
label: isUnread ? 'Mark Read' : 'Mark Unread',
onTap: onToggleRead,
),
],
);
}
class _ChannelQuickAction extends StatelessWidget {
const _ChannelQuickAction({
required this.icon,
required this.label,
required this.onTap,
});
final IconData icon;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 76,
height: 56,
alignment: Alignment.center,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.dialog),
),
child: Icon(icon, size: 24, color: context.colors.onSurface),
),
const SizedBox(height: Grid.xxs),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurface,
),
),
],
),
);
}
class _ActionTile extends StatelessWidget {
const _ActionTile({
required this.icon,
required this.label,
required this.onTap,
this.destructive = false,
});
final IconData icon;
final String label;
final VoidCallback onTap;
final bool destructive;
@override
Widget build(BuildContext context) => ListTile(
leading: Icon(icon, color: destructive ? context.colors.error : null),
title: Text(
label,
style: destructive ? TextStyle(color: context.colors.error) : null,
),
onTap: onTap,
);
}
Future<void> _confirmAndRun(
BuildContext sheetContext,
WidgetRef ref, {
required String title,
required String body,
required String confirmLabel,
required Future<void> Function() action,
}) async {
final pageContext = Navigator.of(sheetContext, rootNavigator: true).context;
final confirmed = await showDialog<bool>(
context: pageContext,
builder: (dialogContext) => AlertDialog(
title: Text(title),
content: Text(body),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(confirmLabel),
),
],
),
);
if (confirmed != true) return;
try {
await action();
if (pageContext.mounted) Navigator.of(pageContext).pop(true);
} catch (error) {
if (!pageContext.mounted) return;
ScaffoldMessenger.of(pageContext).showSnackBar(
SnackBar(
content: Text(
'Couldnt ${confirmLabel.toLowerCase()} channel. Try again.',
),
),
);
}
}
Future<void> _showMoveSectionSheet(
BuildContext context,
WidgetRef ref, {
required Channel channel,
required String? sectionId,
}) async {
final sections = [...ref.read(channelSectionsProvider).store.sections]
..sort((a, b) => a.order.compareTo(b.order));
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final section in sections)
ListTile(
leading: const Icon(LucideIcons.folder),
title: Text(section.name),
trailing: sectionId == section.id
? Icon(
LucideIcons.check,
color: sheetContext.colors.primary,
)
: null,
onTap: () {
Navigator.of(sheetContext).pop();
ref
.read(channelSectionsProvider.notifier)
.assignChannel(channel.id, section.id);
},
),
ListTile(
leading: const Icon(LucideIcons.folderPlus),
title: const Text('New section…'),
onTap: () async {
Navigator.of(sheetContext).pop();
final name = await _showSectionNameDialog(context);
if (name == null || name.isEmpty) return;
final notifier = ref.read(channelSectionsProvider.notifier);
notifier.createSection(name);
final created = ref
.read(channelSectionsProvider)
.store
.sections
.where((section) => section.name == name.trim())
.lastOrNull;
if (created != null) {
notifier.assignChannel(channel.id, created.id);
}
},
),
if (sectionId != null)
ListTile(
leading: const Icon(LucideIcons.folderMinus),
title: const Text('Remove from section'),
onTap: () {
Navigator.of(sheetContext).pop();
ref
.read(channelSectionsProvider.notifier)
.unassignChannel(channel.id);
},
),
],
),
),
),
);
}
Future<String?> _showSectionNameDialog(BuildContext context) async {
final controller = TextEditingController();
final result = await showDialog<String>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('New Section'),
content: TextField(controller: controller, autofocus: true),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () =>
Navigator.of(dialogContext).pop(controller.text.trim()),
child: const Text('Create'),
),
],
),
);
controller.dispose();
return result;
}
@@ -25,9 +25,11 @@ import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import '../forum/forum_posts_view.dart';
import 'channel.dart';
import 'channel_actions_sheet.dart';
import 'channel_link_navigation.dart';
import 'agent_activity/working_bots_provider.dart';
import 'channel_management_provider.dart';
import 'channel_sections/channel_sections_provider.dart';
import 'channel_messages_provider.dart';
import 'channel_typing_provider.dart';
import 'channel_typing_indicator.dart';
@@ -38,7 +40,6 @@ import 'date_formatters.dart';
import 'day_divider.dart';
import 'dm_channel_labels.dart';
import 'ephemeral_channel_display.dart';
import 'manage_channel_sheet.dart';
import 'members_sheet.dart';
import 'message_actions.dart';
import 'message_content.dart';
@@ -277,27 +278,25 @@ class ChannelDetailPage extends HookConsumerWidget {
channel: resolvedChannel,
currentPubkey: currentPubkey,
),
if (!resolvedChannel.isDm)
IconButton(
color: context.colors.primary,
onPressed: () async {
final shouldClose = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
showDragHandle: true,
constraints: BoxConstraints(
maxWidth: 640,
maxHeight: MediaQuery.sizeOf(context).height * 0.9,
),
builder: (_) => ManageChannelSheet(channel: resolvedChannel),
);
if (shouldClose == true && context.mounted) {
Navigator.of(context).pop();
}
},
tooltip: 'Manage channel',
icon: const Icon(LucideIcons.ellipsisVertical, size: 22),
),
IconButton(
color: context.colors.primary,
onPressed: () async {
final shouldClose = await showChannelActionsSheet(
context: context,
channel: resolvedChannel,
isUnread: false,
sectionId: ref
.read(channelSectionsProvider)
.store
.assignments[resolvedChannel.id],
);
if (shouldClose == true && context.mounted) {
Navigator.of(context).pop();
}
},
tooltip: 'Channel actions',
icon: const Icon(LucideIcons.ellipsisVertical, size: 22),
),
],
),
body: Stack(
@@ -479,6 +479,20 @@ List<List<String>> buildCreateChannelTags({
];
}
/// Builds the relay tags for setting the archived state of [channelId].
List<List<String>> buildSetChannelArchivedTags(
String channelId, {
required bool archived,
}) => [
['h', channelId],
['archived', archived.toString()],
];
/// Builds the relay tags for deleting [channelId].
List<List<String>> buildDeleteChannelTags(String channelId) => [
['h', channelId],
];
class ChannelActions {
final Ref _ref;
final RelaySessionNotifier _session;
@@ -584,6 +598,36 @@ class ChannelActions {
await _refreshChannelState(channelId);
}
/// Archives the channel and refreshes its cached state.
Future<void> archiveChannel(String channelId) =>
_setChannelArchived(channelId, archived: true);
/// Unarchives the channel and refreshes its cached state.
Future<void> unarchiveChannel(String channelId) =>
_setChannelArchived(channelId, archived: false);
Future<void> _setChannelArchived(
String channelId, {
required bool archived,
}) async {
await _signedEventRelay.submit(
kind: 9002,
content: '',
tags: buildSetChannelArchivedTags(channelId, archived: archived),
);
await _refreshChannelState(channelId);
}
/// Deletes the channel and refreshes its cached state.
Future<void> deleteChannel(String channelId) async {
await _signedEventRelay.submit(
kind: 9008,
content: '',
tags: buildDeleteChannelTags(channelId),
);
await _refreshChannelState(channelId);
}
Future<void> setCanvas({
required String channelId,
required String content,
@@ -29,6 +29,7 @@ import '../profile/user_cache_provider.dart';
import '../pairing/pairing_page.dart';
import '../pairing/pairing_provider.dart';
import 'channel.dart';
import 'channel_actions_sheet.dart';
import 'channel_detail_page.dart';
import 'channel_management_provider.dart';
import 'dm_channel_labels.dart';
@@ -118,212 +118,12 @@ class _ChannelTile extends ConsumerWidget {
}
void _showChannelActions(BuildContext context, WidgetRef ref) {
showModalBottomSheet<void>(
showChannelActionsSheet(
context: context,
showDragHandle: true,
builder: (sheetContext) {
final sections = ref.read(channelSectionsProvider).store.sections
..sort((a, b) => a.order.compareTo(b.order));
final isStarred =
ref
.read(channelStarsProvider)
.store
.channels[channel.id]
?.starred ==
true;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(
isStarred ? LucideIcons.starOff : LucideIcons.star,
),
title: Text(isStarred ? 'Unstar channel' : 'Star channel'),
onTap: () {
Navigator.of(sheetContext).pop();
if (isStarred) {
ref
.read(channelStarsProvider.notifier)
.unstarChannel(channel.id);
} else {
ref
.read(channelStarsProvider.notifier)
.starChannel(channel.id);
}
},
),
ListTile(
leading: const Icon(LucideIcons.folderInput),
title: const Text('Move to section'),
onTap: () async {
Navigator.of(sheetContext).pop();
await _showMoveSectionSheet(context, ref, sections);
},
),
ListTile(
leading: Icon(
isMuted ? LucideIcons.bell : LucideIcons.bellOff,
),
title: Text(isMuted ? 'Unmute channel' : 'Mute channel'),
onTap: () {
Navigator.of(sheetContext).pop();
if (isMuted) {
ref
.read(channelMutesProvider.notifier)
.unmuteChannel(channel.id);
} else {
ref
.read(channelMutesProvider.notifier)
.muteChannel(channel.id);
}
},
),
ListTile(
leading: Icon(
isUnread ? LucideIcons.checkCheck : LucideIcons.circleDot,
),
title: Text(isUnread ? 'Mark as read' : 'Mark as unread'),
onTap: () {
Navigator.of(sheetContext).pop();
final ts = dateTimeToUnixSeconds(channel.lastMessageAt);
if (ts != null) {
if (isUnread) {
onMarkRead?.call();
ref
.read(readStateProvider.notifier)
.markContextRead(
channel.id,
ts,
clearForcedMessages: true,
);
ref
.read(channelsProvider.notifier)
.clearObservedUnreadCoveredByRead(channel.id, ts);
} else {
ref
.read(readStateProvider.notifier)
.markContextUnread(
channel.id,
channelId: channel.id,
);
}
}
},
),
],
),
),
);
},
);
}
Future<void> _showMoveSectionSheet(
BuildContext context,
WidgetRef ref,
List<ChannelSection> sections,
) async {
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.xs,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final section in sections)
ListTile(
leading: Icon(
LucideIcons.folder,
color: sectionId == section.id
? sheetContext.colors.primary
: null,
),
title: Text(section.name),
trailing: sectionId == section.id
? Icon(
LucideIcons.check,
color: sheetContext.colors.primary,
)
: null,
onTap: () {
Navigator.of(sheetContext).pop();
ref
.read(channelSectionsProvider.notifier)
.assignChannel(channel.id, section.id);
},
),
ListTile(
leading: const Icon(LucideIcons.folderPlus),
title: const Text('New section…'),
onTap: () async {
Navigator.of(sheetContext).pop();
if (!context.mounted) return;
final name = await showDialog<String>(
context: context,
builder: (_) => const _SectionNameDialog(
title: 'New Section',
confirmLabel: 'Create',
),
);
if (name != null && name.isNotEmpty) {
ref
.read(channelSectionsProvider.notifier)
.createSection(name);
// Assign after create — sections list has been mutated,
// re-read to find the new section by name.
final newSection = ref
.read(channelSectionsProvider)
.store
.sections
.lastWhere(
(s) => s.name == name.trim(),
orElse: () => const ChannelSection(
id: '',
name: '',
order: -1,
),
);
if (newSection.id.isNotEmpty) {
ref
.read(channelSectionsProvider.notifier)
.assignChannel(channel.id, newSection.id);
}
}
},
),
if (sectionId != null)
ListTile(
leading: const Icon(LucideIcons.folderMinus),
title: const Text('Remove from section'),
onTap: () {
Navigator.of(sheetContext).pop();
ref
.read(channelSectionsProvider.notifier)
.unassignChannel(channel.id);
},
),
],
),
),
);
},
channel: channel,
isUnread: isUnread,
onMarkRead: onMarkRead,
sectionId: sectionId,
);
}
}
@@ -0,0 +1,390 @@
import 'dart:async';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channel_actions_sheet.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
const _currentPubkey = 'me';
Channel _channel({String type = 'stream', bool isArchived = false}) => Channel(
id: 'channel-id',
name: type == 'dm' ? 'Alice' : 'general',
channelType: type,
visibility: 'open',
description: '',
createdBy: 'owner',
createdAt: DateTime(2025),
memberCount: 2,
isMember: true,
archivedAt: isArchived ? DateTime(2025, 1, 2) : null,
);
Widget _app({
required Channel channel,
required Future<List<ChannelMember>> Function() loadMembers,
bool isUnread = false,
AsyncValue<Map<String, String>> agentOwners = const AsyncValue.data(
<String, String>{},
),
ChannelActions Function(Ref ref)? createChannelActions,
String? currentPubkey = _currentPubkey,
}) => ProviderScope(
overrides: [
currentPubkeyProvider.overrideWith((ref) => currentPubkey),
channelMembersProvider(channel.id).overrideWith((ref) => loadMembers()),
agentOwnersProvider.overrideWithValue(agentOwners),
if (createChannelActions != null)
channelActionsProvider.overrideWith(createChannelActions),
],
child: MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: ChannelActionsSheet(channel: channel, isUnread: isUnread),
),
),
);
Widget _modalApp({
required Channel channel,
required Future<List<ChannelMember>> Function() loadMembers,
required ChannelActions Function(Ref ref) createChannelActions,
}) => ProviderScope(
overrides: [
currentPubkeyProvider.overrideWith((ref) => _currentPubkey),
channelMembersProvider(channel.id).overrideWith((ref) => loadMembers()),
channelActionsProvider.overrideWith(createChannelActions),
],
child: MaterialApp(
theme: AppTheme.light(),
home: Builder(
builder: (context) => Scaffold(
body: TextButton(
onPressed: () => showChannelActionsSheet(
context: context,
channel: channel,
isUnread: false,
),
child: const Text('Open actions'),
),
),
),
),
);
void main() {
testWidgets('owner sees the complete regular-channel action set', (
tester,
) async {
await tester.pumpWidget(
_app(
channel: _channel(),
loadMembers: () async => [
ChannelMember(
pubkey: _currentPubkey,
role: 'owner',
joinedAt: DateTime(2025),
),
],
),
);
await tester.pumpAndSettle();
for (final label in [
'Star',
'Mark Unread',
'Move to section…',
'Mute channel',
'Manage channel',
'Copy channel name',
'Copy channel ID',
'Leave channel',
'Archive channel',
'Delete channel',
]) {
expect(find.text(label), findsOneWidget, reason: label);
}
final moveTop = tester.getTopLeft(find.text('Move to section…')).dy;
final muteTop = tester.getTopLeft(find.text('Mute channel')).dy;
final manageTop = tester.getTopLeft(find.text('Manage channel')).dy;
final copyNameTop = tester.getTopLeft(find.text('Copy channel name')).dy;
final copyIdTop = tester.getTopLeft(find.text('Copy channel ID')).dy;
expect(moveTop, lessThan(muteTop));
expect(muteTop, lessThan(manageTop));
expect(manageTop, lessThan(copyNameTop));
expect(copyNameTop, lessThan(copyIdTop));
});
testWidgets('unread channel uses the Mark Read label', (tester) async {
await tester.pumpWidget(
_app(
channel: _channel(),
isUnread: true,
loadMembers: () async => const [],
),
);
await tester.pumpAndSettle();
expect(find.text('Mark Read'), findsOneWidget);
expect(find.text('Mark Unread'), findsNothing);
});
testWidgets('admin can archive but cannot delete', (tester) async {
await tester.pumpWidget(
_app(
channel: _channel(),
loadMembers: () async => [
ChannelMember(
pubkey: _currentPubkey,
role: 'admin',
joinedAt: DateTime(2025),
),
],
),
);
await tester.pumpAndSettle();
expect(find.text('Archive channel'), findsOneWidget);
expect(find.text('Delete channel'), findsNothing);
});
testWidgets('verified owner agent grants archive and delete', (tester) async {
const agentPubkey = 'agent';
await tester.pumpWidget(
_app(
channel: _channel(),
agentOwners: const AsyncValue.data({agentPubkey: _currentPubkey}),
loadMembers: () async => [
ChannelMember(
pubkey: agentPubkey,
role: 'owner',
joinedAt: DateTime(2025),
),
],
),
);
await tester.pumpAndSettle();
expect(find.text('Archive channel'), findsOneWidget);
expect(find.text('Delete channel'), findsOneWidget);
});
testWidgets('unresolved identity grants no lifecycle actions', (
tester,
) async {
await tester.pumpWidget(
_app(
channel: _channel(),
currentPubkey: null,
loadMembers: () async => [
ChannelMember(
pubkey: 'ordinary-owner',
role: 'owner',
joinedAt: DateTime(2025),
),
],
),
);
await tester.pumpAndSettle();
expect(find.text('Archive channel'), findsNothing);
expect(find.text('Delete channel'), findsNothing);
});
testWidgets('archived owner can unarchive but cannot delete', (tester) async {
late _FakeChannelActions actions;
await tester.pumpWidget(
_app(
channel: _channel(isArchived: true),
loadMembers: () async => [
ChannelMember(
pubkey: _currentPubkey,
role: 'owner',
joinedAt: DateTime(2025),
),
],
createChannelActions: (ref) => actions = _FakeChannelActions(ref),
),
);
await tester.pumpAndSettle();
expect(find.text('Archive channel'), findsNothing);
expect(find.text('Unarchive channel'), findsOneWidget);
expect(find.text('Delete channel'), findsNothing);
await tester.tap(find.text('Unarchive channel'));
await tester.pumpAndSettle();
expect(find.text('Unarchive #general?'), findsOneWidget);
await tester.tap(find.widgetWithText(FilledButton, 'Unarchive'));
await tester.pumpAndSettle();
expect(actions.unarchivedChannelId, 'channel-id');
});
testWidgets('owned non-owner agent grants no lifecycle actions', (
tester,
) async {
const agentPubkey = 'agent';
await tester.pumpWidget(
_app(
channel: _channel(),
agentOwners: const AsyncValue.data({agentPubkey: _currentPubkey}),
loadMembers: () async => [
ChannelMember(
pubkey: agentPubkey,
role: 'bot',
joinedAt: DateTime(2025),
),
],
),
);
await tester.pumpAndSettle();
expect(find.text('Archive channel'), findsNothing);
expect(find.text('Delete channel'), findsNothing);
});
testWidgets('agent ownership loading keeps lifecycle actions pending', (
tester,
) async {
await tester.pumpWidget(
_app(
channel: _channel(),
agentOwners: const AsyncValue.loading(),
loadMembers: () async => const [],
),
);
await tester.pump();
expect(find.text('Loading channel actions…'), findsOneWidget);
expect(find.text('Archive channel'), findsNothing);
expect(find.text('Delete channel'), findsNothing);
});
testWidgets('member sees neither owner action', (tester) async {
await tester.pumpWidget(
_app(
channel: _channel(),
loadMembers: () async => [
ChannelMember(
pubkey: _currentPubkey,
role: 'member',
joinedAt: DateTime(2025),
),
],
),
);
await tester.pumpAndSettle();
expect(find.text('Archive channel'), findsNothing);
expect(find.text('Delete channel'), findsNothing);
expect(find.text('Leave channel'), findsOneWidget);
});
testWidgets('shows loading and unavailable capability states', (
tester,
) async {
final pending = Completer<List<ChannelMember>>();
await tester.pumpWidget(
_app(channel: _channel(), loadMembers: () => pending.future),
);
await tester.pump();
expect(find.text('Loading channel actions…'), findsOneWidget);
pending.completeError(Exception('relay unavailable'));
await tester.pumpAndSettle();
expect(find.text('Channel actions unavailable'), findsOneWidget);
});
testWidgets(
'manage leave closes both nested sheets without popping the page',
(tester) async {
await tester.pumpWidget(
_modalApp(
channel: _channel(),
loadMembers: () async => const [],
createChannelActions: (ref) => _FakeChannelActions(ref),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Open actions'));
await tester.pumpAndSettle();
await tester.tap(find.text('Manage channel'));
await tester.pumpAndSettle();
await tester.tap(find.text('Leave channel').last);
await tester.pumpAndSettle();
expect(find.byType(ChannelActionsSheet), findsNothing);
expect(find.byType(Scaffold), findsOneWidget);
},
);
testWidgets('DM omits quick actions, then shows mute and copy rows', (
tester,
) async {
await tester.pumpWidget(
_app(
channel: _channel(type: 'dm'),
loadMembers: () async => const [],
),
);
await tester.pumpAndSettle();
for (final label in [
'Mute channel',
'Copy channel name',
'Copy channel ID',
]) {
expect(find.text(label), findsOneWidget, reason: label);
}
for (final label in [
'Star',
'Unstar',
'Mark Unread',
'Mark Read',
'Move to section…',
'Manage channel',
'Leave channel',
'Archive channel',
'Delete channel',
]) {
expect(find.text(label), findsNothing, reason: label);
}
final muteTop = tester.getTopLeft(find.text('Mute channel')).dy;
final copyNameTop = tester.getTopLeft(find.text('Copy channel name')).dy;
final copyIdTop = tester.getTopLeft(find.text('Copy channel ID')).dy;
expect(muteTop, lessThan(copyNameTop));
expect(copyNameTop, lessThan(copyIdTop));
});
}
class _FakeChannelActions extends ChannelActions {
_FakeChannelActions(Ref ref)
: super(
ref: ref,
session: ref.read(relaySessionProvider.notifier),
signedEventRelay: SignedEventRelay(
session: ref.read(relaySessionProvider.notifier),
nsec: null,
),
currentPubkey: _currentPubkey,
);
String? unarchivedChannelId;
@override
Future<void> leaveChannel(String channelId) async {}
@override
Future<void> unarchiveChannel(String channelId) async {
unarchivedChannelId = channelId;
}
}
@@ -828,7 +828,14 @@ void main() {
);
expect(find.text('Message…'), findsNothing);
await tester.tap(find.byTooltip('Manage channel'));
await tester.tap(find.byTooltip('Channel actions'));
await tester.pumpAndSettle();
await tester.drag(
find.byType(SingleChildScrollView).last,
const Offset(0, -300),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Manage channel').last);
await tester.pumpAndSettle();
await tester.tap(find.text('Join channel'));
await tester.pumpAndSettle();
@@ -841,6 +848,27 @@ void main() {
expect(find.text('Message #general'), findsOneWidget);
});
testWidgets('detail-header manage leave closes the detail page', (
tester,
) async {
await tester.pumpWidget(
_buildTestable(
messages: const [],
createChannelActions: (ref) => _FakeChannelActions(ref),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Channel actions'));
await tester.pumpAndSettle();
await tester.tap(find.text('Manage channel').last);
await tester.pumpAndSettle();
await tester.tap(find.text('Leave channel').last);
await tester.pumpAndSettle();
expect(find.byTooltip('Channel actions'), findsNothing);
});
testWidgets('keeps manage sheet dismissible with a long canvas', (
tester,
) async {
@@ -860,11 +888,18 @@ void main() {
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Manage channel'));
await tester.tap(find.byTooltip('Channel actions'));
await tester.pumpAndSettle();
await tester.drag(
find.byType(SingleChildScrollView).last,
const Offset(0, -300),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Manage channel').last);
await tester.pumpAndSettle();
final sheet = find.byType(BottomSheet);
expect(sheet, findsOneWidget);
final sheet = find.byType(BottomSheet).last;
expect(find.byType(BottomSheet), findsNWidgets(2));
expect(tester.getSize(sheet).height, lessThanOrEqualTo(720));
final sheetTop = tester.getTopLeft(sheet).dy;
@@ -874,7 +909,7 @@ void main() {
);
await tester.pumpAndSettle();
expect(sheet, findsNothing);
expect(find.text('Manage channel'), findsOneWidget);
});
testWidgets('shows empty state when no messages', (tester) async {
@@ -200,6 +200,28 @@ void main() {
});
});
group('build channel lifecycle tags', () {
test('archive matches kind 9002 tags', () {
expect(buildSetChannelArchivedTags('channel-id', archived: true), [
['h', 'channel-id'],
['archived', 'true'],
]);
});
test('unarchive matches kind 9002 tags', () {
expect(buildSetChannelArchivedTags('channel-id', archived: false), [
['h', 'channel-id'],
['archived', 'false'],
]);
});
test('delete matches desktop kind 9008 tags', () {
expect(buildDeleteChannelTags('channel-id'), [
['h', 'channel-id'],
]);
});
});
group('directory providers relay-config invalidation', () {
NostrEvent profile(String pubkey, String name) => NostrEvent(
id: '$pubkey-profile',