part of '../channels_page.dart'; const int _defaultCreateChannelTtlSeconds = 7 * 24 * 60 * 60; class _CreateChannelMenuOption { final Key? key; final String label; final T value; const _CreateChannelMenuOption({ this.key, required this.label, required this.value, }); } const _createChannelTtlOptions = [ _CreateChannelMenuOption(label: '30 minutes', value: 30 * 60), _CreateChannelMenuOption(label: '1 hour', value: 60 * 60), _CreateChannelMenuOption(label: '6 hours', value: 6 * 60 * 60), _CreateChannelMenuOption(label: '12 hours', value: 12 * 60 * 60), _CreateChannelMenuOption(label: '1 day', value: 24 * 60 * 60), _CreateChannelMenuOption(label: '3 days', value: 3 * 24 * 60 * 60), _CreateChannelMenuOption( label: '7 days', value: _defaultCreateChannelTtlSeconds, ), _CreateChannelMenuOption(label: '14 days', value: 14 * 24 * 60 * 60), _CreateChannelMenuOption(label: '30 days', value: 30 * 24 * 60 * 60), ]; class _CreateChannelSheet extends HookConsumerWidget { final String channelType; const _CreateChannelSheet({required this.channelType}); @override Widget build(BuildContext context, WidgetRef ref) { final nameController = useTextEditingController(); final descriptionController = useTextEditingController(); final visibility = useState('open'); final temporary = useState(false); final ttlSeconds = useState(_defaultCreateChannelTtlSeconds); final isSubmitting = useState(false); final errorMessage = useState(null); useListenable(nameController); final kindLabel = channelType == 'forum' ? 'forum' : 'channel'; final canSubmit = nameController.text.trim().isNotEmpty && !isSubmitting.value; Future submit() async { final name = nameController.text.trim(); if (name.isEmpty || isSubmitting.value) { return; } isSubmitting.value = true; errorMessage.value = null; try { final created = await ref .read(channelActionsProvider) .createChannel( name: name, channelType: channelType, visibility: visibility.value, description: descriptionController.text.trim(), ttlSeconds: temporary.value ? ttlSeconds.value : null, ); if (context.mounted) { Navigator.of(context).pop(created); } } catch (error) { errorMessage.value = error.toString(); } finally { isSubmitting.value = false; } } return Padding( padding: EdgeInsets.fromLTRB( Grid.gutter, 0, Grid.gutter, MediaQuery.viewInsetsOf(context).bottom + Grid.xs, ), child: SafeArea( top: false, child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _CreateChannelFieldLabel(label: 'Name'), const SizedBox(height: Grid.xxs), _CreateChannelFieldShell( child: TextField( key: const Key('create-channel-name'), controller: nameController, enabled: !isSubmitting.value, autofocus: true, autocorrect: false, enableSuggestions: false, textCapitalization: TextCapitalization.none, decoration: InputDecoration( hintText: channelType == 'forum' ? 'design-discussions' : 'release-notes', border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, contentPadding: const EdgeInsets.symmetric( horizontal: Grid.twelve, vertical: Grid.xs, ), ), textInputAction: TextInputAction.done, onSubmitted: (_) { if (canSubmit) { unawaited(submit()); } }, ), ), const SizedBox(height: Grid.xs), const _CreateChannelFieldLabel( label: 'Description', optional: true, ), const SizedBox(height: Grid.xxs), _CreateChannelFieldShell( child: TextField( key: const Key('create-channel-description'), controller: descriptionController, enabled: !isSubmitting.value, decoration: InputDecoration( hintText: 'What this $kindLabel is for', border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, contentPadding: const EdgeInsets.symmetric( horizontal: Grid.twelve, vertical: Grid.xs, ), ), minLines: 2, maxLines: 3, textInputAction: TextInputAction.done, onSubmitted: (_) { if (canSubmit) { unawaited(submit()); } }, ), ), const SizedBox(height: Grid.sm), _CreateChannelRadioGroup( enabled: !isSubmitting.value, label: 'Channel type', onSelected: (value) => temporary.value = value, options: const [ _CreateChannelMenuOption( key: Key('create-channel-type-ongoing'), label: 'Ongoing', value: false, ), _CreateChannelMenuOption( key: Key('create-channel-type-temporary'), label: 'Temporary', value: true, ), ], value: temporary.value, ), if (temporary.value) ...[ const SizedBox(height: Grid.xs), _CreateChannelSettingMenu( controlKey: const Key('create-channel-ttl'), enabled: !isSubmitting.value, label: 'Expires after', onSelected: (value) => ttlSeconds.value = value, options: _createChannelTtlOptions, value: ttlSeconds.value, valueLabel: _createChannelTtlOptions .firstWhere((option) => option.value == ttlSeconds.value) .label, ), ], const SizedBox(height: Grid.sm), _CreateChannelRadioGroup( enabled: !isSubmitting.value, label: 'Visibility', onSelected: (value) => visibility.value = value, options: const [ _CreateChannelMenuOption( key: Key('create-channel-visibility-public'), label: 'Public', value: 'open', ), _CreateChannelMenuOption( key: Key('create-channel-visibility-private'), label: 'Private', value: 'private', ), ], value: visibility.value, ), if (errorMessage.value case final error?) ...[ const SizedBox(height: Grid.xs), Text( error, style: context.textTheme.bodySmall?.copyWith( color: context.colors.error, ), ), ], ], ), ), ), ); } } class _CreateChannelFieldLabel extends StatelessWidget { final String label; final bool optional; const _CreateChannelFieldLabel({required this.label, this.optional = false}); @override Widget build(BuildContext context) { return Text.rich( TextSpan( children: [ TextSpan(text: label), if (optional) TextSpan( text: ' Optional', style: context.textTheme.labelSmall?.copyWith( color: context.colors.onSurfaceVariant.withValues(alpha: 0.6), fontWeight: FontWeight.w400, ), ), ], ), style: context.textTheme.labelLarge?.copyWith( color: context.colors.onSurface, fontWeight: FontWeight.w600, ), ); } } class _CreateChannelFieldShell extends StatelessWidget { final Widget child; const _CreateChannelFieldShell({required this.child}); @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( color: context.controlSurface.withValues(alpha: 0.55), border: Border.all(color: context.colors.outlineVariant), borderRadius: BorderRadius.circular(Radii.container), ), child: child, ); } } class _CreateChannelRadioGroup extends StatelessWidget { final bool enabled; final String label; final ValueChanged onSelected; final List<_CreateChannelMenuOption> options; final T value; const _CreateChannelRadioGroup({ required this.enabled, required this.label, required this.onSelected, required this.options, required this.value, }); @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _CreateChannelFieldLabel(label: label), const SizedBox(height: Grid.xxs), Material( color: context.colors.surface, shape: RoundedRectangleBorder( side: BorderSide(color: context.colors.outlineVariant), borderRadius: BorderRadius.circular(Radii.container), ), child: RadioGroup( groupValue: value, onChanged: (nextValue) { if (enabled && nextValue != null) { onSelected(nextValue); } }, child: Column( children: [ for (final (index, option) in options.indexed) ...[ RadioListTile( key: option.key, value: option.value, enabled: enabled, selected: option.value == value, controlAffinity: ListTileControlAffinity.leading, contentPadding: const EdgeInsets.symmetric( horizontal: Grid.xs, vertical: Grid.half, ), visualDensity: VisualDensity.standard, activeColor: context.colors.primary, title: Text( option.label, style: context.textTheme.bodyMedium?.copyWith( color: enabled ? context.colors.onSurface : context.colors.onSurface.withValues(alpha: 0.38), fontWeight: option.value == value ? FontWeight.w600 : FontWeight.w400, ), ), ), if (index < options.length - 1) Divider( height: 1, indent: Grid.xs, endIndent: Grid.xs, color: context.colors.outlineVariant, ), ], ], ), ), ), ], ); } } class _CreateChannelSettingMenu extends StatelessWidget { final Key controlKey; final bool enabled; final String label; final ValueChanged onSelected; final List<_CreateChannelMenuOption> options; final T value; final IconData? valueIcon; final String valueLabel; const _CreateChannelSettingMenu({ required this.controlKey, required this.enabled, required this.label, required this.onSelected, required this.options, required this.value, this.valueIcon, required this.valueLabel, }); @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( color: context.colors.surface, border: Border.all(color: context.colors.outlineVariant), borderRadius: BorderRadius.circular(Radii.container), ), child: PopupMenuButton( enabled: enabled, initialValue: value, onSelected: onSelected, tooltip: '$label: $valueLabel', itemBuilder: (context) => [ for (final option in options) CheckedPopupMenuItem( checked: option.value == value, value: option.value, child: Text(option.label), ), ], child: Padding( key: controlKey, padding: const EdgeInsets.symmetric( horizontal: Grid.twelve, vertical: Grid.xs, ), child: Row( children: [ Expanded( child: Text( label, style: context.textTheme.labelLarge?.copyWith( color: context.colors.onSurface, fontWeight: FontWeight.w600, ), ), ), if (valueIcon case final icon?) ...[ Icon(icon, size: 16, color: context.colors.onSurfaceVariant), const SizedBox(width: Grid.half), ], Text( valueLabel, style: context.textTheme.bodyMedium?.copyWith( color: context.colors.onSurface, fontWeight: FontWeight.w500, ), ), const SizedBox(width: Grid.half), Icon( BuzzIcons.chevronDown, size: 16, color: context.colors.onSurfaceVariant, ), ], ), ), ), ); } } class _NewDirectMessageSheet extends HookConsumerWidget { final String? currentPubkey; const _NewDirectMessageSheet({required this.currentPubkey}); @override Widget build(BuildContext context, WidgetRef ref) { final queryController = useTextEditingController(); final queryFocusNode = useFocusNode(); final query = useState(''); final debouncedQuery = useState(''); final selectedUsers = useState>([]); final isSubmitting = useState(false); final submitError = useState(null); final previewDirectoryActive = ref.watch(dmDirectoryPreviewEnabledProvider); useEffect(() { final timer = Timer(const Duration(milliseconds: 250), () { debouncedQuery.value = query.value.trim().toLowerCase(); }); return timer.cancel; }, [query.value]); final normalizedQuery = previewDirectoryActive ? query.value.trim().toLowerCase() : debouncedQuery.value; final isSearchTransitionPending = !previewDirectoryActive && query.value.trim().toLowerCase() != normalizedQuery; final directoryAsync = previewDirectoryActive ? AsyncValue.data(dmDirectoryPreviewUsers) : normalizedQuery.isEmpty ? ref.watch(relayDirectoryUsersProvider) : ref.watch(relayDirectorySearchProvider(normalizedQuery)); final selectedPubkeys = selectedUsers.value .map((user) => user.pubkey.toLowerCase()) .toSet(); final availableResults = directoryAsync.asData?.value.where((user) { final normalizedPubkey = user.pubkey.toLowerCase(); if (selectedPubkeys.contains(normalizedPubkey) || normalizedPubkey == currentPubkey?.toLowerCase()) { return false; } if (normalizedQuery.isEmpty) { return true; } return user.label.toLowerCase().contains(normalizedQuery) || user.secondaryLabel.toLowerCase().contains(normalizedQuery) || normalizedPubkey.contains(normalizedQuery); }).toList() ?? const []; // Eligible recipients shown or chosen here are the comparison context. final recipients = [...availableResults, ...selectedUsers.value]; final names = watchIdentityNames( ref, [for (final user in recipients) user.pubkey], agentPubkeys: { for (final user in recipients) if (user.isAgent) user.pubkey, }, fallbackNames: {for (final user in recipients) user.pubkey: user.label}, ); final canSubmit = !isSubmitting.value && selectedUsers.value.isNotEmpty; Future submit() async { if (selectedUsers.value.isEmpty || isSubmitting.value) { return; } if (previewDirectoryActive) { submitError.value = 'Preview only — mock people cannot be messaged.'; return; } isSubmitting.value = true; submitError.value = null; try { final channel = await ref .read(channelActionsProvider) .openDm( pubkeys: selectedUsers.value.map((user) => user.pubkey).toList(), ); if (context.mounted) { Navigator.of(context).pop(channel); } } catch (error) { submitError.value = error.toString(); } finally { isSubmitting.value = false; } } return Padding( padding: EdgeInsets.fromLTRB( Grid.gutter, 0, Grid.gutter, MediaQuery.viewInsetsOf(context).bottom + Grid.xs, ), child: SafeArea( top: false, // The sheet bounds this viewport even with the keyboard visible. // Keep rows as slivers so search and chip updates do not lay out the // entire directory. The field still scrolls with long chip selections. child: CustomScrollView( shrinkWrap: true, slivers: [ SliverToBoxAdapter( child: GestureDetector( behavior: HitTestBehavior.translucent, onTap: isSubmitting.value ? null : queryFocusNode.requestFocus, child: SizedBox( width: double.infinity, child: ConstrainedBox( key: const Key('new-dm-recipient-field'), constraints: const BoxConstraints(minHeight: Grid.xl), child: DecoratedBox( decoration: BoxDecoration( color: context.colors.surface, border: Border.all( color: context.colors.outlineVariant, ), borderRadius: BorderRadius.circular(Radii.container), ), child: Padding( padding: const EdgeInsets.symmetric( horizontal: Grid.twelve, vertical: Grid.xxs, ), child: LayoutBuilder( builder: (context, constraints) { final hasSelectedUsers = selectedUsers.value.isNotEmpty; final searchFieldWidth = hasSelectedUsers ? 96.0 : (constraints.maxWidth - 32).clamp( 96.0, constraints.maxWidth, ); return Wrap( key: const Key('new-dm-recipient-wrap'), spacing: 6, runSpacing: 6, crossAxisAlignment: WrapCrossAlignment.center, children: [ Padding( padding: const EdgeInsets.symmetric( vertical: Grid.half, ), child: Text( 'To:', style: context.textTheme.bodyLarge ?.copyWith(fontWeight: FontWeight.w600), ), ), for (final user in selectedUsers.value) _SelectedDmRecipientChip( user: user, label: names.labelFor(user.pubkey), enabled: !isSubmitting.value, onDeleted: () { selectedUsers.value = [ for (final candidate in selectedUsers.value) if (candidate.pubkey != user.pubkey) candidate, ]; queryFocusNode.requestFocus(); }, ), SizedBox( width: searchFieldWidth, child: TextField( key: const Key('new-dm-search'), controller: queryController, focusNode: queryFocusNode, autofocus: true, autocorrect: false, enableSuggestions: false, enabled: !isSubmitting.value, onChanged: (value) => query.value = value, onSubmitted: (_) { if (canSubmit) { unawaited(submit()); } }, decoration: InputDecoration( hintText: hasSelectedUsers ? null : 'Search for a person', border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, isDense: true, contentPadding: const EdgeInsets.symmetric( vertical: Grid.half, ), suffixIcon: isSubmitting.value ? const Padding( padding: EdgeInsets.all( Grid.half, ), child: SizedBox.square( dimension: 16, child: BuzzLoadingIndicator( size: 16, semanticLabel: 'Creating conversation', ), ), ) : null, suffixIconConstraints: const BoxConstraints( minHeight: 32, minWidth: 32, ), ), textInputAction: TextInputAction.done, ), ), ], ); }, ), ), ), ), ), ), ), const SliverToBoxAdapter(child: SizedBox(height: Grid.xs)), Builder( key: const Key('new-dm-results'), builder: (context) { if (selectedUsers.value.length >= 8) { return const SliverToBoxAdapter( child: SizedBox( height: 96, child: Center( child: Text( 'DMs support up to nine people, including you.', ), ), ), ); } if (directoryAsync.isLoading && directoryAsync.asData == null || isSearchTransitionPending) { return const SliverToBoxAdapter( child: SizedBox( height: 280, child: Center( child: BuzzLoadingIndicator( size: 44, semanticLabel: 'Loading people', ), ), ), ); } if (directoryAsync.hasError) { return SliverToBoxAdapter( child: SizedBox( height: 280, child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Text( 'Could not load people from this relay.', ), const SizedBox(height: Grid.half), TextButton( onPressed: () { if (normalizedQuery.isEmpty) { ref.invalidate(relayDirectoryUsersProvider); } else { ref.invalidate( relayDirectorySearchProvider( normalizedQuery, ), ); } }, child: const Text('Retry'), ), ], ), ), ), ); } if (availableResults.isEmpty) { return SliverToBoxAdapter( child: SizedBox( height: 280, child: Center( child: Text( normalizedQuery.isEmpty ? 'No people or agents available to message.' : 'No matching users.', ), ), ), ); } return SliverList.builder( itemCount: availableResults.length, itemBuilder: (context, index) { final user = availableResults[index]; return AppListCardItem( index: index, itemCount: availableResults.length, dividerIndent: Grid.xs + 40 + Grid.xs, child: ListTile( key: Key('new-dm-person-${user.pubkey}'), leading: AvatarImage( imageUrl: user.avatarUrl, radius: 20, backgroundColor: context.controlSurface, fallback: Text( user.initial, style: context.textTheme.labelLarge?.copyWith( color: context.colors.onPrimaryContainer, fontWeight: FontWeight.w600, ), ), isAgent: user.isAgent, ), title: Text( names.labelFor(user.pubkey), maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Text( user.secondaryLabel, maxLines: 1, overflow: TextOverflow.ellipsis, ), trailing: Icon( BuzzIcons.plus, size: 18, color: context.colors.onSurfaceVariant, ), onTap: isSubmitting.value ? null : () { selectedUsers.value = [ ...selectedUsers.value, user, ]; queryController.clear(); query.value = ''; debouncedQuery.value = ''; submitError.value = null; queryFocusNode.requestFocus(); }, ), ); }, ); }, ), if (submitError.value case final error?) ...[ const SliverToBoxAdapter(child: SizedBox(height: Grid.xxs)), SliverToBoxAdapter( child: Text( error, style: context.textTheme.bodySmall?.copyWith( color: context.colors.error, ), ), ), ], ], ), ), ); } } class _SelectedDmRecipientChip extends StatelessWidget { final DirectoryUser user; final String label; final bool enabled; final VoidCallback onDeleted; const _SelectedDmRecipientChip({ required this.user, required this.label, required this.enabled, required this.onDeleted, }); @override Widget build(BuildContext context) { final foreground = context.colors.onSurfaceVariant; return ConstrainedBox( key: Key('new-dm-selected-${user.pubkey}'), constraints: const BoxConstraints(maxWidth: 224), child: SizedBox( height: 40, child: Material( color: context.colors.surfaceContainerHighest, shape: const StadiumBorder(), clipBehavior: Clip.antiAlias, child: Semantics( button: true, enabled: enabled, excludeSemantics: true, label: 'Remove $label', child: InkWell( customBorder: const StadiumBorder(), onTap: enabled ? onDeleted : null, child: Padding( padding: const EdgeInsets.only( left: Grid.half, right: Grid.twelve, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ AvatarImage( imageUrl: user.avatarUrl, radius: 16, backgroundColor: context.controlSurface, fallback: Text( user.initial, style: context.textTheme.labelMedium?.copyWith( color: context.colors.onPrimaryContainer, fontWeight: FontWeight.w600, ), ), isAgent: user.isAgent, ), const SizedBox(width: Grid.xxs), Flexible( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: context.textTheme.bodyLarge?.copyWith( color: foreground, fontWeight: FontWeight.w500, ), ), ), ], ), ), ), ), ), ), ); } }