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:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-07-25 03:02:53 -07:00
parent ab3af82871
commit 0c397f5771
11 changed files with 1503 additions and 25 deletions
@@ -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']);
});
});
}