feat(mobile): sync per-group channel sorting (#4231)

**Category:** improvement
**User Impact:** Mobile users can sort each channel group by recent
activity or A–Z, with their choices synchronized with desktop.
**Problem:** Desktop supports persistent per-group channel sorting, but
mobile shows the same groups without equivalent controls or shared
preferences. The earlier mobile attempt coupled sorting to unsafe
dirty-state behavior that could overwrite newer cross-client changes.
**Solution:** Add mobile sorting controls and encrypted NIP-78
synchronization using the existing desktop `channel-sort` contract,
while retaining ordinary whole-blob last-write-wins behavior. Local
state is scoped by identity and normalized relay, startup closes
fetch/subscription gaps, and both clients use the same deterministic
ordering rules.

<details>
<summary>File changes</summary>

**desktop/src/features/sidebar/lib/channelSortPreference.test.mjs**
Updates ordering coverage for the deterministic, cross-client A–Z
comparison rule.

**desktop/src/features/sidebar/lib/channelSortPreference.ts**
Aligns desktop channel-name collation with mobile so synchronized
preferences produce the same visible order.

**mobile/lib/features/channels/channel_sort/channel_sort_manager.dart**
Adds encrypted relay synchronization with safe startup gap handling,
clock checks, and ordinary last-write-wins conflicts.

**mobile/lib/features/channels/channel_sort/channel_sort_provider.dart**
Scopes sort state to the active identity and community lifecycle.

**mobile/lib/features/channels/channel_sort/channel_sort_storage.dart**
Defines the desktop-compatible payload, relay-scoped cache and
migration, cleanup, and shared ordering behavior.

**mobile/lib/features/channels/channels_page.dart**
Connects sort state to the channel page.

**mobile/lib/features/channels/channels_page/body.dart**
Applies each selected order to Starred, custom groups, Channels, and
DMs.

**mobile/lib/features/channels/channels_page/sections.dart**
Adds checked Recent and A–Z actions using the existing anchored-popover
UI.


**mobile/test/features/channels/channel_sort/channel_sort_manager_test.dart**
Covers payload adoption, encrypted publication, conflicts, timestamps,
retries, and cleanup.


**mobile/test/features/channels/channel_sort/channel_sort_storage_test.dart**
Covers parsing, relay isolation, migration, cleanup, and ordering modes.

**mobile/test/features/channels/channels_page_test.dart**
Verifies the group controls expose both choices.

</details>

### Reproduction steps

1. Open the mobile channel list with populated built-in and custom
groups.
2. Open a group menu and choose **Sort: Recent**; confirm active
channels move to the top.
3. Choose **Sort: A–Z**; confirm deterministic alphabetical ordering
returns.
4. Repeat for Starred, a custom group, Channels, and DMs.
5. Open desktop with the same identity and community and confirm each
synchronized preference.
6. Switch communities and confirm cached preferences do not bleed across
relays.

### Screenshots

Approved `live` custom-section flow with `research` kept offscreen.

| Recent selected | A–Z result | A–Z selected |
|---|---|---|
| ![live custom section with Recent
selected](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4231/live-recent-selected.png)
| ![live custom section sorted
A–Z](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4231/live-az-result.png)
| ![live custom section with A–Z
selected](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4231/live-az-selected.png)
|

### Validation

- Mobile `flutter analyze` — clean
- Focused mobile sort and channel-page suites — 37/37 passed
- Desktop full suite — 3906/3906 passed
- Mobile full suite — 1034 passed, 1 skipped, 1 unrelated baseline
failure reproduced at `ac4fa13b8`

<!-- Originating Buzz channel: 2a16a2bb-6fd3-4d69-8182-2afcb21b2d14 -->

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
This commit is contained in:
Taylor Ho
2026-08-03 18:02:09 -07:00
committed by GitHub
co-authored by npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent d5da74e4e0
commit b42b093613
11 changed files with 1381 additions and 34 deletions
@@ -0,0 +1,322 @@
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';
const _dTag = 'channel-sort';
const _maxClockDriftSeconds = 300;
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);
}
/// Desktop-compatible encrypted NIP-78 sync for per-group sort preferences.
/// Remote state is ordinary whole-blob LWW: unlike the rejected #2829 design,
/// local state never vetoes a newer remote blob or leapfrogs an unseen edit.
class ChannelSortManager {
final String pubkey;
final String relayUrl;
final ChannelSortStorage _storage;
final ChannelSortCrypto _crypto;
final RelaySessionNotifier? _relaySession;
final SignedEventRelay? _signedEventRelay;
final bool _remoteEnabled;
final VoidCallback _onChanged;
final Duration _startupRetryBaseDelay;
final Duration _publishDelay;
ChannelSortStore _store;
ChannelSortSyncState _syncState;
Timer? _publishDebounce;
Timer? _startupRetryTimer;
int _startupRetryAttempt = 0;
int _lastRemoteCreatedAt = 0;
String _lastRemoteEventId = '';
int _generation = 0;
void Function()? _unsubscribe;
bool _disposed = false;
ChannelSortManager({
required this.pubkey,
required this.relayUrl,
required SharedPreferences prefs,
required ChannelSortCrypto crypto,
required RelaySessionNotifier? relaySession,
required SignedEventRelay? signedEventRelay,
required bool remoteEnabled,
required VoidCallback onChanged,
@visibleForTesting
Duration startupRetryBaseDelay = const Duration(seconds: 2),
@visibleForTesting Duration publishDelay = const Duration(seconds: 2),
}) : _storage = ChannelSortStorage(prefs),
_crypto = crypto,
_relaySession = relaySession,
_signedEventRelay = signedEventRelay,
_remoteEnabled = remoteEnabled,
_onChanged = onChanged,
_startupRetryBaseDelay = startupRetryBaseDelay,
_publishDelay = publishDelay,
_store = ChannelSortStorage(prefs).read(pubkey, relayUrl),
_syncState = ChannelSortStorage(prefs).readSyncState(pubkey, relayUrl) {
_lastRemoteCreatedAt = _syncState.updatedAt;
_lastRemoteEventId = _syncState.eventId;
}
ChannelSortStore get store => _store;
ChannelSortMode sortModeFor(String groupKey) =>
_store.groups[groupKey] ?? kDefaultSortMode;
Future<void> initialize() async {
if (_disposed || !_remoteEnabled || _relaySession == null) {
if (!_disposed) _onChanged();
return;
}
await _syncWithRelay();
if (!_disposed) _onChanged();
}
Future<void> _syncWithRelay() async {
final firstFetch = await _fetchAndApply();
final subscribed = _unsubscribe != null || await _startLiveSubscription();
// Fetch again after the subscription is ready. This closes the event gap
// between history and live setup (and catches anything published while a
// rate-limited subscription was retrying).
final secondFetch = subscribed ? await _fetchAndApply() : null;
if (firstFetch == null || !subscribed || secondFetch == null) {
_scheduleStartupRetry();
return;
}
_startupRetryAttempt = 0;
if (_syncState.hasPendingLocalChanges ||
(!firstFetch && !secondFetch && _store.groups.isNotEmpty)) {
_schedulePublish();
}
}
void _scheduleStartupRetry() {
if (_disposed) return;
_startupRetryTimer?.cancel();
final delayMs = min(
_startupRetryBaseDelay.inMilliseconds << min(_startupRetryAttempt, 5),
30000,
);
_startupRetryAttempt++;
_startupRetryTimer = Timer(Duration(milliseconds: delayMs), () {
_startupRetryTimer = null;
unawaited(
_syncWithRelay().then((_) {
if (!_disposed) _onChanged();
}),
);
});
}
void setSortModeFor(
String groupKey,
ChannelSortMode mode, {
Iterable<String>? liveSectionIds,
}) {
if (_disposed || _store.groups[groupKey] == mode) return;
final updated = ChannelSortStore(
groups: {..._store.groups, groupKey: mode},
);
_store = liveSectionIds == null
? updated
: stripOrphanedSectionModes(updated, liveSectionIds);
final now = currentUnixSeconds();
final pendingUpdatedAt = min(
max(now, _syncState.pendingUpdatedAt + 1),
now + _maxClockDriftSeconds,
);
_syncState = ChannelSortSyncState(
updatedAt: _syncState.updatedAt,
eventId: _syncState.eventId,
hasPendingLocalChanges: true,
pendingUpdatedAt: pendingUpdatedAt,
);
_generation++;
_persist();
_schedulePublish();
_onChanged();
}
void _schedulePublish() {
if (!_remoteEnabled || _disposed) return;
_publishDebounce?.cancel();
_publishDebounce = Timer(_publishDelay, () {
_publishDebounce = null;
unawaited(_publish());
});
}
Future<bool?> _fetchAndApply() async {
if (_relaySession == null) return null;
try {
final events = await _relaySession.fetchHistory(_filter());
var found = false;
for (final event in events) {
if (event.pubkey != pubkey || event.getTagValue('d') != _dTag) continue;
found = true;
_applyRemote(event);
}
return found;
} catch (error) {
debugPrint('[ChannelSortManager] fetch failed: $error');
return null;
}
}
Future<bool> _startLiveSubscription() async {
if (_relaySession == null) return false;
try {
_unsubscribe = await _relaySession.subscribe(
_filter(),
_handleIncomingEvent,
onClosed: (_) {
if (_disposed) return;
_unsubscribe?.call();
_unsubscribe = null;
_scheduleStartupRetry();
},
);
return true;
} catch (error) {
debugPrint('[ChannelSortManager] subscribe failed: $error');
return false;
}
}
NostrFilter _filter() => NostrFilter(
kinds: const [EventKind.readState],
authors: [pubkey],
tags: const {
'#d': [_dTag],
},
limit: 1,
);
void _applyRemote(NostrEvent event) {
if (event.createdAt > currentUnixSeconds() + _maxClockDriftSeconds) return;
if (_syncState.hasPendingLocalChanges &&
event.createdAt <= _syncState.pendingUpdatedAt) {
return;
}
final isNewer =
event.createdAt > _lastRemoteCreatedAt ||
(event.createdAt == _lastRemoteCreatedAt &&
event.id.compareTo(_lastRemoteEventId) < 0);
if (!isNewer) return;
try {
final parsed = jsonDecode(_crypto.decrypt(event.content));
if (parsed is! Map<String, dynamic> || parsed['version'] != 1) return;
final incoming = ChannelSortStore.fromJson(parsed);
_lastRemoteCreatedAt = event.createdAt;
_lastRemoteEventId = event.id;
_syncState = ChannelSortSyncState(
updatedAt: event.createdAt,
eventId: event.id,
);
_publishDebounce?.cancel();
_publishDebounce = null;
_store = incoming;
_generation++;
_persist();
} catch (_) {
// Ignore malformed or undecryptable blobs without advancing the cursor.
}
}
void _handleIncomingEvent(NostrEvent event) {
if (_disposed ||
event.pubkey != pubkey ||
event.getTagValue('d') != _dTag) {
return;
}
final before = _generation;
_applyRemote(event);
if (_generation != before && !_disposed) _onChanged();
}
Future<void> _publish() async {
if (_disposed || !_remoteEnabled || _signedEventRelay == null) return;
final generationAtStart = _generation;
// A newer remote blob wins. If one arrives during this read, _generation
// changes and we abort rather than overwriting it.
final preflight = await _fetchAndApply();
if (_disposed || _generation != generationAtStart) return;
if (preflight == null) {
_schedulePublish();
return;
}
try {
final now = currentUnixSeconds();
final createdAt = max(now, _lastRemoteCreatedAt + 1);
if (createdAt > now + _maxClockDriftSeconds) {
debugPrint(
'[ChannelSortManager] publish delayed: relay cursor '
'is ${createdAt - now}s ahead of local time',
);
_schedulePublish();
return;
}
final ciphertext = _crypto.encrypt(jsonEncode(_store.toJson()));
String? submittedEventId;
await _signedEventRelay.submit(
kind: EventKind.readState,
content: ciphertext,
tags: const [
['d', _dTag],
['t', _dTag],
],
createdAt: createdAt,
onSigned: (event) => submittedEventId = event.id,
);
if (_disposed || _generation != generationAtStart) return;
// Keep local publications strictly ordered for this manager lifetime, as
// desktop does, but never persist that volatile publication cursor.
_lastRemoteCreatedAt = createdAt;
_lastRemoteEventId = submittedEventId ?? '';
_syncState = ChannelSortSyncState(
updatedAt: _syncState.updatedAt,
eventId: _syncState.eventId,
);
_persist();
} catch (error) {
debugPrint('[ChannelSortManager] publish failed: $error');
}
}
void _persist() {
_storage.write(pubkey, relayUrl, _store);
_storage.writeSyncState(pubkey, relayUrl, _syncState);
}
void dispose() {
if (_disposed) return;
_disposed = true;
_publishDebounce?.cancel();
_startupRetryTimer?.cancel();
_unsubscribe?.call();
_unsubscribe = null;
}
}
@@ -0,0 +1,126 @@
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();
_manager = null;
final relayConfig = ref.watch(relayConfigProvider);
final sessionState = ref.watch(relaySessionProvider);
final activeCommunity = ref.watch(activeCommunityProvider).value;
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 relayUrl = activeCommunity?.relayUrl.trim();
if (relayUrl == null || relayUrl.isEmpty) {
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,
relayUrl: relayUrl,
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,219 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../channel.dart';
String normalizeChannelSortRelayUrl(String relayUrl) =>
relayUrl.trim().replaceFirst(RegExp(r'/+$'), '').toLowerCase();
String channelSortKey(String pubkey, String relayUrl) =>
'buzz.channel-sort.v1:$pubkey:${Uri.encodeComponent(normalizeChannelSortRelayUrl(relayUrl))}';
String legacyChannelSortKey(String pubkey) => 'buzz.channel-sort.v1:$pubkey';
/// Per-group sidebar sort mode. The wire values match desktop exactly.
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;
String sectionSortGroupKey(String sectionId) => 'section:$sectionId';
class ChannelSortStore {
final int version;
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 groups = <String, ChannelSortMode>{};
final rawGroups = json['groups'];
if (rawGroups is Map) {
for (final entry in rawGroups.entries) {
final mode = ChannelSortMode.fromWire(entry.value);
if (entry.key is String && mode != null) {
groups[entry.key as String] = mode;
}
}
}
return ChannelSortStore(groups: groups);
}
}
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(groups: kept);
}
/// Mobile and desktop both use a case-insensitive deterministic ordering.
/// The id tie-break keeps equal folded names stable across clients.
int compareChannelsByName(Channel left, Channel right) {
final name = left.name.toLowerCase().compareTo(right.name.toLowerCase());
return name != 0 ? name : left.id.compareTo(right.id);
}
List<Channel> sortChannelsForList(
List<Channel> channels,
ChannelSortMode mode,
) {
final sorted = channels.toList();
if (mode == ChannelSortMode.alpha) {
sorted.sort(compareChannelsByName);
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 compareChannelsByName(left, right);
});
return sorted;
}
class ChannelSortSyncState {
final int updatedAt;
final String eventId;
final bool hasPendingLocalChanges;
final int pendingUpdatedAt;
const ChannelSortSyncState({
this.updatedAt = 0,
this.eventId = '',
this.hasPendingLocalChanges = false,
this.pendingUpdatedAt = 0,
});
Map<String, dynamic> toJson() => {
'updatedAt': updatedAt,
'eventId': eventId,
'hasPendingLocalChanges': hasPendingLocalChanges,
'pendingUpdatedAt': pendingUpdatedAt,
};
factory ChannelSortSyncState.fromJson(Map<String, dynamic> json) {
final updatedAt = json['updatedAt'] is int ? json['updatedAt'] as int : 0;
final hasPendingLocalChanges = json['hasPendingLocalChanges'] == true;
final storedPendingUpdatedAt = json['pendingUpdatedAt'];
// Older builds used updatedAt for both the remote cursor and local edit
// stamp. Preserve the pending guard while resetting that ambiguous cursor.
final isLegacyPending =
hasPendingLocalChanges && storedPendingUpdatedAt is! int;
return ChannelSortSyncState(
updatedAt: isLegacyPending ? 0 : updatedAt,
eventId: isLegacyPending
? ''
: (json['eventId'] is String ? json['eventId'] as String : ''),
hasPendingLocalChanges: hasPendingLocalChanges,
pendingUpdatedAt: storedPendingUpdatedAt is int
? storedPendingUpdatedAt
: (hasPendingLocalChanges ? updatedAt : 0),
);
}
}
class ChannelSortStorage {
final SharedPreferences _prefs;
ChannelSortStorage(this._prefs);
ChannelSortStore read(String pubkey, String relayUrl) {
final scopedKey = channelSortKey(pubkey, relayUrl);
final scoped = _readKey(scopedKey);
if (scoped != null) return scoped;
// One-time read-through migration from #2829 development builds. The
// first active relay claims the legacy value; removing it prevents the
// same unscoped preferences from bleeding into later communities.
final legacyKey = legacyChannelSortKey(pubkey);
final legacy = _readKey(legacyKey);
if (legacy != null) {
write(pubkey, relayUrl, legacy);
_prefs.remove(legacyKey);
return legacy;
}
return const ChannelSortStore();
}
ChannelSortStore? _readKey(String key) {
final raw = _prefs.getString(key);
if (raw == null || raw.isEmpty) {
return null;
}
try {
final parsed = jsonDecode(raw);
if (parsed is! Map<String, dynamic> || parsed['version'] != 1) {
return null;
}
return ChannelSortStore.fromJson(parsed);
} catch (_) {
return null;
}
}
void write(String pubkey, String relayUrl, ChannelSortStore store) {
_prefs.setString(
channelSortKey(pubkey, relayUrl),
jsonEncode(store.toJson()),
);
}
ChannelSortSyncState readSyncState(String pubkey, String relayUrl) {
final raw = _prefs.getString(_syncStateKey(pubkey, relayUrl));
if (raw == null || raw.isEmpty) return const ChannelSortSyncState();
try {
final parsed = jsonDecode(raw);
return parsed is Map<String, dynamic>
? ChannelSortSyncState.fromJson(parsed)
: const ChannelSortSyncState();
} catch (_) {
return const ChannelSortSyncState();
}
}
void writeSyncState(
String pubkey,
String relayUrl,
ChannelSortSyncState state,
) {
_prefs.setString(
_syncStateKey(pubkey, relayUrl),
jsonEncode(state.toJson()),
);
}
String _syncStateKey(String pubkey, String relayUrl) =>
'${channelSortKey(pubkey, relayUrl)}:sync';
}
@@ -37,6 +37,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';
@@ -101,6 +101,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 =
@@ -165,16 +166,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>>({});
@@ -212,19 +230,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,
@@ -286,6 +309,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);
@@ -316,6 +344,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(
@@ -324,12 +354,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,
),
],
@@ -18,6 +18,8 @@ class _CustomChannelSection extends StatelessWidget {
final VoidCallback onDelete;
final VoidCallback onMoveUp;
final VoidCallback onMoveDown;
final ChannelSortMode sortMode;
final ValueChanged<ChannelSortMode> onSortModeChange;
final Future<void> Function(Channel channel) onSelectChannel;
final void Function(Channel channel) onMarkChannelRead;
@@ -37,6 +39,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,
});
@@ -57,6 +61,8 @@ class _CustomChannelSection extends StatelessWidget {
onDelete: onDelete,
onMoveUp: onMoveUp,
onMoveDown: onMoveDown,
sortMode: sortMode,
onSortModeChange: onSortModeChange,
),
_AnimatedSectionBody(
expanded: expanded,
@@ -92,6 +98,8 @@ class _CustomSectionHeader extends ConsumerWidget {
final VoidCallback onDelete;
final VoidCallback onMoveUp;
final VoidCallback onMoveDown;
final ChannelSortMode sortMode;
final ValueChanged<ChannelSortMode> onSortModeChange;
const _CustomSectionHeader({
required this.section,
@@ -103,6 +111,8 @@ class _CustomSectionHeader extends ConsumerWidget {
required this.onDelete,
required this.onMoveUp,
required this.onMoveDown,
required this.sortMode,
required this.onSortModeChange,
});
@override
@@ -208,6 +218,7 @@ class _CustomSectionHeader extends ConsumerWidget {
label: 'Move down',
),
),
..._sortMenuItems(sortMode),
PopupMenuItem(
value: 'delete',
padding: _sectionMenuItemPadding,
@@ -226,6 +237,10 @@ class _CustomSectionHeader extends ConsumerWidget {
onMoveUp();
case 'move_down':
onMoveDown();
case _kSortRecentMenuValue:
onSortModeChange(ChannelSortMode.recent);
case _kSortAlphaMenuValue:
onSortModeChange(ChannelSortMode.alpha);
case 'delete':
onDelete();
}
@@ -320,6 +335,43 @@ class _SectionNameDialog extends HookWidget {
}
}
const _kSortRecentMenuValue = 'sort_recent';
const _kSortAlphaMenuValue = 'sort_alpha';
PopupMenuItem<String> _sortMenuItem({
required String value,
required String label,
required bool selected,
}) => PopupMenuItem(
value: value,
child: Row(
children: [
Expanded(child: Text(label)),
if (selected)
const Icon(LucideIcons.check, key: ValueKey('sort-selected-check'))
else
const SizedBox(width: 24),
],
),
);
List<PopupMenuEntry<String>> _sortMenuItems(
ChannelSortMode current, {
bool showDivider = true,
}) => [
if (showDivider) const PopupMenuDivider(),
_sortMenuItem(
value: _kSortRecentMenuValue,
label: 'Sort: Recent',
selected: current == ChannelSortMode.recent,
),
_sortMenuItem(
value: _kSortAlphaMenuValue,
label: 'Sort: AZ',
selected: current == ChannelSortMode.alpha,
),
];
class _ChannelSection extends StatelessWidget {
final String title;
final IconData icon;
@@ -332,6 +384,8 @@ class _ChannelSection extends StatelessWidget {
final Set<String> mutedChannelIds;
final String? currentPubkey;
final String emptyLabel;
final ChannelSortMode? sortMode;
final ValueChanged<ChannelSortMode>? onSortModeChange;
final Future<void> Function(Channel channel) onSelectChannel;
const _ChannelSection({
@@ -346,6 +400,8 @@ class _ChannelSection extends StatelessWidget {
required this.mutedChannelIds,
required this.currentPubkey,
required this.emptyLabel,
this.sortMode,
this.onSortModeChange,
required this.onSelectChannel,
});
@@ -360,6 +416,8 @@ class _ChannelSection extends StatelessWidget {
icon: icon,
expanded: expanded,
onToggle: onToggle,
sortMode: sortMode,
onSortModeChange: onSortModeChange,
),
_AnimatedSectionBody(
expanded: expanded,
@@ -454,12 +512,16 @@ class _SectionHeader extends StatelessWidget {
final IconData icon;
final bool expanded;
final VoidCallback onToggle;
final ChannelSortMode? sortMode;
final ValueChanged<ChannelSortMode>? onSortModeChange;
const _SectionHeader({
required this.label,
required this.icon,
required this.expanded,
required this.onToggle,
this.sortMode,
this.onSortModeChange,
});
@override
@@ -494,6 +556,44 @@ class _SectionHeader extends StatelessWidget {
),
),
const Spacer(),
if (sortMode case final mode?) ...[
Builder(
builder: (buttonContext) => IconButton(
key: ValueKey('sort-menu-$label'),
tooltip: '$label options',
visualDensity: VisualDensity.compact,
icon: Icon(
LucideIcons.ellipsisVertical,
size: _kChannelIconSize,
color: sectionColor,
),
onPressed: () async {
final value = await showAnchoredPopover<String>(
context: buttonContext,
width: 216,
alignment: AnchoredPopoverAlignment.end,
color: context.colors.surface,
elevation: 4,
shadowColor: context.colors.shadow.withValues(
alpha: 0.18,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(Radii.md),
side: BorderSide(color: context.colors.outline),
),
surfaceKey: ValueKey('sort-popover-$label'),
items: _sortMenuItems(mode, showDivider: false),
);
if (value == _kSortRecentMenuValue) {
onSortModeChange?.call(ChannelSortMode.recent);
} else if (value == _kSortAlphaMenuValue) {
onSortModeChange?.call(ChannelSortMode.alpha);
}
},
),
),
const SizedBox(width: Grid.quarter),
],
_SectionChevron(expanded: expanded, color: sectionColor),
],
),
@@ -0,0 +1,377 @@
import 'dart:async';
import 'dart:convert';
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';
import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
void main() {
late SharedPreferences prefs;
late nostr.Keys keys;
late ChannelSortCrypto crypto;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
keys = nostr.Keys.generate();
crypto = ChannelSortCrypto(keys.nsec, keys.public);
});
NostrEvent event(
Map<String, String> groups,
int createdAt, {
String id = 'e',
}) => NostrEvent(
id: id,
pubkey: keys.public,
createdAt: createdAt,
kind: EventKind.readState,
tags: const [
['d', 'channel-sort'],
],
content: crypto.encrypt(jsonEncode({'version': 1, 'groups': groups})),
sig: 'sig',
);
ChannelSortManager manager(
_FakeRelaySession relay,
_RecordingSignedEventRelay signed, {
Duration retry = const Duration(milliseconds: 5),
}) => ChannelSortManager(
pubkey: keys.public,
relayUrl: 'wss://relay.example',
prefs: prefs,
crypto: crypto,
relaySession: relay,
signedEventRelay: signed,
remoteEnabled: true,
onChanged: () {},
startupRetryBaseDelay: retry,
publishDelay: const Duration(milliseconds: 5),
);
test(
'adopts desktop payload and defaults unspecified groups to alpha',
() async {
final relay = _FakeRelaySession()
..historyEvents = [
event({'channels': 'recent', 'section:abc': 'recent'}, 100),
];
final subject = manager(relay, _RecordingSignedEventRelay());
await subject.initialize();
expect(subject.sortModeFor('channels'), ChannelSortMode.recent);
expect(subject.sortModeFor('section:abc'), ChannelSortMode.recent);
expect(subject.sortModeFor('dms'), ChannelSortMode.alpha);
subject.dispose();
},
);
test('publishes local edit using desktop encrypted wire format', () async {
final relay = _FakeRelaySession();
final signed = _RecordingSignedEventRelay();
final subject = manager(relay, signed);
await subject.initialize();
subject.setSortModeFor('dms', ChannelSortMode.recent);
final submitted = await signed.submitted.future.timeout(
const Duration(seconds: 1),
);
expect(
submitted.tags.any((tag) => tag[0] == 'd' && tag[1] == 'channel-sort'),
isTrue,
);
final payload =
jsonDecode(crypto.decrypt(submitted.content)) as Map<String, dynamic>;
expect(payload, {
'version': 1,
'groups': {'dms': 'recent'},
});
subject.dispose();
});
test(
'failed publish preflight retries without submitting stale state',
() async {
final relay = _FakeRelaySession();
final signed = _RecordingSignedEventRelay();
final subject = manager(relay, signed);
await subject.initialize();
relay.fetchFailures = 1;
subject.setSortModeFor('dms', ChannelSortMode.recent);
await Future<void>.delayed(const Duration(milliseconds: 8));
expect(signed.submitCount, 0);
final submitted = await signed.submitted.future.timeout(
const Duration(seconds: 1),
);
expect(relay.fetchCount, greaterThanOrEqualTo(4));
final payload =
jsonDecode(crypto.decrypt(submitted.content)) as Map<String, dynamic>;
expect(payload['groups'], {'dms': 'recent'});
subject.dispose();
},
);
test(
'pending local edit survives manager rebuild and older relay state',
() async {
final firstRelay = _FakeRelaySession();
final first = manager(firstRelay, _RecordingSignedEventRelay());
await first.initialize();
first.setSortModeFor('channels', ChannelSortMode.recent);
first.dispose();
final secondRelay = _FakeRelaySession()
..historyEvents = [
event({'dms': 'recent'}, 100),
];
final signed = _RecordingSignedEventRelay();
final second = manager(secondRelay, signed);
await second.initialize();
expect(second.store.groups, {'channels': ChannelSortMode.recent});
final submitted = await signed.submitted.future.timeout(
const Duration(seconds: 1),
);
final payload =
jsonDecode(crypto.decrypt(submitted.content)) as Map<String, dynamic>;
expect(payload['groups'], {'channels': 'recent'});
second.dispose();
},
);
test('newer remote event cancels pending local whole-blob write', () async {
final relay = _FakeRelaySession();
final signed = _RecordingSignedEventRelay();
final subject = manager(relay, signed);
await subject.initialize();
subject.setSortModeFor('channels', ChannelSortMode.recent);
relay.emit(
event({
'dms': 'recent',
}, DateTime.now().millisecondsSinceEpoch ~/ 1000 + 1),
);
await Future<void>.delayed(const Duration(milliseconds: 30));
expect(subject.store.groups, {'dms': ChannelSortMode.recent});
expect(signed.submitted.isCompleted, isFalse);
subject.dispose();
});
test('same-second remote does not erase a pending local edit', () async {
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
ChannelSortStorage(prefs).writeSyncState(
keys.public,
'wss://relay.example',
ChannelSortSyncState(updatedAt: now - 1, eventId: 'remote'),
);
final relay = _FakeRelaySession();
final signed = _RecordingSignedEventRelay();
final subject = manager(relay, signed);
await subject.initialize();
subject.setSortModeFor('channels', ChannelSortMode.recent);
relay.emit(event({'dms': 'recent'}, now, id: 'a'));
expect(subject.store.groups, {'channels': ChannelSortMode.recent});
await signed.submitted.future.timeout(const Duration(seconds: 1));
subject.dispose();
});
test('publishing does not advance the persisted remote cursor', () async {
final storage = ChannelSortStorage(prefs);
storage.writeSyncState(
keys.public,
'wss://relay.example',
const ChannelSortSyncState(updatedAt: 100, eventId: 'remote'),
);
final signed = _RecordingSignedEventRelay();
final subject = manager(_FakeRelaySession(), signed);
await subject.initialize();
subject.setSortModeFor('channels', ChannelSortMode.recent);
await signed.submitted.future.timeout(const Duration(seconds: 1));
await Future<void>.delayed(Duration.zero);
final syncState = storage.readSyncState(keys.public, 'wss://relay.example');
expect(syncState.updatedAt, 100);
expect(syncState.eventId, 'remote');
expect(syncState.hasPendingLocalChanges, isFalse);
expect(syncState.pendingUpdatedAt, 0);
subject.dispose();
});
test('legacy pending state resets its ambiguous remote cursor', () {
final storage = ChannelSortStorage(prefs);
// Simulate the pre-migration JSON, which had no pendingUpdatedAt field.
prefs.setString(
'${channelSortKey(keys.public, 'wss://relay.example')}:sync',
jsonEncode({
'updatedAt': 4102444800,
'eventId': 'local-publication',
'hasPendingLocalChanges': true,
}),
);
final syncState = storage.readSyncState(keys.public, 'wss://relay.example');
expect(syncState.updatedAt, 0);
expect(syncState.eventId, isEmpty);
expect(syncState.pendingUpdatedAt, 4102444800);
});
test('lower event ID wins when remote timestamps tie', () async {
final relay = _FakeRelaySession()
..historyEvents = [
event({'channels': 'recent'}, 200, id: 'f'),
];
final subject = manager(relay, _RecordingSignedEventRelay());
await subject.initialize();
relay.emit(event({'dms': 'recent'}, 200, id: 'a'));
expect(subject.store.groups, {'dms': ChannelSortMode.recent});
relay.emit(event({'starred': 'recent'}, 200, id: 'z'));
expect(subject.store.groups, {'dms': ChannelSortMode.recent});
subject.dispose();
});
test('adopts a newer relay replacement after publishing', () async {
final relay = _FakeRelaySession();
final signed = _RecordingSignedEventRelay();
final subject = manager(relay, signed);
await subject.initialize();
subject.setSortModeFor('channels', ChannelSortMode.recent);
final submitted = await signed.submitted.future.timeout(
const Duration(seconds: 1),
);
relay.emit(event({'dms': 'recent'}, submitted.createdAt! + 1, id: 'a'));
expect(subject.store.groups, {'dms': ChannelSortMode.recent});
subject.dispose();
});
test(
'future-dated remote event is ignored and cannot wedge publishing',
() async {
final relay = _FakeRelaySession()
..historyEvents = [
event({'channels': 'recent'}, 4102444800),
];
final signed = _RecordingSignedEventRelay();
final subject = manager(relay, signed);
await subject.initialize();
expect(subject.sortModeFor('channels'), ChannelSortMode.alpha);
subject.setSortModeFor('dms', ChannelSortMode.recent);
await signed.submitted.future.timeout(const Duration(seconds: 1));
subject.dispose();
},
);
test('retries failed startup and closes fetch-subscribe gap', () async {
final relay = _FakeRelaySession()..fetchFailures = 1;
final subject = manager(relay, _RecordingSignedEventRelay());
await subject.initialize();
relay.historyEvents = [
event({'starred': 'recent'}, 300),
];
await Future<void>.delayed(const Duration(milliseconds: 40));
expect(subject.sortModeFor('starred'), ChannelSortMode.recent);
expect(relay.fetchCount, greaterThanOrEqualTo(3));
subject.dispose();
});
test('setSortModeFor prunes deleted custom section keys', () async {
final subject = manager(_FakeRelaySession(), _RecordingSignedEventRelay());
await subject.initialize();
subject.setSortModeFor('section:dead', ChannelSortMode.recent);
subject.setSortModeFor(
'channels',
ChannelSortMode.recent,
liveSectionIds: ['live'],
);
expect(subject.store.groups.keys, ['channels']);
subject.dispose();
});
}
class _SubmittedEvent {
final String content;
final List<List<String>> tags;
final int? createdAt;
const _SubmittedEvent(this.content, this.tags, this.createdAt);
}
class _RecordingSignedEventRelay implements SignedEventRelay {
final submitted = Completer<_SubmittedEvent>();
int submitCount = 0;
@override
String? get pubkey => null;
@override
Future<NostrEvent> submit({
required int kind,
required String content,
required List<List<String>> tags,
int? createdAt,
void Function(NostrEvent event)? onSigned,
}) async {
submitCount++;
onSigned?.call(
const NostrEvent(
id: 'signed-event',
pubkey: '',
createdAt: 0,
kind: 0,
tags: [],
content: '',
sig: '',
),
);
if (!submitted.isCompleted) {
submitted.complete(_SubmittedEvent(content, tags, createdAt));
}
return const NostrEvent(
id: 'ack',
pubkey: '',
createdAt: 0,
kind: 0,
tags: [],
content: '',
sig: '',
);
}
}
class _FakeRelaySession extends RelaySessionNotifier {
List<NostrEvent> historyEvents = [];
int fetchFailures = 0;
int fetchCount = 0;
void Function(NostrEvent)? _listener;
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
fetchCount++;
if (fetchFailures > 0) {
fetchFailures--;
throw Exception('rate limited');
}
return historyEvents;
}
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async {
_listener = onEvent;
return () => _listener = null;
}
void emit(NostrEvent event) => _listener?.call(event);
}
@@ -0,0 +1,124 @@
import 'dart:convert';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channel_sort/channel_sort_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
group('ChannelSortStore JSON', () {
test('round-trips desktop wire format and drops invalid modes', () {
final store = ChannelSortStore(
groups: {
'channels': ChannelSortMode.recent,
'dms': ChannelSortMode.alpha,
},
);
expect(store.toJson()['groups'], {'channels': 'recent', 'dms': 'alpha'});
expect(ChannelSortStore.fromJson(store.toJson()).groups, store.groups);
expect(
ChannelSortStore.fromJson({
'version': 1,
'groups': {'channels': 'recent', 'starred': 'bogus'},
}).groups,
{'channels': ChannelSortMode.recent},
);
});
});
group('ChannelSortStorage', () {
test('normalizes relay scope and isolates communities', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final storage = ChannelSortStorage(prefs);
final store = ChannelSortStore(
groups: {'channels': ChannelSortMode.recent},
);
storage.write('pk', ' WSS://Relay.Example/ ', store);
expect(storage.read('pk', 'wss://relay.example').groups, store.groups);
expect(storage.read('pk', 'wss://other.example').groups, isEmpty);
});
test('migrates legacy unscoped cache into the first relay scope', () async {
SharedPreferences.setMockInitialValues({
legacyChannelSortKey('pk'): jsonEncode({
'version': 1,
'groups': {'dms': 'recent'},
}),
});
final prefs = await SharedPreferences.getInstance();
final storage = ChannelSortStorage(prefs);
expect(storage.read('pk', 'wss://one').groups, {
'dms': ChannelSortMode.recent,
});
expect(prefs.getString(channelSortKey('pk', 'wss://one')), isNotNull);
expect(prefs.getString(legacyChannelSortKey('pk')), isNull);
expect(storage.read('pk', 'wss://two').groups, isEmpty);
});
test('ignores corrupt and unsupported payloads', () async {
SharedPreferences.setMockInitialValues({
channelSortKey('pk', 'wss://one'): 'nope',
channelSortKey('pk', 'wss://two'): '{"version":2,"groups":{}}',
});
final prefs = await SharedPreferences.getInstance();
final storage = ChannelSortStorage(prefs);
expect(storage.read('pk', 'wss://one').groups, isEmpty);
expect(storage.read('pk', 'wss://two').groups, isEmpty);
});
});
test('prunes orphaned section modes but keeps fixed groups', () {
final store = ChannelSortStore(
groups: {
'channels': ChannelSortMode.recent,
'section:live': ChannelSortMode.recent,
'section:dead': ChannelSortMode.alpha,
},
);
expect(stripOrphanedSectionModes(store, ['live']).groups.keys, [
'channels',
'section:live',
]);
});
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 matches desktop code-unit collation and id tie-break', () {
final sorted = sortChannelsForList([
channel('2', 'zeta'),
channel('3', 'Alpha'),
channel('1', 'alpha'),
channel('4', 'Éclair'),
], ChannelSortMode.alpha);
expect(sorted.map((c) => c.id), ['1', '3', '2', '4']);
});
test('recent puts newest first and quiet channels alpha last', () {
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']);
});
});
}
@@ -135,6 +135,27 @@ void main() {
expect(find.text('DMs'), findsOneWidget);
expect(find.text('Community'), findsOneWidget);
expect(find.byTooltip('Create or start conversation'), findsOneWidget);
expect(find.byTooltip('Channels options'), findsOneWidget);
expect(find.byIcon(LucideIcons.ellipsisVertical), findsWidgets);
expect(find.byIcon(LucideIcons.arrowUpDown), findsNothing);
expect(find.byTooltip('DMs options'), findsOneWidget);
await tester.tap(find.byTooltip('Channels options'));
await tester.pumpAndSettle();
expect(find.text('Sort: Recent'), findsOneWidget);
expect(find.text('Sort: AZ'), findsOneWidget);
final popover = find.byKey(const ValueKey('sort-popover-Channels'));
expect(popover, findsOneWidget);
expect(
find.descendant(of: popover, matching: find.byType(PopupMenuDivider)),
findsNothing,
);
final selectedCheck = find.byKey(const ValueKey('sort-selected-check'));
expect(selectedCheck, findsOneWidget);
expect(
tester.getCenter(selectedCheck).dx,
greaterThan(tester.getCenter(find.text('Sort: AZ')).dx),
);
for (final label in ['general', 'Alice']) {
final text = tester.widget<Text>(find.text(label));
@@ -249,16 +270,25 @@ void main() {
);
}
final menuItems = tester.widgetList<PopupMenuItem<String>>(
find.descendant(
of: popover,
matching: find.byWidgetPredicate(
(widget) => widget is PopupMenuItem<String>,
),
),
);
expect(menuItems, hasLength(4));
for (final item in menuItems) {
final actionMenuItems = tester
.widgetList<PopupMenuItem<String>>(
find.descendant(
of: popover,
matching: find.byWidgetPredicate(
(widget) => widget is PopupMenuItem<String>,
),
),
)
.where(
(item) => const {
'rename',
'move_up',
'move_down',
'delete',
}.contains(item.value),
);
expect(actionMenuItems, hasLength(4));
for (final item in actionMenuItems) {
expect(
item.padding,
const EdgeInsets.fromLTRB(Grid.xs, 0, Grid.twelve, 0),
@@ -484,7 +514,13 @@ void main() {
expect(find.text('alpha.example.com'), findsOneWidget);
expect(find.text('bravo.example.com'), findsOneWidget);
expect(find.text('Rename'), findsNothing);
expect(find.byIcon(LucideIcons.ellipsisVertical), findsNothing);
expect(
find.descendant(
of: options,
matching: find.byIcon(LucideIcons.ellipsisVertical),
),
findsNothing,
);
expect(find.text('Edit'), findsOneWidget);
expect(find.byIcon(LucideIcons.trash2), findsNothing);
expect(