Files
buzz/mobile/lib/features/channels/channel_actions_sheet.dart
Logan Johnson 7a1910e700 feat(mobile): show contextual names in lists, Search and Pulse (#7896)
🤖
## Summary

This PR finishes the change on the remaining mobile screens. After it,
every place that names a person or agent uses a contextual name:

- The channel list, DM labels, and the new-DM picker
- The channel header and details, and the members and add-members sheets
- Huddle avatars, spotlight, and overlay
- The Activity inbox
- Search results for people and messages
- Pulse notes, agent cards, and reply previews

Screens that belong to a channel compare names against its members.
Search, Pulse, and the new-DM picker have no channel, so they compare
the identities shown together. In the example below, Search showed four
results all named "murderbot". Now the human keeps the plain name, and
baxen's three agents are labeled by owner and key suffix.

Part 3 of 3: #7894 (resolver) → #7895 (conversations) → #7896 (lists,
Search, Pulse).

### Related issue

Related to #2910. The desktop search change in #6483 addresses the same
problem in a different app.

### Testing

- Widget tests cover the Activity inbox, Search, and Pulse notes with
colliding names.
- `flutter analyze` is clean, and the full `flutter test` suite passes.
- Checked on an Android emulator against the live relay by searching for
"murderbot". The after image also shows "classy-murderbot", a new
account that was created between the two captures.

| Before | After |
| --- | --- |
| <img width="300" alt="Before: Search shows four people all named
murderbot"
src="https://github.com/user-attachments/assets/21bbcc52-fea8-4b31-ab8c-ff3ec9b32a39"
/> | <img width="300" alt="After: three results show as baxen's
murderbot with a key suffix, and the human shows as murderbot"
src="https://github.com/user-attachments/assets/d59584d9-4a35-46da-b39a-83addfad3cd2"
/> |

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-30 10:46:14 -04:00

614 lines
21 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter/services.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/app_list.dart';
import '../../shared/widgets/app_list_card.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/buzz_action_tile.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/ios_glass_navigation_button.dart';
import '../../shared/widgets/lucide_star_icon.dart';
import '../../shared/widgets/modal_presentation.dart';
import '../../shared/widgets/sheet_divider.dart';
import 'channel.dart';
import 'add_members_sheet.dart';
import 'channel_identity_names_provider.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 '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';
part 'channel_details_page.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,
}) => showBuzzModalBottomSheet<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 HookConsumerWidget {
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 displayedChannel = useState(channel);
final currentChannel = displayedChannel.value;
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: IconTheme.merge(
data: const IconThemeData(size: 22),
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(
contentPadding: EdgeInsets.zero,
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(
contentPadding: EdgeInsets.zero,
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(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.settings),
title: const Text('Manage channel'),
onTap: () async {
final shouldClose = await showBuzzModalBottomSheet<bool>(
context: context,
title: 'Manage channel',
isScrollControlled: true,
showDragHandle: true,
constraints: BoxConstraints(
maxWidth: 640,
maxHeight: MediaQuery.sizeOf(context).height * 0.9,
),
builder: (_) => ManageChannelSheet(
channel: currentChannel,
canEditDetails:
canManageLifecycle && !currentChannel.isArchived,
onChannelUpdated: (updated) =>
displayedChannel.value = updated,
),
);
if (shouldClose == true && context.mounted) {
Navigator.of(context).pop(true);
}
},
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(LucideIcons.copy),
title: const Text('Copy channel name'),
onTap: () {
close();
copyToClipboard(
context,
currentChannel.name,
message: 'Channel name copied to clipboard',
);
},
),
ListTile(
contentPadding: EdgeInsets.zero,
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 #${currentChannel.name}?',
body: 'You’ll 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 #${currentChannel.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 #${currentChannel.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 #${currentChannel.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(
children: [
Expanded(
child: _ChannelQuickAction(
icon: isStarred ? LucideIcons.starOff : LucideIcons.star,
label: isStarred ? 'Unstar' : 'Star',
onTap: onToggleStar,
),
),
const SizedBox(width: Grid.twelve),
Expanded(
child: _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: () {
unawaited(HapticFeedback.lightImpact());
onTap();
},
behavior: HitTestBehavior.opaque,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
height: 68 + (Grid.xxs * 2),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.dialog),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 22, 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(
contentPadding: EdgeInsets.zero,
leading: Icon(icon, color: destructive ? context.colors.error : null),
title: Text(
label,
style: destructive ? TextStyle(color: context.colors.error) : null,
),
onTap: () {
unawaited(HapticFeedback.lightImpact());
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 showBuzzDialog<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(
'Couldn’t ${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 showBuzzModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: IconTheme.merge(
data: const IconThemeData(size: 22),
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 showBuzzDialog<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;
}