mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(mobile): protect unpublished channel-section edits and sync channel sort
Mobile channel-section edits were silently lost: sections publish on a 5s
debounce, but the provider tears the sync manager down on every relay-session
status flip without flushing. The rebuilt manager then fetched the stale
remote blob and unconditionally adopted it over newer local state, erasing
the just-made edit ("groups do not persist").
Fix, mirroring the read-state manager pattern:
- Persisted dirty flag (dirty-since) in ChannelSectionsStorage so
unpublished edits survive manager teardown and app restarts.
- Dirty-state protection in _mergeEvent: a remote blob never overwrites
unpublished local edits; the pending publish reconciles the relay.
- Publish-on-reconnect: initialize() re-schedules a publish when the dirty
flag is set, and seed-publishes local state when the relay confirms it has
no blob (never on a failed fetch).
- Edit-generation guard so a publish only clears the dirty flag when no new
edit landed while it was in flight.
Also adds mobile channel-sort sync parity (desktop-only until now):
- New channel_sort feature (storage/manager/provider) using the same
encrypted NIP-78 blob desktop publishes (kind 30078, d-tag channel-sort,
NIP-44 to self, whole-blob LWW) including the dirty-flag protection above.
- Per-group Sort: Recent / A-Z controls on Starred, custom sections,
Channels, and DMs, matching desktop group keys (starred/channels/dms,
section:<id>) and sort semantics; orphaned section:<id> keys are pruned
on write.
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
Signed-off-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
This commit is contained in:
parent
ab3af82871
commit
0c397f5771
@@ -48,6 +48,17 @@ class ChannelSectionsManager {
|
||||
void Function()? _unsubscribe;
|
||||
bool _disposed = false;
|
||||
|
||||
/// Unix seconds of the oldest unpublished local edit; 0 when clean.
|
||||
/// Persisted so unpublished edits survive manager teardown — the provider
|
||||
/// rebuilds this manager on every relay-session status flip, which would
|
||||
/// otherwise drop the pending debounce and let a stale remote blob clobber
|
||||
/// newer local state on the next fetch.
|
||||
int _dirtySince;
|
||||
|
||||
/// Bumped on every local edit so a publish only clears the dirty flag when
|
||||
/// no new edit landed while the publish was in flight.
|
||||
int _editGeneration = 0;
|
||||
|
||||
ChannelSectionsManager({
|
||||
required this.pubkey,
|
||||
required SharedPreferences prefs,
|
||||
@@ -62,10 +73,14 @@ class ChannelSectionsManager {
|
||||
_signedEventRelay = signedEventRelay,
|
||||
_remoteEnabled = remoteEnabled,
|
||||
_onChanged = onChanged,
|
||||
_store = ChannelSectionsStorage(prefs).read(pubkey);
|
||||
_store = ChannelSectionsStorage(prefs).read(pubkey),
|
||||
_dirtySince = ChannelSectionsStorage(prefs).readDirtySince(pubkey);
|
||||
|
||||
ChannelSectionStore get store => _store;
|
||||
|
||||
@visibleForTesting
|
||||
bool get isDirty => _dirtySince > 0;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_disposed) return;
|
||||
|
||||
@@ -74,8 +89,19 @@ class ChannelSectionsManager {
|
||||
return;
|
||||
}
|
||||
|
||||
await _fetchAndMerge();
|
||||
final foundRemote = await _fetchAndMerge();
|
||||
await _startLiveSubscription();
|
||||
|
||||
// Publish-on-reconnect: unpublished local edits (persisted dirty flag)
|
||||
// survive teardown/rebuild and get flushed once we're connected again.
|
||||
// Seed-publish: if the relay confirmed it has no blob at all but local
|
||||
// state exists, push local up so other clients can sync. `foundRemote`
|
||||
// is null when the fetch failed — never seed-publish on a failed fetch.
|
||||
if (_dirtySince > 0 ||
|
||||
(foundRemote == false &&
|
||||
(_store.sections.isNotEmpty || _store.assignments.isNotEmpty))) {
|
||||
markDirty();
|
||||
}
|
||||
_onChanged();
|
||||
}
|
||||
|
||||
@@ -193,7 +219,16 @@ class ChannelSectionsManager {
|
||||
}
|
||||
|
||||
void markDirty() {
|
||||
if (!_remoteEnabled || _disposed) return;
|
||||
if (_disposed) return;
|
||||
// Record dirtiness even while offline (remote disabled) — the flag is
|
||||
// persisted, so the reconnect-time manager re-publishes instead of
|
||||
// letting the stale remote blob clobber offline edits.
|
||||
if (_dirtySince == 0) {
|
||||
_dirtySince = currentUnixSeconds();
|
||||
_storage.writeDirtySince(pubkey, _dirtySince);
|
||||
}
|
||||
_editGeneration++;
|
||||
if (!_remoteEnabled) return;
|
||||
_publishDebounce?.cancel();
|
||||
_publishDebounce = Timer(const Duration(seconds: 5), () {
|
||||
_publishDebounce = null;
|
||||
@@ -201,8 +236,10 @@ class ChannelSectionsManager {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _fetchAndMerge() async {
|
||||
if (_relaySession == null) return;
|
||||
/// Returns whether the relay reported a `channel-sections` blob, or null
|
||||
/// when the fetch failed (offline / relay error).
|
||||
Future<bool?> _fetchAndMerge() async {
|
||||
if (_relaySession == null) return null;
|
||||
try {
|
||||
final events = await _relaySession.fetchHistory(
|
||||
NostrFilter(
|
||||
@@ -217,8 +254,12 @@ class ChannelSectionsManager {
|
||||
_mergeEvents(events);
|
||||
_persist();
|
||||
if (!_disposed) _onChanged();
|
||||
return events.any(
|
||||
(e) => e.pubkey == pubkey && e.getTagValue('d') == 'channel-sections',
|
||||
);
|
||||
} catch (_) {
|
||||
// Local state remains usable when relay is unavailable.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +310,13 @@ class ChannelSectionsManager {
|
||||
if (isNewer) {
|
||||
_lastRemoteCreatedAt = event.createdAt;
|
||||
_lastRemoteEventId = event.id;
|
||||
// Dirty-state protection: never let a remote blob overwrite
|
||||
// unpublished local edits. Whole-blob LWW would otherwise adopt a
|
||||
// stale remote store fetched right after a teardown/rebuild and
|
||||
// silently drop the just-made edit. We still advance
|
||||
// _lastRemoteCreatedAt above so our eventual publish sorts after the
|
||||
// remote event; the pending publish then reconciles the relay.
|
||||
if (_dirtySince > 0) return;
|
||||
_store = incoming;
|
||||
_persist();
|
||||
}
|
||||
@@ -311,11 +359,18 @@ class ChannelSectionsManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read-before-write: merge remote state before publishing
|
||||
final generationAtStart = _editGeneration;
|
||||
|
||||
// Read-before-write: advance _lastRemoteCreatedAt past any remote blob so
|
||||
// our event sorts after it. Dirty-state protection in _mergeEvent keeps
|
||||
// the fetched blob from clobbering the unpublished local store.
|
||||
await _fetchAndMerge();
|
||||
|
||||
// No-op suppression: skip if nothing changed
|
||||
if (_isIdenticalToLastPublished()) return;
|
||||
if (_isIdenticalToLastPublished()) {
|
||||
_clearDirty(generationAtStart);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final payload = jsonEncode(_store.toJson());
|
||||
@@ -337,11 +392,22 @@ class ChannelSectionsManager {
|
||||
sections: List.of(_store.sections),
|
||||
assignments: Map.of(_store.assignments),
|
||||
);
|
||||
_clearDirty(generationAtStart);
|
||||
} catch (error) {
|
||||
debugPrint('[ChannelSectionsManager] publish failed: $error');
|
||||
// Dirty flag stays set; the next initialize() re-schedules the publish.
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears the persisted dirty flag unless a new edit landed while the
|
||||
/// publish that succeeded was in flight.
|
||||
void _clearDirty(int generationAtStart) {
|
||||
if (_editGeneration != generationAtStart) return;
|
||||
if (_dirtySince == 0) return;
|
||||
_dirtySince = 0;
|
||||
_storage.writeDirtySince(pubkey, 0);
|
||||
}
|
||||
|
||||
void _persist() {
|
||||
_storage.write(pubkey, _store);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
String channelSectionsKey(String pubkey) => 'buzz.channel-sections.v1:$pubkey';
|
||||
|
||||
String channelSectionsDirtySinceKey(String pubkey) =>
|
||||
'buzz.channel-sections.dirty-since.v1:$pubkey';
|
||||
|
||||
class ChannelSection {
|
||||
final String id;
|
||||
final String name;
|
||||
@@ -113,4 +116,18 @@ class ChannelSectionsStorage {
|
||||
void write(String pubkey, ChannelSectionStore store) {
|
||||
_prefs.setString(channelSectionsKey(pubkey), jsonEncode(store.toJson()));
|
||||
}
|
||||
|
||||
/// Unix seconds of the oldest unpublished local edit, or 0 when local state
|
||||
/// has been fully published. Persisted so unpublished edits survive manager
|
||||
/// teardown (relay status flips rebuild the provider) and app restarts.
|
||||
int readDirtySince(String pubkey) =>
|
||||
_prefs.getInt(channelSectionsDirtySinceKey(pubkey)) ?? 0;
|
||||
|
||||
void writeDirtySince(String pubkey, int dirtySince) {
|
||||
if (dirtySince <= 0) {
|
||||
_prefs.remove(channelSectionsDirtySinceKey(pubkey));
|
||||
} else {
|
||||
_prefs.setInt(channelSectionsDirtySinceKey(pubkey), dirtySince);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
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_sort_storage.dart';
|
||||
|
||||
class ChannelSortCrypto {
|
||||
final Uint8List _conversationKey;
|
||||
|
||||
ChannelSortCrypto(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);
|
||||
}
|
||||
|
||||
/// Syncs per-group sidebar sort preferences across clients via encrypted
|
||||
/// NIP-78 app data (kind 30078, d-tag `channel-sort`), mirroring desktop's
|
||||
/// `ChannelSortSyncManager`: NIP-44 encrypted-to-self content, debounced
|
||||
/// writes, whole-blob last-write-wins, plus the same dirty-state protection
|
||||
/// as the mobile sections manager so unpublished edits survive manager
|
||||
/// teardown and reconnects.
|
||||
class ChannelSortManager {
|
||||
final String pubkey;
|
||||
final ChannelSortStorage _storage;
|
||||
final ChannelSortCrypto _crypto;
|
||||
final RelaySessionNotifier? _relaySession;
|
||||
final SignedEventRelay? _signedEventRelay;
|
||||
final bool _remoteEnabled;
|
||||
final VoidCallback _onChanged;
|
||||
|
||||
ChannelSortStore _store;
|
||||
ChannelSortStore? _lastPublishedStore;
|
||||
Timer? _publishDebounce;
|
||||
int _lastRemoteCreatedAt = 0;
|
||||
String? _lastRemoteEventId;
|
||||
void Function()? _unsubscribe;
|
||||
bool _disposed = false;
|
||||
|
||||
/// Unix seconds of the oldest unpublished local edit; 0 when clean.
|
||||
/// Persisted so unpublished edits survive manager teardown and restarts.
|
||||
int _dirtySince;
|
||||
|
||||
/// Bumped on every local edit so a publish only clears the dirty flag when
|
||||
/// no new edit landed while the publish was in flight.
|
||||
int _editGeneration = 0;
|
||||
|
||||
ChannelSortManager({
|
||||
required this.pubkey,
|
||||
required SharedPreferences prefs,
|
||||
required ChannelSortCrypto crypto,
|
||||
required RelaySessionNotifier? relaySession,
|
||||
required SignedEventRelay? signedEventRelay,
|
||||
required bool remoteEnabled,
|
||||
required VoidCallback onChanged,
|
||||
}) : _storage = ChannelSortStorage(prefs),
|
||||
_crypto = crypto,
|
||||
_relaySession = relaySession,
|
||||
_signedEventRelay = signedEventRelay,
|
||||
_remoteEnabled = remoteEnabled,
|
||||
_onChanged = onChanged,
|
||||
_store = ChannelSortStorage(prefs).read(pubkey),
|
||||
_dirtySince = ChannelSortStorage(prefs).readDirtySince(pubkey);
|
||||
|
||||
ChannelSortStore get store => _store;
|
||||
|
||||
@visibleForTesting
|
||||
bool get isDirty => _dirtySince > 0;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_disposed) return;
|
||||
|
||||
if (!_remoteEnabled || _relaySession == null) {
|
||||
_onChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
final foundRemote = await _fetchAndMerge();
|
||||
await _startLiveSubscription();
|
||||
|
||||
// Publish-on-reconnect for unpublished local edits, and seed-publish when
|
||||
// the relay confirmed it has no blob but local prefs exist. `foundRemote`
|
||||
// is null when the fetch failed — never seed-publish on a failed fetch.
|
||||
if (_dirtySince > 0 || (foundRemote == false && _store.groups.isNotEmpty)) {
|
||||
markDirty();
|
||||
}
|
||||
_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;
|
||||
}
|
||||
|
||||
void setSortModeFor(
|
||||
String groupKey,
|
||||
ChannelSortMode mode, {
|
||||
Iterable<String>? liveSectionIds,
|
||||
}) {
|
||||
if (_disposed) return;
|
||||
final updated = ChannelSortStore(
|
||||
groups: {..._store.groups, groupKey: mode},
|
||||
);
|
||||
// Prune sort modes left behind by deleted custom sections on write so the
|
||||
// stored map can't grow unboundedly with stale `section:` keys.
|
||||
_store = liveSectionIds != null
|
||||
? stripOrphanedSectionModes(updated, liveSectionIds)
|
||||
: updated;
|
||||
_persist();
|
||||
markDirty();
|
||||
}
|
||||
|
||||
ChannelSortMode sortModeFor(String groupKey) =>
|
||||
_store.groups[groupKey] ?? kDefaultSortMode;
|
||||
|
||||
void markDirty() {
|
||||
if (_disposed) return;
|
||||
// Record dirtiness even while offline (remote disabled) — the flag is
|
||||
// persisted, so the reconnect-time manager re-publishes instead of
|
||||
// letting the stale remote blob clobber offline edits.
|
||||
if (_dirtySince == 0) {
|
||||
_dirtySince = currentUnixSeconds();
|
||||
_storage.writeDirtySince(pubkey, _dirtySince);
|
||||
}
|
||||
_editGeneration++;
|
||||
if (!_remoteEnabled) return;
|
||||
_publishDebounce?.cancel();
|
||||
_publishDebounce = Timer(const Duration(seconds: 5), () {
|
||||
_publishDebounce = null;
|
||||
unawaited(_publish());
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns whether the relay reported a `channel-sort` blob, or null when
|
||||
/// the fetch failed (offline / relay error).
|
||||
Future<bool?> _fetchAndMerge() async {
|
||||
if (_relaySession == null) return null;
|
||||
try {
|
||||
final events = await _relaySession.fetchHistory(
|
||||
NostrFilter(
|
||||
kinds: const [EventKind.readState],
|
||||
authors: [pubkey],
|
||||
tags: const {
|
||||
'#d': ['channel-sort'],
|
||||
},
|
||||
limit: 1,
|
||||
),
|
||||
);
|
||||
_mergeEvents(events);
|
||||
_persist();
|
||||
if (!_disposed) _onChanged();
|
||||
return events.any(
|
||||
(e) => e.pubkey == pubkey && e.getTagValue('d') == 'channel-sort',
|
||||
);
|
||||
} catch (_) {
|
||||
// Local state remains usable when relay is unavailable.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startLiveSubscription() async {
|
||||
if (_relaySession == null) return;
|
||||
try {
|
||||
_unsubscribe = await _relaySession.subscribe(
|
||||
NostrFilter(
|
||||
kinds: const [EventKind.readState],
|
||||
authors: [pubkey],
|
||||
tags: const {
|
||||
'#d': ['channel-sort'],
|
||||
},
|
||||
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-sort d-tag events.
|
||||
final dTag = event.getTagValue('d');
|
||||
if (dTag != 'channel-sort') return;
|
||||
|
||||
try {
|
||||
final plaintext = _crypto.decrypt(event.content);
|
||||
final parsed = jsonDecode(plaintext);
|
||||
if (parsed is! Map<String, dynamic>) return;
|
||||
|
||||
final incoming = ChannelSortStore.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;
|
||||
// Dirty-state protection: never let a remote blob overwrite
|
||||
// unpublished local edits; the pending publish reconciles the relay.
|
||||
if (_dirtySince > 0) return;
|
||||
_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.groups.length != _store.groups.length) return false;
|
||||
for (final key in _store.groups.keys) {
|
||||
if (last.groups[key] != _store.groups[key]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _publish({bool allowDisposed = false}) async {
|
||||
if ((!allowDisposed && _disposed) ||
|
||||
!_remoteEnabled ||
|
||||
_signedEventRelay == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final generationAtStart = _editGeneration;
|
||||
|
||||
// Read-before-write: advance _lastRemoteCreatedAt past any remote blob so
|
||||
// our event sorts after it. Dirty-state protection in _mergeEvent keeps
|
||||
// the fetched blob from clobbering the unpublished local store.
|
||||
await _fetchAndMerge();
|
||||
|
||||
// No-op suppression: skip if nothing changed
|
||||
if (_isIdenticalToLastPublished()) {
|
||||
_clearDirty(generationAtStart);
|
||||
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-sort'],
|
||||
['t', 'channel-sort'],
|
||||
],
|
||||
createdAt: createdAt,
|
||||
);
|
||||
|
||||
_lastRemoteCreatedAt = max(_lastRemoteCreatedAt, createdAt);
|
||||
_lastPublishedStore = ChannelSortStore(groups: Map.of(_store.groups));
|
||||
_clearDirty(generationAtStart);
|
||||
} catch (error) {
|
||||
debugPrint('[ChannelSortManager] publish failed: $error');
|
||||
// Dirty flag stays set; the next initialize() re-schedules the publish.
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears the persisted dirty flag unless a new edit landed while the
|
||||
/// publish that succeeded was in flight.
|
||||
void _clearDirty(int generationAtStart) {
|
||||
if (_editGeneration != generationAtStart) return;
|
||||
if (_dirtySince == 0) return;
|
||||
_dirtySince = 0;
|
||||
_storage.writeDirtySince(pubkey, 0);
|
||||
}
|
||||
|
||||
void _persist() {
|
||||
_storage.write(pubkey, _store);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
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/community/community_provider.dart';
|
||||
import 'channel_sort_manager.dart';
|
||||
import 'channel_sort_storage.dart';
|
||||
|
||||
class ChannelSortState {
|
||||
final bool isReady;
|
||||
final ChannelSortStore store;
|
||||
|
||||
/// Bumped on every change to force downstream rebuilds.
|
||||
final int version;
|
||||
|
||||
const ChannelSortState({
|
||||
this.isReady = false,
|
||||
this.store = const ChannelSortStore(),
|
||||
this.version = 0,
|
||||
});
|
||||
|
||||
ChannelSortMode sortModeFor(String groupKey) =>
|
||||
store.groups[groupKey] ?? kDefaultSortMode;
|
||||
}
|
||||
|
||||
class ChannelSortNotifier extends Notifier<ChannelSortState> {
|
||||
ChannelSortManager? _manager;
|
||||
|
||||
@override
|
||||
ChannelSortState build() {
|
||||
_manager?.dispose(flushPending: false);
|
||||
_manager = null;
|
||||
|
||||
final relayConfig = ref.watch(relayConfigProvider);
|
||||
final sessionState = ref.watch(relaySessionProvider);
|
||||
// Rebuild when the active community changes (pubkey may differ).
|
||||
ref.watch(activeCommunityProvider);
|
||||
|
||||
final nsec = relayConfig.nsec?.trim();
|
||||
if (nsec == null || nsec.isEmpty) {
|
||||
return const ChannelSortState();
|
||||
}
|
||||
|
||||
final pubkey = _safePubkeyFromNsec(nsec);
|
||||
if (pubkey == null || pubkey.isEmpty) {
|
||||
return const ChannelSortState();
|
||||
}
|
||||
|
||||
final ChannelSortCrypto crypto;
|
||||
try {
|
||||
crypto = ChannelSortCrypto(nsec, pubkey);
|
||||
} catch (_) {
|
||||
return const ChannelSortState();
|
||||
}
|
||||
|
||||
final prefs = ref.read(savedPrefsProvider);
|
||||
final signedRelay = SignedEventRelay(
|
||||
session: ref.read(relaySessionProvider.notifier),
|
||||
nsec: nsec,
|
||||
);
|
||||
|
||||
late final ChannelSortManager manager;
|
||||
manager = ChannelSortManager(
|
||||
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 ChannelSortState(isReady: false, store: manager.store, version: 1);
|
||||
}
|
||||
|
||||
void setSortModeFor(
|
||||
String groupKey,
|
||||
ChannelSortMode mode, {
|
||||
Iterable<String>? liveSectionIds,
|
||||
}) =>
|
||||
_manager?.setSortModeFor(groupKey, mode, liveSectionIds: liveSectionIds);
|
||||
|
||||
void _emitManagerState(ChannelSortManager manager) {
|
||||
if (_manager != manager) return;
|
||||
state = ChannelSortState(
|
||||
isReady: true,
|
||||
store: manager.store,
|
||||
version: state.version + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final channelSortProvider =
|
||||
NotifierProvider<ChannelSortNotifier, ChannelSortState>(
|
||||
ChannelSortNotifier.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,156 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../channel.dart';
|
||||
|
||||
String channelSortKey(String pubkey) => 'buzz.channel-sort.v1:$pubkey';
|
||||
|
||||
String channelSortDirtySinceKey(String pubkey) =>
|
||||
'buzz.channel-sort.dirty-since.v1:$pubkey';
|
||||
|
||||
/// Per-group sidebar sort mode. Matches desktop's `ChannelSortMode` — the
|
||||
/// payload strings ('alpha' | 'recent') are shared relay format.
|
||||
enum ChannelSortMode {
|
||||
alpha('alpha'),
|
||||
recent('recent');
|
||||
|
||||
final String wireValue;
|
||||
|
||||
const ChannelSortMode(this.wireValue);
|
||||
|
||||
static ChannelSortMode? fromWire(Object? value) {
|
||||
if (value == 'alpha') return ChannelSortMode.alpha;
|
||||
if (value == 'recent') return ChannelSortMode.recent;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const ChannelSortMode kDefaultSortMode = ChannelSortMode.alpha;
|
||||
|
||||
/// Group key for a custom section, matching desktop's `section:<id>` format.
|
||||
String sectionSortGroupKey(String sectionId) => 'section:$sectionId';
|
||||
|
||||
class ChannelSortStore {
|
||||
final int version;
|
||||
|
||||
/// Group key → sort mode. Fixed groups use their name ('starred',
|
||||
/// 'channels', 'forums', 'dms'); custom sections use `section:<id>`.
|
||||
final Map<String, ChannelSortMode> groups;
|
||||
|
||||
const ChannelSortStore({this.version = 1, this.groups = const {}});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'version': version,
|
||||
'groups': {
|
||||
for (final entry in groups.entries) entry.key: entry.value.wireValue,
|
||||
},
|
||||
};
|
||||
|
||||
factory ChannelSortStore.fromJson(Map<String, dynamic> json) {
|
||||
final rawGroups = json['groups'];
|
||||
final groups = <String, ChannelSortMode>{};
|
||||
if (rawGroups is Map) {
|
||||
for (final entry in rawGroups.entries) {
|
||||
final key = entry.key;
|
||||
final mode = ChannelSortMode.fromWire(entry.value);
|
||||
if (key is String && mode != null) {
|
||||
groups[key] = mode;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ChannelSortStore(version: 1, groups: groups);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops per-section sort modes whose custom section no longer exists so
|
||||
/// deleted sections don't leave stale `section:<id>` keys behind. Fixed group
|
||||
/// keys are always kept. Returns the same store when nothing needs stripping.
|
||||
ChannelSortStore stripOrphanedSectionModes(
|
||||
ChannelSortStore store,
|
||||
Iterable<String> liveSectionIds,
|
||||
) {
|
||||
final liveKeys = {for (final id in liveSectionIds) sectionSortGroupKey(id)};
|
||||
final kept = <String, ChannelSortMode>{
|
||||
for (final entry in store.groups.entries)
|
||||
if (!entry.key.startsWith('section:') || liveKeys.contains(entry.key))
|
||||
entry.key: entry.value,
|
||||
};
|
||||
if (kept.length == store.groups.length) return store;
|
||||
return ChannelSortStore(version: store.version, groups: kept);
|
||||
}
|
||||
|
||||
/// Sorts one sidebar grouping's channels by the selected mode, mirroring
|
||||
/// desktop's `sortChannelsForSidebar`: `alpha` orders by name (id
|
||||
/// tie-breaker); `recent` orders by last message time, newest first, with
|
||||
/// message-less channels sinking to the bottom alphabetically.
|
||||
List<Channel> sortChannelsForList(
|
||||
List<Channel> channels,
|
||||
ChannelSortMode mode,
|
||||
) {
|
||||
int byName(Channel left, Channel right) {
|
||||
final name = left.name.toLowerCase().compareTo(right.name.toLowerCase());
|
||||
if (name != 0) return name;
|
||||
return left.id.compareTo(right.id);
|
||||
}
|
||||
|
||||
final sorted = channels.toList();
|
||||
if (mode == ChannelSortMode.alpha) {
|
||||
sorted.sort(byName);
|
||||
return sorted;
|
||||
}
|
||||
sorted.sort((left, right) {
|
||||
final leftMs = left.lastMessageAt?.millisecondsSinceEpoch;
|
||||
final rightMs = right.lastMessageAt?.millisecondsSinceEpoch;
|
||||
if (leftMs != null && rightMs != null && leftMs != rightMs) {
|
||||
return rightMs.compareTo(leftMs);
|
||||
}
|
||||
if (leftMs != null && rightMs == null) return -1;
|
||||
if (leftMs == null && rightMs != null) return 1;
|
||||
return byName(left, right);
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
class ChannelSortStorage {
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
ChannelSortStorage(this._prefs);
|
||||
|
||||
ChannelSortStore read(String pubkey) {
|
||||
final raw = _prefs.getString(channelSortKey(pubkey));
|
||||
if (raw == null || raw.isEmpty) {
|
||||
return const ChannelSortStore();
|
||||
}
|
||||
|
||||
try {
|
||||
final parsed = jsonDecode(raw);
|
||||
if (parsed is! Map<String, dynamic>) {
|
||||
return const ChannelSortStore();
|
||||
}
|
||||
if (parsed['version'] != 1) {
|
||||
return const ChannelSortStore();
|
||||
}
|
||||
return ChannelSortStore.fromJson(parsed);
|
||||
} catch (_) {
|
||||
return const ChannelSortStore();
|
||||
}
|
||||
}
|
||||
|
||||
void write(String pubkey, ChannelSortStore store) {
|
||||
_prefs.setString(channelSortKey(pubkey), jsonEncode(store.toJson()));
|
||||
}
|
||||
|
||||
/// Unix seconds of the oldest unpublished local edit, or 0 when clean.
|
||||
/// Persisted so unpublished edits survive manager teardown and restarts.
|
||||
int readDirtySince(String pubkey) =>
|
||||
_prefs.getInt(channelSortDirtySinceKey(pubkey)) ?? 0;
|
||||
|
||||
void writeDirtySince(String pubkey, int dirtySince) {
|
||||
if (dirtySince <= 0) {
|
||||
_prefs.remove(channelSortDirtySinceKey(pubkey));
|
||||
} else {
|
||||
_prefs.setInt(channelSortDirtySinceKey(pubkey), dirtySince);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ import 'ephemeral_channel_display.dart';
|
||||
import 'channel_mutes/channel_mutes_provider.dart';
|
||||
import 'channel_sections/channel_sections_provider.dart';
|
||||
import 'channel_sections/channel_sections_storage.dart';
|
||||
import 'channel_sort/channel_sort_provider.dart';
|
||||
import 'channel_sort/channel_sort_storage.dart';
|
||||
import 'channel_stars/channel_stars_provider.dart';
|
||||
import 'channels_provider.dart';
|
||||
import 'read_state/deferred_read_state_update.dart';
|
||||
|
||||
@@ -120,6 +120,7 @@ class _SliverChannelsList extends HookConsumerWidget {
|
||||
final starredExpanded = useState(true);
|
||||
final channelsExpanded = useState(true);
|
||||
final dmsExpanded = useState(true);
|
||||
final sortState = ref.watch(channelSortProvider);
|
||||
final initialSeedComplete = useState(false);
|
||||
final seededPubkey = useRef<String?>(null);
|
||||
final seedCompleteForPubkey =
|
||||
@@ -184,16 +185,33 @@ class _SliverChannelsList extends HookConsumerWidget {
|
||||
};
|
||||
// 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) &&
|
||||
!starredChannelIds.contains(c.id),
|
||||
)
|
||||
.toList();
|
||||
final starredStreamChannels = sortChannelsForList(
|
||||
streamChannels.where((c) => starredChannelIds.contains(c.id)).toList(),
|
||||
sortState.sortModeFor('starred'),
|
||||
);
|
||||
final ungroupedStreamChannels = sortChannelsForList(
|
||||
streamChannels
|
||||
.where(
|
||||
(c) =>
|
||||
!assignedChannelIds.contains(c.id) &&
|
||||
!starredChannelIds.contains(c.id),
|
||||
)
|
||||
.toList(),
|
||||
sortState.sortModeFor('channels'),
|
||||
);
|
||||
// DMs default to the display-label alphabetical order (labels can differ
|
||||
// from channel names); Recent mode reorders by last message time.
|
||||
final sortedDmChannels =
|
||||
sortState.sortModeFor('dms') == ChannelSortMode.recent
|
||||
? sortChannelsForList(dmChannels, ChannelSortMode.recent)
|
||||
: dmChannels;
|
||||
|
||||
final liveSectionIds = [for (final s in userSections) s.id];
|
||||
void setSortMode(String groupKey, ChannelSortMode mode) {
|
||||
ref
|
||||
.read(channelSortProvider.notifier)
|
||||
.setSortModeFor(groupKey, mode, liveSectionIds: liveSectionIds);
|
||||
}
|
||||
|
||||
final sectionExpandedStates = useState<Map<String, bool>>({});
|
||||
|
||||
@@ -228,19 +246,24 @@ class _SliverChannelsList extends HookConsumerWidget {
|
||||
mutedChannelIds: mutedChannelIds,
|
||||
currentPubkey: currentPubkey,
|
||||
emptyLabel: '',
|
||||
sortMode: sortState.sortModeFor('starred'),
|
||||
onSortModeChange: (mode) => setSortMode('starred', mode),
|
||||
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 &&
|
||||
!starredChannelIds.contains(c.id),
|
||||
)
|
||||
.toList(),
|
||||
channels: sortChannelsForList(
|
||||
streamChannels
|
||||
.where(
|
||||
(c) =>
|
||||
sectionAssignments[c.id] == section.id &&
|
||||
!starredChannelIds.contains(c.id),
|
||||
)
|
||||
.toList(),
|
||||
sortState.sortModeFor(sectionSortGroupKey(section.id)),
|
||||
),
|
||||
unreadChannelIds: unreadChannelIds,
|
||||
unreadChannelCounts: unreadChannelCounts,
|
||||
mutedChannelIds: mutedChannelIds,
|
||||
@@ -302,6 +325,11 @@ class _SliverChannelsList extends HookConsumerWidget {
|
||||
onMoveDown: () => ref
|
||||
.read(channelSectionsProvider.notifier)
|
||||
.moveSectionDown(section.id),
|
||||
sortMode: sortState.sortModeFor(
|
||||
sectionSortGroupKey(section.id),
|
||||
),
|
||||
onSortModeChange: (mode) =>
|
||||
setSortMode(sectionSortGroupKey(section.id), mode),
|
||||
onSelectChannel: onSelectChannel,
|
||||
onMarkChannelRead: (channel) {
|
||||
final ts = dateTimeToUnixSeconds(channel.lastMessageAt);
|
||||
@@ -328,6 +356,8 @@ class _SliverChannelsList extends HookConsumerWidget {
|
||||
mutedChannelIds: mutedChannelIds,
|
||||
currentPubkey: currentPubkey,
|
||||
emptyLabel: 'No stream channels yet',
|
||||
sortMode: sortState.sortModeFor('channels'),
|
||||
onSortModeChange: (mode) => setSortMode('channels', mode),
|
||||
onSelectChannel: onSelectChannel,
|
||||
),
|
||||
_ChannelSection(
|
||||
@@ -336,12 +366,14 @@ class _SliverChannelsList extends HookConsumerWidget {
|
||||
showTopDivider: true,
|
||||
expanded: dmsExpanded.value,
|
||||
onToggle: () => dmsExpanded.value = !dmsExpanded.value,
|
||||
channels: dmChannels,
|
||||
channels: sortedDmChannels,
|
||||
unreadChannelIds: unreadChannelIds,
|
||||
unreadChannelCounts: unreadChannelCounts,
|
||||
mutedChannelIds: mutedChannelIds,
|
||||
currentPubkey: currentPubkey,
|
||||
emptyLabel: 'No direct messages yet',
|
||||
sortMode: sortState.sortModeFor('dms'),
|
||||
onSortModeChange: (mode) => setSortMode('dms', mode),
|
||||
onSelectChannel: onSelectChannel,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -16,6 +16,8 @@ class _CustomChannelSection extends StatelessWidget {
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onMoveUp;
|
||||
final VoidCallback onMoveDown;
|
||||
final ChannelSortMode sortMode;
|
||||
final void Function(ChannelSortMode mode) onSortModeChange;
|
||||
final Future<void> Function(Channel channel) onSelectChannel;
|
||||
final void Function(Channel channel) onMarkChannelRead;
|
||||
|
||||
@@ -35,6 +37,8 @@ class _CustomChannelSection extends StatelessWidget {
|
||||
required this.onDelete,
|
||||
required this.onMoveUp,
|
||||
required this.onMoveDown,
|
||||
required this.sortMode,
|
||||
required this.onSortModeChange,
|
||||
required this.onSelectChannel,
|
||||
required this.onMarkChannelRead,
|
||||
});
|
||||
@@ -55,6 +59,8 @@ class _CustomChannelSection extends StatelessWidget {
|
||||
onDelete: onDelete,
|
||||
onMoveUp: onMoveUp,
|
||||
onMoveDown: onMoveDown,
|
||||
sortMode: sortMode,
|
||||
onSortModeChange: onSortModeChange,
|
||||
),
|
||||
_AnimatedSectionBody(
|
||||
expanded: expanded,
|
||||
@@ -90,6 +96,8 @@ class _CustomSectionHeader extends ConsumerWidget {
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onMoveUp;
|
||||
final VoidCallback onMoveDown;
|
||||
final ChannelSortMode sortMode;
|
||||
final void Function(ChannelSortMode mode) onSortModeChange;
|
||||
|
||||
const _CustomSectionHeader({
|
||||
required this.section,
|
||||
@@ -101,6 +109,8 @@ class _CustomSectionHeader extends ConsumerWidget {
|
||||
required this.onDelete,
|
||||
required this.onMoveUp,
|
||||
required this.onMoveDown,
|
||||
required this.sortMode,
|
||||
required this.onSortModeChange,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -184,6 +194,7 @@ class _CustomSectionHeader extends ConsumerWidget {
|
||||
enabled: !isLast,
|
||||
child: const Text('Move Down'),
|
||||
),
|
||||
..._sortMenuItems(sortMode),
|
||||
const PopupMenuItem(value: 'delete', child: Text('Delete')),
|
||||
],
|
||||
);
|
||||
@@ -194,6 +205,10 @@ class _CustomSectionHeader extends ConsumerWidget {
|
||||
onMoveUp();
|
||||
case 'move_down':
|
||||
onMoveDown();
|
||||
case _kSortRecentMenuValue:
|
||||
onSortModeChange(ChannelSortMode.recent);
|
||||
case _kSortAlphaMenuValue:
|
||||
onSortModeChange(ChannelSortMode.alpha);
|
||||
case 'delete':
|
||||
onDelete();
|
||||
}
|
||||
@@ -223,6 +238,25 @@ CustomEmoji? _resolveCustomEmoji(String icon, List<CustomEmoji> palette) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const String _kSortRecentMenuValue = 'sort_recent';
|
||||
const String _kSortAlphaMenuValue = 'sort_alpha';
|
||||
|
||||
/// Sort radio items shared by the fixed-group and custom-section menus,
|
||||
/// mirroring desktop's Sort → Recent / A–Z options.
|
||||
List<PopupMenuEntry<String>> _sortMenuItems(ChannelSortMode current) => [
|
||||
const PopupMenuDivider(),
|
||||
CheckedPopupMenuItem(
|
||||
value: _kSortRecentMenuValue,
|
||||
checked: current == ChannelSortMode.recent,
|
||||
child: const Text('Sort: Recent'),
|
||||
),
|
||||
CheckedPopupMenuItem(
|
||||
value: _kSortAlphaMenuValue,
|
||||
checked: current == ChannelSortMode.alpha,
|
||||
child: const Text('Sort: A–Z'),
|
||||
),
|
||||
];
|
||||
|
||||
class _SectionNameDialog extends HookWidget {
|
||||
final String title;
|
||||
final String confirmLabel;
|
||||
@@ -274,6 +308,8 @@ class _ChannelSection extends StatelessWidget {
|
||||
final Set<String> mutedChannelIds;
|
||||
final String? currentPubkey;
|
||||
final String emptyLabel;
|
||||
final ChannelSortMode? sortMode;
|
||||
final void Function(ChannelSortMode mode)? onSortModeChange;
|
||||
final Future<void> Function(Channel channel) onSelectChannel;
|
||||
|
||||
const _ChannelSection({
|
||||
@@ -288,6 +324,8 @@ class _ChannelSection extends StatelessWidget {
|
||||
required this.mutedChannelIds,
|
||||
required this.currentPubkey,
|
||||
required this.emptyLabel,
|
||||
this.sortMode,
|
||||
this.onSortModeChange,
|
||||
required this.onSelectChannel,
|
||||
});
|
||||
|
||||
@@ -302,6 +340,8 @@ class _ChannelSection extends StatelessWidget {
|
||||
icon: icon,
|
||||
expanded: expanded,
|
||||
onToggle: onToggle,
|
||||
sortMode: sortMode,
|
||||
onSortModeChange: onSortModeChange,
|
||||
),
|
||||
_AnimatedSectionBody(
|
||||
expanded: expanded,
|
||||
@@ -396,17 +436,22 @@ class _SectionHeader extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final bool expanded;
|
||||
final VoidCallback onToggle;
|
||||
final ChannelSortMode? sortMode;
|
||||
final void Function(ChannelSortMode mode)? onSortModeChange;
|
||||
|
||||
const _SectionHeader({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.expanded,
|
||||
required this.onToggle,
|
||||
this.sortMode,
|
||||
this.onSortModeChange,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sectionColor = context.colors.primary;
|
||||
final showSortMenu = sortMode != null && onSortModeChange != null;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onToggle,
|
||||
@@ -436,6 +481,38 @@ class _SectionHeader extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (showSortMenu) ...[
|
||||
GestureDetector(
|
||||
onTapUp: (details) async {
|
||||
final overlay =
|
||||
Overlay.of(context).context.findRenderObject()!
|
||||
as RenderBox;
|
||||
final position = RelativeRect.fromRect(
|
||||
details.globalPosition & Size.zero,
|
||||
Offset.zero & overlay.size,
|
||||
);
|
||||
final value = await showMenu<String>(
|
||||
context: context,
|
||||
position: position,
|
||||
// _sortMenuItems leads with a divider for the mixed
|
||||
// custom-section menu; skip it here.
|
||||
items: _sortMenuItems(sortMode!).skip(1).toList(),
|
||||
);
|
||||
switch (value) {
|
||||
case _kSortRecentMenuValue:
|
||||
onSortModeChange!(ChannelSortMode.recent);
|
||||
case _kSortAlphaMenuValue:
|
||||
onSortModeChange!(ChannelSortMode.alpha);
|
||||
}
|
||||
},
|
||||
child: Icon(
|
||||
LucideIcons.ellipsisVertical,
|
||||
size: _kChannelIconSize,
|
||||
color: sectionColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.quarter),
|
||||
],
|
||||
_SectionChevron(expanded: expanded, color: sectionColor),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:buzz/features/channels/channel_sections/channel_sections_manager.dart';
|
||||
import 'package:buzz/features/channels/channel_sections/channel_sections_storage.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
|
||||
void main() {
|
||||
late SharedPreferences prefs;
|
||||
late nostr.Keys keychain;
|
||||
late ChannelSectionsCrypto crypto;
|
||||
|
||||
Future<void> setUpEnv() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
prefs = await SharedPreferences.getInstance();
|
||||
keychain = nostr.Keys.generate();
|
||||
crypto = ChannelSectionsCrypto(keychain.nsec, keychain.public);
|
||||
}
|
||||
|
||||
NostrEvent sectionsEvent({
|
||||
required List<Map<String, dynamic>> sections,
|
||||
Map<String, String> assignments = const {},
|
||||
required int createdAt,
|
||||
String id = 'remote-event',
|
||||
}) {
|
||||
final payload = jsonEncode({
|
||||
'version': 1,
|
||||
'sections': sections,
|
||||
'assignments': assignments,
|
||||
});
|
||||
return NostrEvent(
|
||||
id: id,
|
||||
pubkey: keychain.public,
|
||||
createdAt: createdAt,
|
||||
kind: EventKind.readState,
|
||||
tags: const [
|
||||
['d', 'channel-sections'],
|
||||
['t', 'channel-sections'],
|
||||
],
|
||||
content: crypto.encrypt(payload),
|
||||
sig: 'sig',
|
||||
);
|
||||
}
|
||||
|
||||
ChannelSectionsManager buildManager({
|
||||
RelaySessionNotifier? relaySession,
|
||||
SignedEventRelay? signedEventRelay,
|
||||
bool remoteEnabled = true,
|
||||
}) {
|
||||
return ChannelSectionsManager(
|
||||
pubkey: keychain.public,
|
||||
prefs: prefs,
|
||||
crypto: crypto,
|
||||
relaySession: relaySession,
|
||||
signedEventRelay: signedEventRelay,
|
||||
remoteEnabled: remoteEnabled,
|
||||
onChanged: () {},
|
||||
);
|
||||
}
|
||||
|
||||
test('stale remote blob does not clobber unpublished local edits '
|
||||
'(teardown/rebuild regression)', () async {
|
||||
await setUpEnv();
|
||||
final relay = _FakeRelaySession();
|
||||
|
||||
// First manager: user creates a section; the 5s debounce never fires
|
||||
// because the provider tears the manager down on a status flip.
|
||||
final first = buildManager(
|
||||
relaySession: relay,
|
||||
signedEventRelay: _RecordingSignedEventRelay(),
|
||||
);
|
||||
await first.initialize();
|
||||
first.createSection('My Group');
|
||||
expect(first.store.sections, hasLength(1));
|
||||
first.dispose(flushPending: false);
|
||||
|
||||
// The relay still has an older (empty) blob. The rebuilt manager
|
||||
// fetches it on initialize — pre-fix this adopted the stale blob and
|
||||
// the just-created group vanished.
|
||||
relay.historyEvents = [sectionsEvent(sections: const [], createdAt: 100)];
|
||||
final second = buildManager(
|
||||
relaySession: relay,
|
||||
signedEventRelay: _RecordingSignedEventRelay(),
|
||||
);
|
||||
expect(second.isDirty, isTrue, reason: 'dirty flag must survive rebuild');
|
||||
await second.initialize();
|
||||
|
||||
expect(
|
||||
second.store.sections.map((s) => s.name),
|
||||
contains('My Group'),
|
||||
reason: 'unpublished local edit must survive a stale remote fetch',
|
||||
);
|
||||
second.dispose(flushPending: false);
|
||||
});
|
||||
|
||||
test('rebuilt manager re-schedules publish for unpublished edits', () async {
|
||||
await setUpEnv();
|
||||
final relay = _FakeRelaySession();
|
||||
|
||||
final first = buildManager(
|
||||
relaySession: relay,
|
||||
signedEventRelay: _RecordingSignedEventRelay(),
|
||||
);
|
||||
await first.initialize();
|
||||
first.createSection('My Group');
|
||||
first.dispose(flushPending: false);
|
||||
|
||||
final signedRelay = _RecordingSignedEventRelay();
|
||||
final second = buildManager(
|
||||
relaySession: relay,
|
||||
signedEventRelay: signedRelay,
|
||||
);
|
||||
await second.initialize();
|
||||
|
||||
// initialize() must arm the publish debounce for the surviving dirty
|
||||
// state; fire it via flush-on-dispose to avoid waiting 5s.
|
||||
second.dispose(flushPending: true);
|
||||
final submitted = await signedRelay.submitted.future.timeout(
|
||||
const Duration(seconds: 1),
|
||||
);
|
||||
expect(submitted.kind, EventKind.readState);
|
||||
final plaintext = crypto.decrypt(submitted.content);
|
||||
final decoded = jsonDecode(plaintext) as Map<String, dynamic>;
|
||||
expect(
|
||||
(decoded['sections'] as List).map((s) => s['name']),
|
||||
contains('My Group'),
|
||||
);
|
||||
});
|
||||
|
||||
test('successful publish clears the persisted dirty flag', () async {
|
||||
await setUpEnv();
|
||||
final relay = _FakeRelaySession();
|
||||
final signedRelay = _RecordingSignedEventRelay();
|
||||
|
||||
final manager = buildManager(
|
||||
relaySession: relay,
|
||||
signedEventRelay: signedRelay,
|
||||
);
|
||||
await manager.initialize();
|
||||
manager.createSection('My Group');
|
||||
expect(manager.isDirty, isTrue);
|
||||
manager.dispose(flushPending: true);
|
||||
await signedRelay.submitted.future.timeout(const Duration(seconds: 1));
|
||||
// Let the post-submit bookkeeping in _publish complete.
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(ChannelSectionsStorage(prefs).readDirtySince(keychain.public), 0);
|
||||
});
|
||||
|
||||
test('clean manager still adopts newer remote blobs (LWW)', () async {
|
||||
await setUpEnv();
|
||||
final relay = _FakeRelaySession();
|
||||
relay.historyEvents = [
|
||||
sectionsEvent(
|
||||
sections: [
|
||||
{'id': 's1', 'name': 'Desktop Group', 'order': 0},
|
||||
],
|
||||
createdAt: 100,
|
||||
),
|
||||
];
|
||||
|
||||
final manager = buildManager(
|
||||
relaySession: relay,
|
||||
signedEventRelay: _RecordingSignedEventRelay(),
|
||||
);
|
||||
await manager.initialize();
|
||||
|
||||
expect(manager.isDirty, isFalse);
|
||||
expect(manager.store.sections.single.name, 'Desktop Group');
|
||||
manager.dispose(flushPending: false);
|
||||
});
|
||||
|
||||
test('offline edits set the persisted dirty flag', () async {
|
||||
await setUpEnv();
|
||||
final manager = buildManager(remoteEnabled: false);
|
||||
await manager.initialize();
|
||||
manager.createSection('Offline Group');
|
||||
|
||||
expect(
|
||||
ChannelSectionsStorage(prefs).readDirtySince(keychain.public),
|
||||
greaterThan(0),
|
||||
reason: 'offline edits must be flagged for publish-on-reconnect',
|
||||
);
|
||||
manager.dispose(flushPending: false);
|
||||
});
|
||||
|
||||
test(
|
||||
'seed-publish schedules when relay confirms no blob but local exists',
|
||||
() async {
|
||||
await setUpEnv();
|
||||
// Local store exists (e.g. created offline earlier) and is clean.
|
||||
ChannelSectionsStorage(prefs).write(
|
||||
keychain.public,
|
||||
const ChannelSectionStore(
|
||||
sections: [ChannelSection(id: 's1', name: 'Local', order: 0)],
|
||||
),
|
||||
);
|
||||
|
||||
final relay = _FakeRelaySession(); // empty history: confirmed no blob
|
||||
final signedRelay = _RecordingSignedEventRelay();
|
||||
final manager = buildManager(
|
||||
relaySession: relay,
|
||||
signedEventRelay: signedRelay,
|
||||
);
|
||||
await manager.initialize();
|
||||
manager.dispose(flushPending: true);
|
||||
|
||||
final submitted = await signedRelay.submitted.future.timeout(
|
||||
const Duration(seconds: 1),
|
||||
);
|
||||
final decoded =
|
||||
jsonDecode(crypto.decrypt(submitted.content)) as Map<String, dynamic>;
|
||||
expect(
|
||||
(decoded['sections'] as List).map((s) => s['name']),
|
||||
contains('Local'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('no seed-publish when the initial fetch fails', () async {
|
||||
await setUpEnv();
|
||||
ChannelSectionsStorage(prefs).write(
|
||||
keychain.public,
|
||||
const ChannelSectionStore(
|
||||
sections: [ChannelSection(id: 's1', name: 'Local', order: 0)],
|
||||
),
|
||||
);
|
||||
|
||||
final relay = _FailingRelaySession();
|
||||
final signedRelay = _RecordingSignedEventRelay();
|
||||
final manager = buildManager(
|
||||
relaySession: relay,
|
||||
signedEventRelay: signedRelay,
|
||||
);
|
||||
await manager.initialize();
|
||||
manager.dispose(flushPending: true);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
expect(
|
||||
signedRelay.submitted.isCompleted,
|
||||
isFalse,
|
||||
reason: 'a failed fetch must never trigger a seed publish',
|
||||
);
|
||||
});
|
||||
|
||||
test('live remote event is ignored while dirty', () async {
|
||||
await setUpEnv();
|
||||
final relay = _FakeRelaySession();
|
||||
final manager = buildManager(
|
||||
relaySession: relay,
|
||||
signedEventRelay: _RecordingSignedEventRelay(),
|
||||
);
|
||||
await manager.initialize();
|
||||
manager.createSection('My Group');
|
||||
|
||||
// Simulate a live stale event arriving before the debounce fires.
|
||||
relay.emit(sectionsEvent(sections: const [], createdAt: 100));
|
||||
|
||||
expect(manager.store.sections.map((s) => s.name), contains('My Group'));
|
||||
manager.dispose(flushPending: false);
|
||||
});
|
||||
}
|
||||
|
||||
class _SubmittedEvent {
|
||||
final int kind;
|
||||
final String content;
|
||||
final List<List<String>> tags;
|
||||
|
||||
const _SubmittedEvent({
|
||||
required this.kind,
|
||||
required this.content,
|
||||
required this.tags,
|
||||
});
|
||||
}
|
||||
|
||||
NostrEvent _stubAckEvent() => const NostrEvent(
|
||||
id: 'stub',
|
||||
pubkey: '',
|
||||
createdAt: 0,
|
||||
kind: 0,
|
||||
tags: [],
|
||||
content: '',
|
||||
sig: '',
|
||||
);
|
||||
|
||||
class _RecordingSignedEventRelay implements SignedEventRelay {
|
||||
final Completer<_SubmittedEvent> submitted = Completer<_SubmittedEvent>();
|
||||
|
||||
@override
|
||||
String? get pubkey => null;
|
||||
|
||||
@override
|
||||
Future<NostrEvent> submit({
|
||||
required int kind,
|
||||
required String content,
|
||||
required List<List<String>> tags,
|
||||
int? createdAt,
|
||||
}) async {
|
||||
if (!submitted.isCompleted) {
|
||||
submitted.complete(
|
||||
_SubmittedEvent(kind: kind, content: content, tags: tags),
|
||||
);
|
||||
}
|
||||
return _stubAckEvent();
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeRelaySession extends RelaySessionNotifier {
|
||||
List<NostrEvent> historyEvents = [];
|
||||
final List<void Function(NostrEvent)> _listeners = [];
|
||||
|
||||
void emit(NostrEvent event) {
|
||||
for (final listener in List.of(_listeners)) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<NostrEvent>> fetchHistory(
|
||||
NostrFilter filter, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async => historyEvents;
|
||||
|
||||
@override
|
||||
Future<void Function()> subscribe(
|
||||
NostrFilter filter,
|
||||
void Function(NostrEvent) onEvent, {
|
||||
void Function(String message)? onClosed,
|
||||
}) async {
|
||||
_listeners.add(onEvent);
|
||||
return () => _listeners.remove(onEvent);
|
||||
}
|
||||
}
|
||||
|
||||
class _FailingRelaySession extends RelaySessionNotifier {
|
||||
@override
|
||||
Future<List<NostrEvent>> fetchHistory(
|
||||
NostrFilter filter, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async => throw Exception('relay unavailable');
|
||||
|
||||
@override
|
||||
Future<void Function()> subscribe(
|
||||
NostrFilter filter,
|
||||
void Function(NostrEvent) onEvent, {
|
||||
void Function(String message)? onClosed,
|
||||
}) async => () {};
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:buzz/features/channels/channel_sort/channel_sort_manager.dart';
|
||||
import 'package:buzz/features/channels/channel_sort/channel_sort_storage.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
|
||||
void main() {
|
||||
late SharedPreferences prefs;
|
||||
late nostr.Keys keychain;
|
||||
late ChannelSortCrypto crypto;
|
||||
|
||||
Future<void> setUpEnv() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
prefs = await SharedPreferences.getInstance();
|
||||
keychain = nostr.Keys.generate();
|
||||
crypto = ChannelSortCrypto(keychain.nsec, keychain.public);
|
||||
}
|
||||
|
||||
NostrEvent sortEvent({
|
||||
required Map<String, String> groups,
|
||||
required int createdAt,
|
||||
String id = 'remote-event',
|
||||
}) {
|
||||
final payload = jsonEncode({'version': 1, 'groups': groups});
|
||||
return NostrEvent(
|
||||
id: id,
|
||||
pubkey: keychain.public,
|
||||
createdAt: createdAt,
|
||||
kind: EventKind.readState,
|
||||
tags: const [
|
||||
['d', 'channel-sort'],
|
||||
['t', 'channel-sort'],
|
||||
],
|
||||
content: crypto.encrypt(payload),
|
||||
sig: 'sig',
|
||||
);
|
||||
}
|
||||
|
||||
ChannelSortManager buildManager({
|
||||
RelaySessionNotifier? relaySession,
|
||||
SignedEventRelay? signedEventRelay,
|
||||
bool remoteEnabled = true,
|
||||
}) {
|
||||
return ChannelSortManager(
|
||||
pubkey: keychain.public,
|
||||
prefs: prefs,
|
||||
crypto: crypto,
|
||||
relaySession: relaySession,
|
||||
signedEventRelay: signedEventRelay,
|
||||
remoteEnabled: remoteEnabled,
|
||||
onChanged: () {},
|
||||
);
|
||||
}
|
||||
|
||||
test('adopts a desktop-published channel-sort blob', () async {
|
||||
await setUpEnv();
|
||||
final relay = _FakeRelaySession();
|
||||
relay.historyEvents = [
|
||||
sortEvent(
|
||||
groups: {'channels': 'recent', 'section:abc': 'recent'},
|
||||
createdAt: 100,
|
||||
),
|
||||
];
|
||||
|
||||
final manager = buildManager(
|
||||
relaySession: relay,
|
||||
signedEventRelay: _RecordingSignedEventRelay(),
|
||||
);
|
||||
await manager.initialize();
|
||||
|
||||
expect(manager.sortModeFor('channels'), ChannelSortMode.recent);
|
||||
expect(manager.sortModeFor('section:abc'), ChannelSortMode.recent);
|
||||
expect(manager.sortModeFor('dms'), ChannelSortMode.alpha);
|
||||
manager.dispose(flushPending: false);
|
||||
});
|
||||
|
||||
test('publishes local change in the desktop wire format', () async {
|
||||
await setUpEnv();
|
||||
final relay = _FakeRelaySession();
|
||||
final signedRelay = _RecordingSignedEventRelay();
|
||||
final manager = buildManager(
|
||||
relaySession: relay,
|
||||
signedEventRelay: signedRelay,
|
||||
);
|
||||
await manager.initialize();
|
||||
|
||||
manager.setSortModeFor('dms', ChannelSortMode.recent);
|
||||
manager.dispose(flushPending: true);
|
||||
|
||||
final submitted = await signedRelay.submitted.future.timeout(
|
||||
const Duration(seconds: 1),
|
||||
);
|
||||
expect(submitted.kind, EventKind.readState);
|
||||
expect(
|
||||
submitted.tags.any(
|
||||
(tag) => tag.length == 2 && tag[0] == 'd' && tag[1] == 'channel-sort',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
final decoded =
|
||||
jsonDecode(crypto.decrypt(submitted.content)) as Map<String, dynamic>;
|
||||
expect(decoded['version'], 1);
|
||||
expect(decoded['groups'], {'dms': 'recent'});
|
||||
});
|
||||
|
||||
test(
|
||||
'stale remote blob does not clobber unpublished local sort edits',
|
||||
() async {
|
||||
await setUpEnv();
|
||||
final relay = _FakeRelaySession();
|
||||
|
||||
final first = buildManager(
|
||||
relaySession: relay,
|
||||
signedEventRelay: _RecordingSignedEventRelay(),
|
||||
);
|
||||
await first.initialize();
|
||||
first.setSortModeFor('channels', ChannelSortMode.recent);
|
||||
first.dispose(flushPending: false);
|
||||
|
||||
relay.historyEvents = [sortEvent(groups: const {}, createdAt: 100)];
|
||||
final second = buildManager(
|
||||
relaySession: relay,
|
||||
signedEventRelay: _RecordingSignedEventRelay(),
|
||||
);
|
||||
expect(second.isDirty, isTrue);
|
||||
await second.initialize();
|
||||
|
||||
expect(second.sortModeFor('channels'), ChannelSortMode.recent);
|
||||
second.dispose(flushPending: false);
|
||||
},
|
||||
);
|
||||
|
||||
test('setSortModeFor prunes orphaned section keys', () async {
|
||||
await setUpEnv();
|
||||
final manager = buildManager(remoteEnabled: false);
|
||||
await manager.initialize();
|
||||
|
||||
manager.setSortModeFor('section:dead', ChannelSortMode.recent);
|
||||
manager.setSortModeFor(
|
||||
'channels',
|
||||
ChannelSortMode.recent,
|
||||
liveSectionIds: ['live'],
|
||||
);
|
||||
|
||||
expect(manager.store.groups.keys, ['channels']);
|
||||
manager.dispose(flushPending: false);
|
||||
});
|
||||
}
|
||||
|
||||
class _SubmittedEvent {
|
||||
final int kind;
|
||||
final String content;
|
||||
final List<List<String>> tags;
|
||||
|
||||
const _SubmittedEvent({
|
||||
required this.kind,
|
||||
required this.content,
|
||||
required this.tags,
|
||||
});
|
||||
}
|
||||
|
||||
NostrEvent _stubAckEvent() => const NostrEvent(
|
||||
id: 'stub',
|
||||
pubkey: '',
|
||||
createdAt: 0,
|
||||
kind: 0,
|
||||
tags: [],
|
||||
content: '',
|
||||
sig: '',
|
||||
);
|
||||
|
||||
class _RecordingSignedEventRelay implements SignedEventRelay {
|
||||
final Completer<_SubmittedEvent> submitted = Completer<_SubmittedEvent>();
|
||||
|
||||
@override
|
||||
String? get pubkey => null;
|
||||
|
||||
@override
|
||||
Future<NostrEvent> submit({
|
||||
required int kind,
|
||||
required String content,
|
||||
required List<List<String>> tags,
|
||||
int? createdAt,
|
||||
}) async {
|
||||
if (!submitted.isCompleted) {
|
||||
submitted.complete(
|
||||
_SubmittedEvent(kind: kind, content: content, tags: tags),
|
||||
);
|
||||
}
|
||||
return _stubAckEvent();
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeRelaySession extends RelaySessionNotifier {
|
||||
List<NostrEvent> historyEvents = [];
|
||||
|
||||
@override
|
||||
Future<List<NostrEvent>> fetchHistory(
|
||||
NostrFilter filter, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async => historyEvents;
|
||||
|
||||
@override
|
||||
Future<void Function()> subscribe(
|
||||
NostrFilter filter,
|
||||
void Function(NostrEvent) onEvent, {
|
||||
void Function(String message)? onClosed,
|
||||
}) async => () {};
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:buzz/features/channels/channel.dart';
|
||||
import 'package:buzz/features/channels/channel_sort/channel_sort_storage.dart';
|
||||
|
||||
void main() {
|
||||
group('ChannelSortStore JSON', () {
|
||||
test('round-trips the desktop wire format', () {
|
||||
final store = ChannelSortStore(
|
||||
groups: {
|
||||
'channels': ChannelSortMode.recent,
|
||||
'dms': ChannelSortMode.alpha,
|
||||
'section:abc': ChannelSortMode.recent,
|
||||
},
|
||||
);
|
||||
final decoded = ChannelSortStore.fromJson(store.toJson());
|
||||
expect(decoded.groups, store.groups);
|
||||
expect(store.toJson()['groups'], {
|
||||
'channels': 'recent',
|
||||
'dms': 'alpha',
|
||||
'section:abc': 'recent',
|
||||
});
|
||||
});
|
||||
|
||||
test('drops unknown modes and non-string keys', () {
|
||||
final decoded = ChannelSortStore.fromJson({
|
||||
'version': 1,
|
||||
'groups': {'channels': 'recent', 'starred': 'bogus', 'dms': 42},
|
||||
});
|
||||
expect(decoded.groups, {'channels': ChannelSortMode.recent});
|
||||
});
|
||||
});
|
||||
|
||||
group('stripOrphanedSectionModes', () {
|
||||
test('removes modes for deleted sections, keeps fixed groups', () {
|
||||
final store = ChannelSortStore(
|
||||
groups: {
|
||||
'channels': ChannelSortMode.recent,
|
||||
'section:live': ChannelSortMode.recent,
|
||||
'section:dead': ChannelSortMode.alpha,
|
||||
},
|
||||
);
|
||||
final stripped = stripOrphanedSectionModes(store, ['live']);
|
||||
expect(stripped.groups.keys, ['channels', 'section:live']);
|
||||
});
|
||||
|
||||
test('returns same store when nothing to strip', () {
|
||||
final store = ChannelSortStore(
|
||||
groups: {'section:live': ChannelSortMode.recent},
|
||||
);
|
||||
expect(
|
||||
identical(stripOrphanedSectionModes(store, ['live']), store),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('ChannelSortStorage', () {
|
||||
test('read/write round-trip and dirty flag persistence', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final storage = ChannelSortStorage(prefs);
|
||||
|
||||
storage.write(
|
||||
'pk',
|
||||
ChannelSortStore(groups: {'channels': ChannelSortMode.recent}),
|
||||
);
|
||||
expect(storage.read('pk').groups, {'channels': ChannelSortMode.recent});
|
||||
|
||||
expect(storage.readDirtySince('pk'), 0);
|
||||
storage.writeDirtySince('pk', 123);
|
||||
expect(storage.readDirtySince('pk'), 123);
|
||||
storage.writeDirtySince('pk', 0);
|
||||
expect(storage.readDirtySince('pk'), 0);
|
||||
});
|
||||
|
||||
test('read tolerates corrupt or versioned-away payloads', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
channelSortKey('pk'): 'not-json',
|
||||
channelSortKey('pk2'): '{"version":2,"groups":{}}',
|
||||
});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final storage = ChannelSortStorage(prefs);
|
||||
expect(storage.read('pk').groups, isEmpty);
|
||||
expect(storage.read('pk2').groups, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('sortChannelsForList', () {
|
||||
Channel channel(String id, String name, {DateTime? lastMessageAt}) =>
|
||||
Channel(
|
||||
id: id,
|
||||
name: name,
|
||||
channelType: 'stream',
|
||||
visibility: 'open',
|
||||
description: '',
|
||||
createdBy: 'pk',
|
||||
createdAt: DateTime.utc(2026),
|
||||
memberCount: 1,
|
||||
lastMessageAt: lastMessageAt,
|
||||
);
|
||||
|
||||
test('alpha sorts by name case-insensitively with id tie-break', () {
|
||||
final sorted = sortChannelsForList([
|
||||
channel('2', 'beta'),
|
||||
channel('1', 'Alpha'),
|
||||
channel('3', 'alpha'),
|
||||
], ChannelSortMode.alpha);
|
||||
expect(sorted.map((c) => c.id), ['1', '3', '2']);
|
||||
});
|
||||
|
||||
test('recent sorts newest first, message-less channels sink alpha', () {
|
||||
final now = DateTime.utc(2026, 7, 25);
|
||||
final sorted = sortChannelsForList([
|
||||
channel('a', 'quiet-z'),
|
||||
channel(
|
||||
'b',
|
||||
'old',
|
||||
lastMessageAt: now.subtract(const Duration(days: 2)),
|
||||
),
|
||||
channel('c', 'new', lastMessageAt: now),
|
||||
channel('d', 'quiet-a'),
|
||||
], ChannelSortMode.recent);
|
||||
expect(sorted.map((c) => c.id), ['c', 'b', 'd', 'a']);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user