mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
feat(mobile): star channels (Slack-style favorites) (#863)
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -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<void> 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<void> _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<void> _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<NostrEvent> 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<String, dynamic>) 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<void> _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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ChannelStarsState> {
|
||||||
|
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, ChannelStarsState>(
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> toJson() => {'starred': starred, 'updatedAt': updatedAt};
|
||||||
|
|
||||||
|
factory ChannelStarEntry.fromJson(Map<String, dynamic> json) =>
|
||||||
|
ChannelStarEntry(
|
||||||
|
starred: json['starred'] as bool,
|
||||||
|
updatedAt: json['updatedAt'] as int,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChannelStarStore {
|
||||||
|
final int version;
|
||||||
|
final Map<String, ChannelStarEntry> channels;
|
||||||
|
|
||||||
|
const ChannelStarStore({this.version = 1, this.channels = const {}});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'version': version,
|
||||||
|
'channels': {for (final e in channels.entries) e.key: e.value.toJson()},
|
||||||
|
};
|
||||||
|
|
||||||
|
factory ChannelStarStore.fromJson(Map<String, dynamic> json) {
|
||||||
|
final rawChannels = json['channels'];
|
||||||
|
final channels = <String, ChannelStarEntry>{};
|
||||||
|
if (rawChannels is Map) {
|
||||||
|
for (final entry in rawChannels.entries) {
|
||||||
|
if (entry.key is String && entry.value is Map<String, dynamic>) {
|
||||||
|
final v = entry.value as Map<String, dynamic>;
|
||||||
|
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 = <String, ChannelStarEntry>{...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<String, dynamic>) {
|
||||||
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import 'channel_management_provider.dart';
|
|||||||
import 'channel_mutes/channel_mutes_provider.dart';
|
import 'channel_mutes/channel_mutes_provider.dart';
|
||||||
import 'channel_sections/channel_sections_provider.dart';
|
import 'channel_sections/channel_sections_provider.dart';
|
||||||
import 'channel_sections/channel_sections_storage.dart';
|
import 'channel_sections/channel_sections_storage.dart';
|
||||||
|
import 'channel_stars/channel_stars_provider.dart';
|
||||||
import 'channels_provider.dart';
|
import 'channels_provider.dart';
|
||||||
import 'read_state/deferred_read_state_update.dart';
|
import 'read_state/deferred_read_state_update.dart';
|
||||||
import 'read_state/read_state_provider.dart';
|
import 'read_state/read_state_provider.dart';
|
||||||
@@ -281,6 +282,11 @@ class _SliverChannelsList extends HookConsumerWidget {
|
|||||||
for (final entry in mutesState.store.channels.entries)
|
for (final entry in mutesState.store.channels.entries)
|
||||||
if (entry.value.muted) entry.key,
|
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
|
final visibleChannels = channels
|
||||||
.where((channel) => channel.isMember && !channel.isArchived)
|
.where((channel) => channel.isMember && !channel.isArchived)
|
||||||
.toList();
|
.toList();
|
||||||
@@ -294,6 +300,7 @@ class _SliverChannelsList extends HookConsumerWidget {
|
|||||||
.where((channel) => channel.isDm)
|
.where((channel) => channel.isDm)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
final starredExpanded = useState(true);
|
||||||
final channelsExpanded = useState(true);
|
final channelsExpanded = useState(true);
|
||||||
final forumsExpanded = useState(true);
|
final forumsExpanded = useState(true);
|
||||||
final dmsExpanded = useState(true);
|
final dmsExpanded = useState(true);
|
||||||
@@ -354,8 +361,17 @@ class _SliverChannelsList extends HookConsumerWidget {
|
|||||||
for (final entry in sectionAssignments.entries)
|
for (final entry in sectionAssignments.entries)
|
||||||
if (validSectionIds.contains(entry.value)) entry.key,
|
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
|
final ungroupedStreamChannels = streamChannels
|
||||||
.where((c) => !assignedChannelIds.contains(c.id))
|
.where(
|
||||||
|
(c) =>
|
||||||
|
!assignedChannelIds.contains(c.id) &&
|
||||||
|
!starredChannelIds.contains(c.id),
|
||||||
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
final sectionExpandedStates = useState<Map<String, bool>>({});
|
final sectionExpandedStates = useState<Map<String, bool>>({});
|
||||||
@@ -377,12 +393,30 @@ class _SliverChannelsList extends HookConsumerWidget {
|
|||||||
if (visibleChannels.isEmpty)
|
if (visibleChannels.isEmpty)
|
||||||
const _EmptyState()
|
const _EmptyState()
|
||||||
else ...[
|
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.
|
// User-defined sections for stream channels, in user-defined order.
|
||||||
for (final section in userSections)
|
for (final section in userSections)
|
||||||
_CustomChannelSection(
|
_CustomChannelSection(
|
||||||
section: section,
|
section: section,
|
||||||
channels: streamChannels
|
channels: streamChannels
|
||||||
.where((c) => sectionAssignments[c.id] == section.id)
|
.where(
|
||||||
|
(c) =>
|
||||||
|
sectionAssignments[c.id] == section.id &&
|
||||||
|
!starredChannelIds.contains(c.id),
|
||||||
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
unreadChannelIds: unreadChannelIds,
|
unreadChannelIds: unreadChannelIds,
|
||||||
mutedChannelIds: mutedChannelIds,
|
mutedChannelIds: mutedChannelIds,
|
||||||
@@ -1006,6 +1040,13 @@ class _ChannelTile extends ConsumerWidget {
|
|||||||
builder: (sheetContext) {
|
builder: (sheetContext) {
|
||||||
final sections = ref.read(channelSectionsProvider).store.sections
|
final sections = ref.read(channelSectionsProvider).store.sections
|
||||||
..sort((a, b) => a.order.compareTo(b.order));
|
..sort((a, b) => a.order.compareTo(b.order));
|
||||||
|
final isStarred =
|
||||||
|
ref
|
||||||
|
.read(channelStarsProvider)
|
||||||
|
.store
|
||||||
|
.channels[channel.id]
|
||||||
|
?.starred ==
|
||||||
|
true;
|
||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -1013,6 +1054,24 @@ class _ChannelTile extends ConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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(
|
ListTile(
|
||||||
leading: const Icon(LucideIcons.folderInput),
|
leading: const Icon(LucideIcons.folderInput),
|
||||||
title: const Text('Move to section'),
|
title: const Text('Move to section'),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user