diff --git a/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart b/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart new file mode 100644 index 000000000..e59b978ab --- /dev/null +++ b/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart @@ -0,0 +1,347 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:uuid/uuid.dart'; + +import '../../../shared/crypto/nip44.dart'; +import '../../../shared/relay/relay.dart'; +import '../read_state/read_state_time.dart'; +import 'channel_sections_storage.dart'; + +const _uuid = Uuid(); + +class ChannelSectionsCrypto { + final Uint8List _conversationKey; + + ChannelSectionsCrypto(String nsec, String pubkey) + : _conversationKey = _deriveKey(nsec, pubkey); + + static Uint8List _deriveKey(String nsec, String pubkey) { + final privkeyHex = nostr.Nip19.decode(payload: nsec).data; + return getConversationKey(privkeyHex, pubkey); + } + + String encrypt(String plaintext) => nip44Encrypt(_conversationKey, plaintext); + + String decrypt(String ciphertext) => + nip44Decrypt(_conversationKey, ciphertext); +} + +class ChannelSectionsManager { + final String pubkey; + final ChannelSectionsStorage _storage; + final ChannelSectionsCrypto _crypto; + final RelaySessionNotifier? _relaySession; + final SignedEventRelay? _signedEventRelay; + final bool _remoteEnabled; + final VoidCallback _onChanged; + + ChannelSectionStore _store; + Timer? _publishDebounce; + int _lastRemoteCreatedAt = 0; + void Function()? _unsubscribe; + bool _disposed = false; + + ChannelSectionsManager({ + required this.pubkey, + required SharedPreferences prefs, + required ChannelSectionsCrypto crypto, + required RelaySessionNotifier? relaySession, + required SignedEventRelay? signedEventRelay, + required bool remoteEnabled, + required VoidCallback onChanged, + }) : _storage = ChannelSectionsStorage(prefs), + _crypto = crypto, + _relaySession = relaySession, + _signedEventRelay = signedEventRelay, + _remoteEnabled = remoteEnabled, + _onChanged = onChanged, + _store = ChannelSectionsStorage(prefs).read(pubkey); + + ChannelSectionStore get store => _store; + + Future initialize() async { + if (_disposed) return; + + if (!_remoteEnabled || _relaySession == null) { + _onChanged(); + return; + } + + await _fetchAndMerge(); + await _startLiveSubscription(); + _onChanged(); + } + + void dispose({bool flushPending = true}) { + if (_disposed) return; + _disposed = true; + + final hadPending = _publishDebounce != null; + _publishDebounce?.cancel(); + _publishDebounce = null; + + if (flushPending && hadPending && _remoteEnabled) { + unawaited(_publish(allowDisposed: true)); + } + + _unsubscribe?.call(); + _unsubscribe = null; + } + + // ------------------------------------------------------------------------- + // CRUD + // ------------------------------------------------------------------------- + + void createSection(String name) { + if (_disposed) return; + final maxOrder = _store.sections.fold( + -1, + (max, s) => s.order > max ? s.order : max, + ); + final section = ChannelSection( + id: _uuid.v4(), + name: name.trim(), + order: maxOrder + 1, + ); + _store = ChannelSectionStore( + sections: [..._store.sections, section], + assignments: _store.assignments, + ); + _persist(); + markDirty(); + } + + void renameSection(String sectionId, String newName) { + if (_disposed) return; + _store = ChannelSectionStore( + sections: [ + for (final s in _store.sections) + if (s.id == sectionId) + ChannelSection(id: s.id, name: newName.trim(), order: s.order) + else + s, + ], + assignments: _store.assignments, + ); + _persist(); + markDirty(); + } + + void deleteSection(String sectionId) { + if (_disposed) return; + final updatedAssignments = Map.from(_store.assignments) + ..removeWhere((_, sid) => sid == sectionId); + _store = ChannelSectionStore( + sections: [ + for (final s in _store.sections) + if (s.id != sectionId) s, + ], + assignments: updatedAssignments, + ); + _persist(); + markDirty(); + } + + void moveSectionUp(String sectionId) { + if (_disposed) return; + final sorted = _sortedSections(); + final idx = sorted.indexWhere((s) => s.id == sectionId); + if (idx <= 0) return; + _swapOrders(sorted, idx, idx - 1); + markDirty(); + } + + void moveSectionDown(String sectionId) { + if (_disposed) return; + final sorted = _sortedSections(); + final idx = sorted.indexWhere((s) => s.id == sectionId); + if (idx < 0 || idx >= sorted.length - 1) return; + _swapOrders(sorted, idx, idx + 1); + markDirty(); + } + + void assignChannel(String channelId, String sectionId) { + if (_disposed) return; + final updated = Map.from(_store.assignments) + ..[channelId] = sectionId; + _store = ChannelSectionStore( + sections: _store.sections, + assignments: updated, + ); + _persist(); + markDirty(); + } + + void unassignChannel(String channelId) { + if (_disposed) return; + final updated = Map.from(_store.assignments) + ..remove(channelId); + _store = ChannelSectionStore( + sections: _store.sections, + assignments: updated, + ); + _persist(); + markDirty(); + } + + void markDirty() { + if (!_remoteEnabled || _disposed) return; + _publishDebounce?.cancel(); + _publishDebounce = Timer(const Duration(seconds: 5), () { + _publishDebounce = null; + unawaited(_publish()); + }); + } + + // ------------------------------------------------------------------------- + // Remote sync + // ------------------------------------------------------------------------- + + Future _fetchAndMerge() async { + if (_relaySession == null) return; + try { + final events = await _relaySession.fetchHistory( + NostrFilter( + kinds: const [EventKind.readState], + authors: [pubkey], + tags: const { + '#d': ['channel-sections'], + }, + limit: 1, + ), + ); + _mergeEvents(events); + _persist(); + if (!_disposed) _onChanged(); + } catch (_) { + // Local state remains usable when relay is unavailable. + } + } + + Future _startLiveSubscription() async { + if (_relaySession == null) return; + try { + _unsubscribe = await _relaySession.subscribe( + NostrFilter( + kinds: const [EventKind.readState], + authors: [pubkey], + tags: const { + '#d': ['channel-sections'], + }, + limit: 1, + ), + _handleIncomingEvent, + ); + } catch (_) { + // Non-fatal — local state and history still work. + } + } + + void _mergeEvents(List events) { + for (final event in events) { + if (event.pubkey != pubkey) continue; + _mergeEvent(event); + } + } + + void _mergeEvent(NostrEvent event) { + // Only process channel-sections d-tag events. + final dTag = event.getTagValue('d'); + if (dTag != 'channel-sections') return; + + try { + final plaintext = _crypto.decrypt(event.content); + final parsed = jsonDecode(plaintext); + if (parsed is! Map) return; + + final incoming = ChannelSectionStore.fromJson(parsed); + + // Last-write-wins: newer createdAt wins; tie-break by event ID. + final isNewer = + event.createdAt > _lastRemoteCreatedAt || + (event.createdAt == _lastRemoteCreatedAt && + event.id.compareTo(_lastRemoteEventId ?? '') > 0); + + if (isNewer) { + _lastRemoteCreatedAt = event.createdAt; + _lastRemoteEventId = event.id; + _store = incoming; + _persist(); + } + } catch (_) { + // Decryption failure or parse error — keep existing state. + } + } + + String? _lastRemoteEventId; + + void _handleIncomingEvent(NostrEvent event) { + if (_disposed) return; + _mergeEvent(event); + if (!_disposed) _onChanged(); + } + + Future _publish({bool allowDisposed = false}) async { + if ((!allowDisposed && _disposed) || + !_remoteEnabled || + _signedEventRelay == null) { + return; + } + + try { + final payload = jsonEncode(_store.toJson()); + final ciphertext = _crypto.encrypt(payload); + final createdAt = max(currentUnixSeconds(), _lastRemoteCreatedAt + 1); + + await _signedEventRelay.submit( + kind: EventKind.readState, + content: ciphertext, + tags: [ + ['d', 'channel-sections'], + ['t', 'channel-sections'], + ], + createdAt: createdAt, + ); + + _lastRemoteCreatedAt = max(_lastRemoteCreatedAt, createdAt); + } catch (error) { + debugPrint('[ChannelSectionsManager] publish failed: $error'); + } + } + + void _persist() { + _storage.write(pubkey, _store); + } + + List _sortedSections() { + final sorted = _store.sections.toList() + ..sort((a, b) => a.order.compareTo(b.order)); + return sorted; + } + + void _swapOrders(List sorted, int indexA, int indexB) { + final orderA = sorted[indexA].order; + final orderB = sorted[indexB].order; + final idA = sorted[indexA].id; + final idB = sorted[indexB].id; + + _store = ChannelSectionStore( + sections: [ + for (final s in _store.sections) + if (s.id == idA) + ChannelSection(id: s.id, name: s.name, order: orderB) + else if (s.id == idB) + ChannelSection(id: s.id, name: s.name, order: orderA) + else + s, + ], + assignments: _store.assignments, + ); + _persist(); + } +} diff --git a/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart b/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart new file mode 100644 index 000000000..233997175 --- /dev/null +++ b/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart @@ -0,0 +1,141 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../../../shared/relay/relay.dart'; +import '../../../shared/theme/theme_provider.dart'; +import '../../../shared/workspace/workspace_provider.dart'; +import 'channel_sections_manager.dart'; +import 'channel_sections_storage.dart'; + +class ChannelSectionsState { + final bool isReady; + final ChannelSectionStore store; + + /// Bumped on every change to force downstream rebuilds. + final int version; + + const ChannelSectionsState({ + this.isReady = false, + this.store = const ChannelSectionStore(), + this.version = 0, + }); +} + +class ChannelSectionsNotifier extends Notifier { + ChannelSectionsManager? _manager; + + @override + ChannelSectionsState build() { + _manager?.dispose(flushPending: false); + _manager = null; + + final relayConfig = ref.watch(relayConfigProvider); + final sessionState = ref.watch(relaySessionProvider); + // Rebuild when the active workspace changes (pubkey may differ). + ref.watch(activeWorkspaceProvider); + + final nsec = relayConfig.nsec?.trim(); + if (nsec == null || nsec.isEmpty) { + return const ChannelSectionsState(); + } + + final pubkey = _safePubkeyFromNsec(nsec); + if (pubkey == null || pubkey.isEmpty) { + return const ChannelSectionsState(); + } + + final ChannelSectionsCrypto crypto; + try { + crypto = ChannelSectionsCrypto(nsec, pubkey); + } catch (_) { + return const ChannelSectionsState(); + } + + final prefs = ref.read(savedPrefsProvider); + final signedRelay = SignedEventRelay( + session: ref.read(relaySessionProvider.notifier), + nsec: nsec, + ); + + late final ChannelSectionsManager manager; + manager = ChannelSectionsManager( + pubkey: pubkey, + prefs: prefs, + crypto: crypto, + relaySession: ref.read(relaySessionProvider.notifier), + signedEventRelay: signedRelay, + remoteEnabled: sessionState.status == SessionStatus.connected, + onChanged: () => _emitManagerState(manager), + ); + _manager = manager; + + ref.onDispose(() { + manager.dispose(); + if (_manager == manager) { + _manager = null; + } + }); + + Future.microtask(() async { + await manager.initialize(); + if (_manager != manager) return; + _emitManagerState(manager); + }); + + return ChannelSectionsState( + isReady: false, + store: manager.store, + version: 1, + ); + } + + // ------------------------------------------------------------------------- + // CRUD delegates + // ------------------------------------------------------------------------- + + void createSection(String name) => _manager?.createSection(name); + + void renameSection(String sectionId, String newName) => + _manager?.renameSection(sectionId, newName); + + void deleteSection(String sectionId) => _manager?.deleteSection(sectionId); + + void moveSectionUp(String sectionId) => _manager?.moveSectionUp(sectionId); + + void moveSectionDown(String sectionId) => + _manager?.moveSectionDown(sectionId); + + void assignChannel(String channelId, String sectionId) => + _manager?.assignChannel(channelId, sectionId); + + void unassignChannel(String channelId) => + _manager?.unassignChannel(channelId); + + // ------------------------------------------------------------------------- + // Internal + // ------------------------------------------------------------------------- + + void _emitManagerState(ChannelSectionsManager manager) { + if (_manager != manager) return; + state = ChannelSectionsState( + isReady: true, + store: manager.store, + version: state.version + 1, + ); + } +} + +final channelSectionsProvider = + NotifierProvider( + ChannelSectionsNotifier.new, + ); + +String? _safePubkeyFromNsec(String nsec) { + try { + final privkeyHex = nostr.Nip19.decode(payload: nsec).data; + if (privkeyHex.isEmpty) return null; + return nostr.Keys(privkeyHex).public; + } catch (_) { + return null; + } +} diff --git a/mobile/lib/features/channels/channel_sections/channel_sections_storage.dart b/mobile/lib/features/channels/channel_sections/channel_sections_storage.dart new file mode 100644 index 000000000..47011941c --- /dev/null +++ b/mobile/lib/features/channels/channel_sections/channel_sections_storage.dart @@ -0,0 +1,109 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +String channelSectionsKey(String pubkey) => + 'sprout.channel-sections.v1:$pubkey'; + +class ChannelSection { + final String id; + final String name; + final int order; + + const ChannelSection({ + required this.id, + required this.name, + required this.order, + }); + + Map toJson() => {'id': id, 'name': name, 'order': order}; + + factory ChannelSection.fromJson(Map json) => ChannelSection( + id: json['id'] as String, + name: json['name'] as String, + order: json['order'] as int, + ); +} + +class ChannelSectionStore { + final int version; + final List sections; + final Map assignments; + + const ChannelSectionStore({ + this.version = 1, + this.sections = const [], + this.assignments = const {}, + }); + + Map toJson() => { + 'version': version, + 'sections': sections.map((s) => s.toJson()).toList(), + 'assignments': assignments, + }; + + factory ChannelSectionStore.fromJson(Map json) { + final rawSections = json['sections']; + final sections = []; + if (rawSections is List) { + for (final entry in rawSections) { + if (entry is Map && + entry['id'] is String && + entry['name'] is String && + entry['order'] is int) { + sections.add(ChannelSection.fromJson(entry)); + } + } + } + + final rawAssignments = json['assignments']; + final assignments = {}; + if (rawAssignments is Map) { + for (final entry in rawAssignments.entries) { + if (entry.key is String && entry.value is String) { + assignments[entry.key as String] = entry.value as String; + } + } + } + + // Strip assignments referencing sections that don't exist. + final sectionIds = {for (final s in sections) s.id}; + assignments.removeWhere((_, sectionId) => !sectionIds.contains(sectionId)); + + return ChannelSectionStore( + version: 1, + sections: sections, + assignments: assignments, + ); + } +} + +class ChannelSectionsStorage { + final SharedPreferences _prefs; + + ChannelSectionsStorage(this._prefs); + + ChannelSectionStore read(String pubkey) { + final raw = _prefs.getString(channelSectionsKey(pubkey)); + if (raw == null || raw.isEmpty) { + return const ChannelSectionStore(); + } + + try { + final parsed = jsonDecode(raw); + if (parsed is! Map) { + return const ChannelSectionStore(); + } + if (parsed['version'] != 1) { + return const ChannelSectionStore(); + } + return ChannelSectionStore.fromJson(parsed); + } catch (_) { + return const ChannelSectionStore(); + } + } + + void write(String pubkey, ChannelSectionStore store) { + _prefs.setString(channelSectionsKey(pubkey), jsonEncode(store.toJson())); + } +} diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 36eb7ed6b..ba94e1e1e 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -21,6 +21,8 @@ import '../pairing/pairing_provider.dart'; import 'channel.dart'; import 'channel_detail_page.dart'; import 'channel_management_provider.dart'; +import 'channel_sections/channel_sections_provider.dart'; +import 'channel_sections/channel_sections_storage.dart'; import 'channels_provider.dart'; import 'read_state/deferred_read_state_update.dart'; import 'read_state/read_state_provider.dart'; @@ -272,6 +274,7 @@ class _SliverChannelsList extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final readState = ref.watch(readStateProvider); + final sectionsState = ref.watch(channelSectionsProvider); final visibleChannels = channels .where((channel) => channel.isMember && !channel.isArchived) .toList(); @@ -333,6 +336,33 @@ class _SliverChannelsList extends HookConsumerWidget { } : const {}; + // Build sorted user-defined sections and compute which stream channels + // belong to each section. Channels not assigned to any valid section fall + // through to the built-in "Channels" list. + final userSections = sectionsState.store.sections.toList() + ..sort((a, b) => a.order.compareTo(b.order)); + final sectionAssignments = sectionsState.store.assignments; + final validSectionIds = {for (final s in userSections) s.id}; + final assignedChannelIds = { + for (final entry in sectionAssignments.entries) + if (validSectionIds.contains(entry.value)) entry.key, + }; + final ungroupedStreamChannels = streamChannels + .where((c) => !assignedChannelIds.contains(c.id)) + .toList(); + + final sectionExpandedStates = useState>({}); + + bool sectionExpanded(String sectionId) => + sectionExpandedStates.value[sectionId] ?? true; + + void toggleSection(String sectionId) { + sectionExpandedStates.value = { + ...sectionExpandedStates.value, + sectionId: !sectionExpanded(sectionId), + }; + } + return SliverPadding( padding: const EdgeInsets.only(top: Grid.xxs, bottom: 80), sliver: SliverList.list( @@ -340,12 +370,87 @@ class _SliverChannelsList extends HookConsumerWidget { if (visibleChannels.isEmpty) const _EmptyState() else ...[ + // User-defined sections for stream channels, in user-defined order. + for (final section in userSections) + _CustomChannelSection( + section: section, + channels: streamChannels + .where((c) => sectionAssignments[c.id] == section.id) + .toList(), + unreadChannelIds: unreadChannelIds, + currentPubkey: currentPubkey, + expanded: sectionExpanded(section.id), + isFirst: userSections.first.id == section.id, + isLast: userSections.last.id == section.id, + onToggle: () => toggleSection(section.id), + onRename: () async { + final name = await showDialog( + context: context, + builder: (_) => _SectionNameDialog( + title: 'Rename Section', + confirmLabel: 'Rename', + initialValue: section.name, + ), + ); + if (name != null && name.isNotEmpty) { + ref + .read(channelSectionsProvider.notifier) + .renameSection(section.id, name); + } + }, + onDelete: () async { + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: Text('Delete "${section.name}"?'), + content: const Text( + 'Channels in this section will move back to the main list.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: Text( + 'Delete', + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + ], + ), + ); + if (confirmed == true) { + ref + .read(channelSectionsProvider.notifier) + .deleteSection(section.id); + } + }, + onMoveUp: () => ref + .read(channelSectionsProvider.notifier) + .moveSectionUp(section.id), + onMoveDown: () => ref + .read(channelSectionsProvider.notifier) + .moveSectionDown(section.id), + onSelectChannel: onSelectChannel, + onMarkChannelRead: (channel) { + final ts = dateTimeToUnixSeconds(channel.lastMessageAt); + if (ts != null) { + ref + .read(readStateProvider.notifier) + .markContextRead(channel.id, ts); + } + }, + ), _ChannelSection( title: 'Channels', icon: LucideIcons.hash, expanded: channelsExpanded.value, onToggle: () => channelsExpanded.value = !channelsExpanded.value, - channels: streamChannels, + channels: ungroupedStreamChannels, unreadChannelIds: unreadChannelIds, currentPubkey: currentPubkey, emptyLabel: 'No stream channels yet', @@ -380,6 +485,220 @@ class _SliverChannelsList extends HookConsumerWidget { } } +// --------------------------------------------------------------------------- +// User-defined channel sections +// --------------------------------------------------------------------------- + +class _CustomChannelSection extends StatelessWidget { + final ChannelSection section; + final List channels; + final Set unreadChannelIds; + final String? currentPubkey; + final bool expanded; + final bool isFirst; + final bool isLast; + final VoidCallback onToggle; + final VoidCallback onRename; + final VoidCallback onDelete; + final VoidCallback onMoveUp; + final VoidCallback onMoveDown; + final Future Function(Channel channel) onSelectChannel; + final void Function(Channel channel) onMarkChannelRead; + + const _CustomChannelSection({ + required this.section, + required this.channels, + required this.unreadChannelIds, + required this.currentPubkey, + required this.expanded, + required this.isFirst, + required this.isLast, + required this.onToggle, + required this.onRename, + required this.onDelete, + required this.onMoveUp, + required this.onMoveDown, + required this.onSelectChannel, + required this.onMarkChannelRead, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _CustomSectionHeader( + section: section, + expanded: expanded, + isFirst: isFirst, + isLast: isLast, + onToggle: onToggle, + onRename: onRename, + onDelete: onDelete, + onMoveUp: onMoveUp, + onMoveDown: onMoveDown, + ), + if (expanded) + for (final channel in channels) + _ChannelTile( + channel: channel, + isUnread: unreadChannelIds.contains(channel.id), + currentPubkey: currentPubkey, + onTap: () => onSelectChannel(channel), + onMarkRead: () => onMarkChannelRead(channel), + sectionId: section.id, + ), + ], + ); + } +} + +class _CustomSectionHeader extends StatelessWidget { + final ChannelSection section; + final bool expanded; + final bool isFirst; + final bool isLast; + final VoidCallback onToggle; + final VoidCallback onRename; + final VoidCallback onDelete; + final VoidCallback onMoveUp; + final VoidCallback onMoveDown; + + const _CustomSectionHeader({ + required this.section, + required this.expanded, + required this.isFirst, + required this.isLast, + required this.onToggle, + required this.onRename, + required this.onDelete, + required this.onMoveUp, + required this.onMoveDown, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onToggle, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.fromLTRB( + Grid.xs, + Grid.twelve, + Grid.xs, + Grid.half, + ), + child: Row( + children: [ + Icon( + LucideIcons.folder, + size: 14, + color: context.colors.onSurfaceVariant, + ), + const SizedBox(width: Grid.half), + Text( + section.name.toUpperCase(), + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + letterSpacing: 0.8, + ), + ), + const Spacer(), + PopupMenuButton( + icon: Icon( + LucideIcons.ellipsisVertical, + size: 14, + color: context.colors.onSurfaceVariant, + ), + padding: EdgeInsets.zero, + onSelected: (value) { + switch (value) { + case 'rename': + onRename(); + case 'move_up': + onMoveUp(); + case 'move_down': + onMoveDown(); + case 'delete': + onDelete(); + } + }, + itemBuilder: (_) => [ + const PopupMenuItem(value: 'rename', child: Text('Rename')), + PopupMenuItem( + value: 'move_up', + enabled: !isFirst, + child: const Text('Move Up'), + ), + PopupMenuItem( + value: 'move_down', + enabled: !isLast, + child: const Text('Move Down'), + ), + const PopupMenuItem(value: 'delete', child: Text('Delete')), + ], + ), + const SizedBox(width: Grid.quarter), + Icon( + expanded ? LucideIcons.chevronDown : LucideIcons.chevronRight, + size: 14, + color: context.colors.onSurfaceVariant, + ), + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Section name dialog (create / rename) +// --------------------------------------------------------------------------- + +class _SectionNameDialog extends HookWidget { + final String title; + final String confirmLabel; + final String initialValue; + + const _SectionNameDialog({ + required this.title, + required this.confirmLabel, + this.initialValue = '', + }); + + @override + Widget build(BuildContext context) { + final controller = useTextEditingController(text: initialValue); + + void confirm() { + final name = controller.text.trim(); + if (name.isNotEmpty) Navigator.of(context).pop(name); + } + + return AlertDialog( + title: Text(title), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration(labelText: 'Name'), + onSubmitted: (_) => confirm(), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton(onPressed: confirm, child: Text(confirmLabel)), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Built-in channel sections (Channels / Forums / DMs) +// --------------------------------------------------------------------------- + class _ChannelSection extends StatelessWidget { final String title; final IconData icon; @@ -437,6 +756,8 @@ class _ChannelSection extends StatelessWidget { isUnread: unreadChannelIds.contains(channel.id), currentPubkey: currentPubkey, onTap: () => onSelectChannel(channel), + onMarkRead: null, + sectionId: null, ), ], ], @@ -530,11 +851,20 @@ class _ChannelTile extends ConsumerWidget { final String? currentPubkey; final VoidCallback onTap; + /// Called when the user requests to mark this channel read (from long-press + /// actions menu). Null for channels in built-in sections. + final VoidCallback? onMarkRead; + + /// The user-defined section this channel currently belongs to, or null. + final String? sectionId; + const _ChannelTile({ required this.channel, required this.isUnread, required this.currentPubkey, required this.onTap, + this.onMarkRead, + this.sectionId, }); @override @@ -544,6 +874,7 @@ class _ChannelTile extends ConsumerWidget { return InkWell( borderRadius: BorderRadius.circular(Radii.md), onTap: onTap, + onLongPress: () => _showChannelActions(context, ref), child: Padding( padding: const EdgeInsets.only( left: Grid.xs + Grid.xxs, @@ -626,6 +957,146 @@ class _ChannelTile extends ConsumerWidget { ), ); } + + void _showChannelActions(BuildContext context, WidgetRef ref) { + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) { + final sections = ref.read(channelSectionsProvider).store.sections + ..sort((a, b) => a.order.compareTo(b.order)); + + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(Grid.xs, 0, Grid.xs, Grid.xs), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(LucideIcons.folderInput), + title: const Text('Move to section'), + onTap: () async { + Navigator.of(sheetContext).pop(); + await _showMoveSectionSheet(context, ref, sections); + }, + ), + if (isUnread) + ListTile( + leading: const Icon(LucideIcons.checkCheck), + title: const Text('Mark as read'), + onTap: () { + Navigator.of(sheetContext).pop(); + onMarkRead?.call(); + final ts = dateTimeToUnixSeconds(channel.lastMessageAt); + if (ts != null) { + ref + .read(readStateProvider.notifier) + .markContextRead(channel.id, ts); + } + }, + ), + ], + ), + ), + ); + }, + ); + } + + Future _showMoveSectionSheet( + BuildContext context, + WidgetRef ref, + List sections, + ) async { + await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(Grid.xs, 0, Grid.xs, Grid.xs), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final section in sections) + ListTile( + leading: Icon( + LucideIcons.folder, + color: sectionId == section.id + ? Theme.of(sheetContext).colorScheme.primary + : null, + ), + title: Text(section.name), + trailing: sectionId == section.id + ? Icon( + LucideIcons.check, + color: Theme.of(sheetContext).colorScheme.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( + 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); + }, + ), + ], + ), + ), + ); + }, + ); + } } class _DmAvatar extends ConsumerWidget {