From 7a5c551dd2051525cedb2258ee1fb269e54d7630 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 4 Jun 2026 16:32:37 -0600 Subject: [PATCH] feat(mobile): star channels (Slack-style favorites) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the desktop star-channels feature to mobile. Mirrors the channel-mutes pattern: a per-channel flag with its own storage, sync manager, and Riverpod provider. - Star/Unstar from the channel long-press action sheet. - A starred channel moves into a "Starred" section pinned at the top and is removed from its normal group — Slack-style exclusivity. - State syncs across devices and with desktop via NIP-78 (kind 30078, d-tag channel-stars, nip44-encrypted to self, last-writer-wins merge). - Adds a storage unit test (parse/merge round-trips). Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- .../channel_stars/channel_stars_manager.dart | 271 ++++++++++++++++++ .../channel_stars/channel_stars_provider.dart | 123 ++++++++ .../channel_stars/channel_stars_storage.dart | 91 ++++++ .../lib/features/channels/channels_page.dart | 63 +++- .../channel_stars_storage_test.dart | 114 ++++++++ 5 files changed, 660 insertions(+), 2 deletions(-) create mode 100644 mobile/lib/features/channels/channel_stars/channel_stars_manager.dart create mode 100644 mobile/lib/features/channels/channel_stars/channel_stars_provider.dart create mode 100644 mobile/lib/features/channels/channel_stars/channel_stars_storage.dart create mode 100644 mobile/test/features/channels/channel_stars/channel_stars_storage_test.dart diff --git a/mobile/lib/features/channels/channel_stars/channel_stars_manager.dart b/mobile/lib/features/channels/channel_stars/channel_stars_manager.dart new file mode 100644 index 000000000..d4d536ea4 --- /dev/null +++ b/mobile/lib/features/channels/channel_stars/channel_stars_manager.dart @@ -0,0 +1,271 @@ +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 '../../../shared/crypto/nip44.dart'; +import '../../../shared/relay/relay.dart'; +import '../read_state/read_state_time.dart'; +import 'channel_stars_storage.dart'; + +class ChannelStarsCrypto { + final Uint8List _conversationKey; + + ChannelStarsCrypto(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 ChannelStarsManager { + final String pubkey; + final ChannelStarsStorage _storage; + final ChannelStarsCrypto _crypto; + final RelaySessionNotifier? _relaySession; + final SignedEventRelay? _signedEventRelay; + final bool _remoteEnabled; + final VoidCallback _onChanged; + + ChannelStarStore _store; + ChannelStarStore? _lastPublishedStore; + Timer? _publishDebounce; + int _lastRemoteCreatedAt = 0; + String? _lastRemoteEventId; + void Function()? _unsubscribe; + bool _disposed = false; + + ChannelStarsManager({ + required this.pubkey, + required SharedPreferences prefs, + required ChannelStarsCrypto crypto, + required RelaySessionNotifier? relaySession, + required SignedEventRelay? signedEventRelay, + required bool remoteEnabled, + required VoidCallback onChanged, + }) : _storage = ChannelStarsStorage(prefs), + _crypto = crypto, + _relaySession = relaySession, + _signedEventRelay = signedEventRelay, + _remoteEnabled = remoteEnabled, + _onChanged = onChanged, + _store = ChannelStarsStorage(prefs).read(pubkey); + + ChannelStarStore 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 starChannel(String channelId) { + if (_disposed) return; + final entry = ChannelStarEntry( + starred: true, + updatedAt: currentUnixSeconds(), + ); + _store = ChannelStarStore(channels: {..._store.channels, channelId: entry}); + _persist(); + markDirty(); + } + + void unstarChannel(String channelId) { + if (_disposed) return; + final entry = ChannelStarEntry( + starred: false, + updatedAt: currentUnixSeconds(), + ); + _store = ChannelStarStore(channels: {..._store.channels, channelId: entry}); + _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-stars'], + }, + 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-stars'], + }, + 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-stars d-tag events. + final dTag = event.getTagValue('d'); + if (dTag != 'channel-stars') return; + + try { + final plaintext = _crypto.decrypt(event.content); + final parsed = jsonDecode(plaintext); + if (parsed is! Map) return; + + final incoming = ChannelStarStore.fromJson(parsed); + + // Gate on createdAt: ignore events older than what we've already seen. + final isNewer = + event.createdAt > _lastRemoteCreatedAt || + (event.createdAt == _lastRemoteCreatedAt && + event.id.compareTo(_lastRemoteEventId ?? '') > 0); + + if (isNewer) { + _lastRemoteCreatedAt = event.createdAt; + _lastRemoteEventId = event.id; + // Per-channel merge: keep the entry with the highest updatedAt for each channel. + _store = mergeStores(_store, incoming); + _persist(); + } + } catch (_) { + // Decryption failure or parse error — keep existing state. + } + } + + void _handleIncomingEvent(NostrEvent event) { + if (_disposed) return; + _mergeEvent(event); + if (!_disposed) _onChanged(); + } + + bool _isIdenticalToLastPublished() { + final last = _lastPublishedStore; + if (last == null) return false; + if (last.channels.length != _store.channels.length) return false; + for (final key in _store.channels.keys) { + final lastEntry = last.channels[key]; + final currentEntry = _store.channels[key]; + if (lastEntry == null || + lastEntry.starred != currentEntry!.starred || + lastEntry.updatedAt != currentEntry.updatedAt) { + return false; + } + } + return true; + } + + Future _publish({bool allowDisposed = false}) async { + if ((!allowDisposed && _disposed) || + !_remoteEnabled || + _signedEventRelay == null) { + return; + } + + // Read-before-write: merge remote state before publishing + await _fetchAndMerge(); + + // No-op suppression: skip if nothing changed + if (_isIdenticalToLastPublished()) 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-stars'], + ['t', 'channel-stars'], + ], + createdAt: createdAt, + ); + + _lastRemoteCreatedAt = max(_lastRemoteCreatedAt, createdAt); + _lastPublishedStore = ChannelStarStore(channels: Map.of(_store.channels)); + } catch (error) { + debugPrint('[ChannelStarsManager] publish failed: $error'); + } + } + + void _persist() { + _storage.write(pubkey, _store); + } +} diff --git a/mobile/lib/features/channels/channel_stars/channel_stars_provider.dart b/mobile/lib/features/channels/channel_stars/channel_stars_provider.dart new file mode 100644 index 000000000..61b050b5f --- /dev/null +++ b/mobile/lib/features/channels/channel_stars/channel_stars_provider.dart @@ -0,0 +1,123 @@ +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_stars_manager.dart'; +import 'channel_stars_storage.dart'; + +class ChannelStarsState { + final bool isReady; + final ChannelStarStore store; + + /// Bumped on every change to force downstream rebuilds. + final int version; + + const ChannelStarsState({ + this.isReady = false, + this.store = const ChannelStarStore(), + this.version = 0, + }); +} + +class ChannelStarsNotifier extends Notifier { + ChannelStarsManager? _manager; + + @override + ChannelStarsState 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 ChannelStarsState(); + } + + final pubkey = _safePubkeyFromNsec(nsec); + if (pubkey == null || pubkey.isEmpty) { + return const ChannelStarsState(); + } + + final ChannelStarsCrypto crypto; + try { + crypto = ChannelStarsCrypto(nsec, pubkey); + } catch (_) { + return const ChannelStarsState(); + } + + final prefs = ref.read(savedPrefsProvider); + final signedRelay = SignedEventRelay( + session: ref.read(relaySessionProvider.notifier), + nsec: nsec, + ); + + late final ChannelStarsManager manager; + manager = ChannelStarsManager( + 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 ChannelStarsState(isReady: false, store: manager.store, version: 1); + } + + // ------------------------------------------------------------------------- + // CRUD delegates + // ------------------------------------------------------------------------- + + void starChannel(String channelId) => _manager?.starChannel(channelId); + + void unstarChannel(String channelId) => _manager?.unstarChannel(channelId); + + // ------------------------------------------------------------------------- + // Internal + // ------------------------------------------------------------------------- + + void _emitManagerState(ChannelStarsManager manager) { + if (_manager != manager) return; + state = ChannelStarsState( + isReady: true, + store: manager.store, + version: state.version + 1, + ); + } +} + +final channelStarsProvider = + NotifierProvider( + ChannelStarsNotifier.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_stars/channel_stars_storage.dart b/mobile/lib/features/channels/channel_stars/channel_stars_storage.dart new file mode 100644 index 000000000..2310ae446 --- /dev/null +++ b/mobile/lib/features/channels/channel_stars/channel_stars_storage.dart @@ -0,0 +1,91 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +String channelStarsKey(String pubkey) => 'sprout.channel-stars.v1:$pubkey'; + +class ChannelStarEntry { + final bool starred; + final int updatedAt; + + const ChannelStarEntry({required this.starred, required this.updatedAt}); + + Map toJson() => {'starred': starred, 'updatedAt': updatedAt}; + + factory ChannelStarEntry.fromJson(Map json) => + ChannelStarEntry( + starred: json['starred'] as bool, + updatedAt: json['updatedAt'] as int, + ); +} + +class ChannelStarStore { + final int version; + final Map channels; + + const ChannelStarStore({this.version = 1, this.channels = const {}}); + + Map toJson() => { + 'version': version, + 'channels': {for (final e in channels.entries) e.key: e.value.toJson()}, + }; + + factory ChannelStarStore.fromJson(Map json) { + final rawChannels = json['channels']; + final channels = {}; + if (rawChannels is Map) { + for (final entry in rawChannels.entries) { + if (entry.key is String && entry.value is Map) { + final v = entry.value as Map; + if (v['starred'] is bool && v['updatedAt'] is int) { + channels[entry.key as String] = ChannelStarEntry.fromJson(v); + } + } + } + } + return ChannelStarStore(version: 1, channels: channels); + } +} + +ChannelStarStore mergeStores(ChannelStarStore local, ChannelStarStore remote) { + // Per-channel max-updatedAt merge: + // For each channel ID in the union, keep the entry with the highest updatedAt. + final merged = {...local.channels}; + for (final entry in remote.channels.entries) { + final existing = merged[entry.key]; + if (existing == null || entry.value.updatedAt > existing.updatedAt) { + merged[entry.key] = entry.value; + } + } + return ChannelStarStore(channels: merged); +} + +class ChannelStarsStorage { + final SharedPreferences _prefs; + + ChannelStarsStorage(this._prefs); + + ChannelStarStore read(String pubkey) { + final raw = _prefs.getString(channelStarsKey(pubkey)); + if (raw == null || raw.isEmpty) { + return const ChannelStarStore(); + } + + try { + final parsed = jsonDecode(raw); + if (parsed is! Map) { + return const ChannelStarStore(); + } + if (parsed['version'] != 1) { + return const ChannelStarStore(); + } + return ChannelStarStore.fromJson(parsed); + } catch (_) { + return const ChannelStarStore(); + } + } + + void write(String pubkey, ChannelStarStore store) { + _prefs.setString(channelStarsKey(pubkey), jsonEncode(store.toJson())); + } +} diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 12d422c78..6f6757e1b 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -24,6 +24,7 @@ import 'channel_management_provider.dart'; import 'channel_mutes/channel_mutes_provider.dart'; import 'channel_sections/channel_sections_provider.dart'; import 'channel_sections/channel_sections_storage.dart'; +import 'channel_stars/channel_stars_provider.dart'; import 'channels_provider.dart'; import 'read_state/deferred_read_state_update.dart'; import 'read_state/read_state_provider.dart'; @@ -281,6 +282,11 @@ class _SliverChannelsList extends HookConsumerWidget { for (final entry in mutesState.store.channels.entries) if (entry.value.muted) entry.key, }; + final starsState = ref.watch(channelStarsProvider); + final starredChannelIds = { + for (final entry in starsState.store.channels.entries) + if (entry.value.starred) entry.key, + }; final visibleChannels = channels .where((channel) => channel.isMember && !channel.isArchived) .toList(); @@ -294,6 +300,7 @@ class _SliverChannelsList extends HookConsumerWidget { .where((channel) => channel.isDm) .toList(); + final starredExpanded = useState(true); final channelsExpanded = useState(true); final forumsExpanded = useState(true); final dmsExpanded = useState(true); @@ -354,8 +361,17 @@ class _SliverChannelsList extends HookConsumerWidget { for (final entry in sectionAssignments.entries) if (validSectionIds.contains(entry.value)) entry.key, }; + // Starred is exclusive: a starred channel lives only in the Starred section, + // not in its custom section or the default Channels list. + final starredStreamChannels = streamChannels + .where((c) => starredChannelIds.contains(c.id)) + .toList(); final ungroupedStreamChannels = streamChannels - .where((c) => !assignedChannelIds.contains(c.id)) + .where( + (c) => + !assignedChannelIds.contains(c.id) && + !starredChannelIds.contains(c.id), + ) .toList(); final sectionExpandedStates = useState>({}); @@ -377,12 +393,30 @@ class _SliverChannelsList extends HookConsumerWidget { if (visibleChannels.isEmpty) const _EmptyState() else ...[ + // Starred channels (exclusive — pinned above all sections). + if (starredStreamChannels.isNotEmpty) + _ChannelSection( + title: 'Starred', + icon: LucideIcons.star, + expanded: starredExpanded.value, + onToggle: () => starredExpanded.value = !starredExpanded.value, + channels: starredStreamChannels, + unreadChannelIds: unreadChannelIds, + mutedChannelIds: mutedChannelIds, + currentPubkey: currentPubkey, + emptyLabel: '', + onSelectChannel: onSelectChannel, + ), // 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) + .where( + (c) => + sectionAssignments[c.id] == section.id && + !starredChannelIds.contains(c.id), + ) .toList(), unreadChannelIds: unreadChannelIds, mutedChannelIds: mutedChannelIds, @@ -1006,6 +1040,13 @@ class _ChannelTile extends ConsumerWidget { 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( @@ -1013,6 +1054,24 @@ class _ChannelTile extends ConsumerWidget { 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'), diff --git a/mobile/test/features/channels/channel_stars/channel_stars_storage_test.dart b/mobile/test/features/channels/channel_stars/channel_stars_storage_test.dart new file mode 100644 index 000000000..32396db22 --- /dev/null +++ b/mobile/test/features/channels/channel_stars/channel_stars_storage_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sprout_mobile/features/channels/channel_stars/channel_stars_storage.dart'; + +/// Tests for [ChannelStarStore] parsing and [mergeStores] last-writer-wins. +void main() { + group('ChannelStarStore.fromJson', () { + test('parses a valid payload', () { + final store = ChannelStarStore.fromJson({ + 'version': 1, + 'channels': { + 'chan-1': {'starred': true, 'updatedAt': 1000}, + 'chan-2': {'starred': false, 'updatedAt': 2000}, + }, + }); + expect(store.channels.length, 2); + expect(store.channels['chan-1']!.starred, isTrue); + expect(store.channels['chan-1']!.updatedAt, 1000); + expect(store.channels['chan-2']!.starred, isFalse); + }); + + test('drops malformed entries (missing/wrong-typed fields)', () { + final store = ChannelStarStore.fromJson({ + 'version': 1, + 'channels': { + 'no-starred': {'updatedAt': 1000}, + 'no-updated-at': {'starred': true}, + 'starred-wrong-type': {'starred': 'yes', 'updatedAt': 1000}, + 'updated-wrong-type': {'starred': true, 'updatedAt': 'now'}, + 'valid': {'starred': true, 'updatedAt': 500}, + }, + }); + expect(store.channels.keys, ['valid']); + expect(store.channels['valid']!.updatedAt, 500); + }); + + test('empty / missing channels yields empty store', () { + expect(ChannelStarStore.fromJson({'version': 1}).channels, isEmpty); + expect( + ChannelStarStore.fromJson({'version': 1, 'channels': {}}).channels, + isEmpty, + ); + }); + + test('round-trips through toJson', () { + const original = ChannelStarStore( + channels: {'c': ChannelStarEntry(starred: true, updatedAt: 42)}, + ); + final round = ChannelStarStore.fromJson(original.toJson()); + expect(round.channels['c']!.starred, isTrue); + expect(round.channels['c']!.updatedAt, 42); + }); + }); + + group('mergeStores (per-channel max-updatedAt)', () { + test('union of non-overlapping channels', () { + final merged = mergeStores( + const ChannelStarStore( + channels: {'a': ChannelStarEntry(starred: true, updatedAt: 100)}, + ), + const ChannelStarStore( + channels: {'b': ChannelStarEntry(starred: false, updatedAt: 200)}, + ), + ); + expect(merged.channels.keys.toSet(), {'a', 'b'}); + }); + + test('higher updatedAt wins (remote newer)', () { + final merged = mergeStores( + const ChannelStarStore( + channels: {'a': ChannelStarEntry(starred: false, updatedAt: 100)}, + ), + const ChannelStarStore( + channels: {'a': ChannelStarEntry(starred: true, updatedAt: 200)}, + ), + ); + expect(merged.channels['a']!.starred, isTrue); + expect(merged.channels['a']!.updatedAt, 200); + }); + + test('higher updatedAt wins (local newer)', () { + final merged = mergeStores( + const ChannelStarStore( + channels: {'a': ChannelStarEntry(starred: true, updatedAt: 300)}, + ), + const ChannelStarStore( + channels: {'a': ChannelStarEntry(starred: false, updatedAt: 100)}, + ), + ); + expect(merged.channels['a']!.starred, isTrue); + expect(merged.channels['a']!.updatedAt, 300); + }); + + test('unstar with higher updatedAt overrides star', () { + final merged = mergeStores( + const ChannelStarStore( + channels: {'a': ChannelStarEntry(starred: true, updatedAt: 100)}, + ), + const ChannelStarStore( + channels: {'a': ChannelStarEntry(starred: false, updatedAt: 999)}, + ), + ); + expect(merged.channels['a']!.starred, isFalse); + expect(merged.channels['a']!.updatedAt, 999); + }); + + test('both empty yields empty', () { + final merged = mergeStores( + const ChannelStarStore(), + const ChannelStarStore(), + ); + expect(merged.channels, isEmpty); + }); + }); +}