From 44ee2af588f849240e10dab8d29140e436df3dbc Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Sat, 1 Aug 2026 18:05:41 -0700 Subject: [PATCH 1/9] feat(mobile): sync per-group channel sorting Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../lib/channelSortPreference.test.mjs | 8 +- .../sidebar/lib/channelSortPreference.ts | 11 +- .../channel_sort/channel_sort_manager.dart | 273 ++++++++++++++++++ .../channel_sort/channel_sort_provider.dart | 126 ++++++++ .../channel_sort/channel_sort_storage.dart | 151 ++++++++++ .../lib/features/channels/channels_page.dart | 2 + .../features/channels/channels_page/body.dart | 68 +++-- .../channels/channels_page/sections.dart | 80 +++++ .../channel_sort_manager_test.dart | 216 ++++++++++++++ .../channel_sort_storage_test.dart | 124 ++++++++ .../features/channels/channels_page_test.dart | 7 + 11 files changed, 1043 insertions(+), 23 deletions(-) create mode 100644 mobile/lib/features/channels/channel_sort/channel_sort_manager.dart create mode 100644 mobile/lib/features/channels/channel_sort/channel_sort_provider.dart create mode 100644 mobile/lib/features/channels/channel_sort/channel_sort_storage.dart create mode 100644 mobile/test/features/channels/channel_sort/channel_sort_manager_test.dart create mode 100644 mobile/test/features/channels/channel_sort/channel_sort_storage_test.dart diff --git a/desktop/src/features/sidebar/lib/channelSortPreference.test.mjs b/desktop/src/features/sidebar/lib/channelSortPreference.test.mjs index ac558075f0..7f2b94656e 100644 --- a/desktop/src/features/sidebar/lib/channelSortPreference.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSortPreference.test.mjs @@ -170,19 +170,19 @@ test("stripOrphanedSectionModes: does not mutate the input store", () => { // ── sortChannelsForSidebar ─────────────────────────────────────────────────── -test("alpha: sorts by name with id tie-breaker", () => { +test("alpha: sorts case-insensitively with deterministic code-unit collation", () => { const sorted = sortChannelsForSidebar( [ makeChannel("2", "zeta"), + makeChannel("3", "Alpha"), makeChannel("1", "alpha"), - makeChannel("b", "same"), - makeChannel("a", "same"), + makeChannel("4", "Éclair"), ], "alpha", ); assert.deepEqual( sorted.map((c) => c.id), - ["1", "a", "b", "2"], + ["1", "3", "2", "4"], ); }); diff --git a/desktop/src/features/sidebar/lib/channelSortPreference.ts b/desktop/src/features/sidebar/lib/channelSortPreference.ts index 6a09e1e8f1..6bd9b48d7b 100644 --- a/desktop/src/features/sidebar/lib/channelSortPreference.ts +++ b/desktop/src/features/sidebar/lib/channelSortPreference.ts @@ -128,8 +128,17 @@ function channelRecencyMs(channel: Channel): number | null { return Number.isFinite(ms) ? ms : null; } +function compareCodeUnits(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + export function compareChannelsByName(left: Channel, right: Channel): number { - return left.name.localeCompare(right.name) || left.id.localeCompare(right.id); + return ( + compareCodeUnits(left.name.toLowerCase(), right.name.toLowerCase()) || + compareCodeUnits(left.id, right.id) + ); } /** diff --git a/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart b/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart new file mode 100644 index 0000000000..46cb0469e8 --- /dev/null +++ b/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart @@ -0,0 +1,273 @@ +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; + 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); + + ChannelSortStore get store => _store; + ChannelSortMode sortModeFor(String groupKey) => + _store.groups[groupKey] ?? kDefaultSortMode; + + Future initialize() async { + if (_disposed || !_remoteEnabled || _relaySession == null) { + if (!_disposed) _onChanged(); + return; + } + await _syncWithRelay(); + if (!_disposed) _onChanged(); + } + + Future _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 (!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? liveSectionIds, + }) { + if (_disposed || _store.groups[groupKey] == mode) return; + final updated = ChannelSortStore( + groups: {..._store.groups, groupKey: mode}, + ); + _store = liveSectionIds == null + ? updated + : stripOrphanedSectionModes(updated, liveSectionIds); + _generation++; + _persist(); + _schedulePublish(); + _onChanged(); + } + + void _schedulePublish() { + if (!_remoteEnabled || _disposed) return; + _publishDebounce?.cancel(); + _publishDebounce = Timer(_publishDelay, () { + _publishDebounce = null; + unawaited(_publish()); + }); + } + + Future _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 _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; + 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 || parsed['version'] != 1) return; + final incoming = ChannelSortStore.fromJson(parsed); + _lastRemoteCreatedAt = event.createdAt; + _lastRemoteEventId = 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 _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. + await _fetchAndApply(); + if (_disposed || _generation != generationAtStart) return; + try { + final now = currentUnixSeconds(); + final createdAt = max(now, _lastRemoteCreatedAt + 1); + if (createdAt > now + _maxClockDriftSeconds) return; + final ciphertext = _crypto.encrypt(jsonEncode(_store.toJson())); + await _signedEventRelay.submit( + kind: EventKind.readState, + content: ciphertext, + tags: const [ + ['d', _dTag], + ['t', _dTag], + ], + createdAt: createdAt, + ); + if (_disposed || _generation != generationAtStart) return; + _lastRemoteCreatedAt = createdAt; + } catch (error) { + debugPrint('[ChannelSortManager] publish failed: $error'); + } + } + + void _persist() => _storage.write(pubkey, relayUrl, _store); + + void dispose() { + if (_disposed) return; + _disposed = true; + _publishDebounce?.cancel(); + _startupRetryTimer?.cancel(); + _unsubscribe?.call(); + _unsubscribe = null; + } +} diff --git a/mobile/lib/features/channels/channel_sort/channel_sort_provider.dart b/mobile/lib/features/channels/channel_sort/channel_sort_provider.dart new file mode 100644 index 0000000000..5a5d144660 --- /dev/null +++ b/mobile/lib/features/channels/channel_sort/channel_sort_provider.dart @@ -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 { + 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? 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.new, + ); + +String? _safePubkeyFromNsec(String nsec) { + try { + final privkeyHex = nostr.Nip19.decode(payload: nsec).data; + if (privkeyHex.isEmpty) return null; + return nostr.Keys(privkeyHex).public; + } catch (_) { + return null; + } +} diff --git a/mobile/lib/features/channels/channel_sort/channel_sort_storage.dart b/mobile/lib/features/channels/channel_sort/channel_sort_storage.dart new file mode 100644 index 0000000000..560cb6f6b2 --- /dev/null +++ b/mobile/lib/features/channels/channel_sort/channel_sort_storage.dart @@ -0,0 +1,151 @@ +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 groups; + + const ChannelSortStore({this.version = 1, this.groups = const {}}); + + Map toJson() => { + 'version': version, + 'groups': { + for (final entry in groups.entries) entry.key: entry.value.wireValue, + }, + }; + + factory ChannelSortStore.fromJson(Map json) { + final groups = {}; + 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 liveSectionIds, +) { + final liveKeys = {for (final id in liveSectionIds) sectionSortGroupKey(id)}; + final kept = { + 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 sortChannelsForList( + List 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 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 || 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()), + ); + } +} diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index ba5d4ebf9d..3ace5da5be 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -36,6 +36,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'; diff --git a/mobile/lib/features/channels/channels_page/body.dart b/mobile/lib/features/channels/channels_page/body.dart index 9f0b1dd4a7..87cc5dd73e 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.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(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>({}); @@ -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, ), ], diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index febe16e996..787a845355 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -18,6 +18,8 @@ class _CustomChannelSection extends StatelessWidget { final VoidCallback onDelete; final VoidCallback onMoveUp; final VoidCallback onMoveDown; + final ChannelSortMode sortMode; + final ValueChanged onSortModeChange; final Future 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 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,23 @@ class _SectionNameDialog extends HookWidget { } } +const _kSortRecentMenuValue = 'sort_recent'; +const _kSortAlphaMenuValue = 'sort_alpha'; + +List> _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 _ChannelSection extends StatelessWidget { final String title; final IconData icon; @@ -332,6 +364,8 @@ class _ChannelSection extends StatelessWidget { final Set mutedChannelIds; final String? currentPubkey; final String emptyLabel; + final ChannelSortMode? sortMode; + final ValueChanged? onSortModeChange; final Future Function(Channel channel) onSelectChannel; const _ChannelSection({ @@ -346,6 +380,8 @@ class _ChannelSection extends StatelessWidget { required this.mutedChannelIds, required this.currentPubkey, required this.emptyLabel, + this.sortMode, + this.onSortModeChange, required this.onSelectChannel, }); @@ -360,6 +396,8 @@ class _ChannelSection extends StatelessWidget { icon: icon, expanded: expanded, onToggle: onToggle, + sortMode: sortMode, + onSortModeChange: onSortModeChange, ), _AnimatedSectionBody( expanded: expanded, @@ -454,12 +492,16 @@ class _SectionHeader extends StatelessWidget { final IconData icon; final bool expanded; final VoidCallback onToggle; + final ChannelSortMode? sortMode; + final ValueChanged? onSortModeChange; const _SectionHeader({ required this.label, required this.icon, required this.expanded, required this.onToggle, + this.sortMode, + this.onSortModeChange, }); @override @@ -494,6 +536,44 @@ class _SectionHeader extends StatelessWidget { ), ), const Spacer(), + if (sortMode case final mode?) ...[ + Builder( + builder: (buttonContext) => IconButton( + key: ValueKey('sort-menu-$label'), + tooltip: 'Sort $label', + visualDensity: VisualDensity.compact, + icon: Icon( + LucideIcons.arrowUpDown, + size: _kChannelIconSize, + color: sectionColor, + ), + onPressed: () async { + final value = await showAnchoredPopover( + 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), + ); + 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), ], ), diff --git a/mobile/test/features/channels/channel_sort/channel_sort_manager_test.dart b/mobile/test/features/channels/channel_sort/channel_sort_manager_test.dart new file mode 100644 index 0000000000..cc4f425b31 --- /dev/null +++ b/mobile/test/features/channels/channel_sort/channel_sort_manager_test.dart @@ -0,0 +1,216 @@ +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 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; + expect(payload, { + 'version': 1, + 'groups': {'dms': 'recent'}, + }); + subject.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'}, 200)); + await Future.delayed(const Duration(milliseconds: 30)); + expect(subject.store.groups, {'dms': ChannelSortMode.recent}); + expect(signed.submitted.isCompleted, isFalse); + 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.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> tags; + const _SubmittedEvent(this.content, this.tags); +} + +class _RecordingSignedEventRelay implements SignedEventRelay { + final submitted = Completer<_SubmittedEvent>(); + + @override + String? get pubkey => null; + + @override + Future submit({ + required int kind, + required String content, + required List> tags, + int? createdAt, + void Function(NostrEvent event)? onSigned, + }) async { + if (!submitted.isCompleted) { + submitted.complete(_SubmittedEvent(content, tags)); + } + return const NostrEvent( + id: 'ack', + pubkey: '', + createdAt: 0, + kind: 0, + tags: [], + content: '', + sig: '', + ); + } +} + +class _FakeRelaySession extends RelaySessionNotifier { + List historyEvents = []; + int fetchFailures = 0; + int fetchCount = 0; + void Function(NostrEvent)? _listener; + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + fetchCount++; + if (fetchFailures > 0) { + fetchFailures--; + throw Exception('rate limited'); + } + return historyEvents; + } + + @override + Future 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); +} diff --git a/mobile/test/features/channels/channel_sort/channel_sort_storage_test.dart b/mobile/test/features/channels/channel_sort/channel_sort_storage_test.dart new file mode 100644 index 0000000000..a4afff0b21 --- /dev/null +++ b/mobile/test/features/channels/channel_sort/channel_sort_storage_test.dart @@ -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']); + }); + }); +} diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 56032a5dbf..69fe3f24e8 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -135,6 +135,13 @@ void main() { expect(find.text('DMs'), findsOneWidget); expect(find.text('Community'), findsOneWidget); expect(find.byTooltip('Create or start conversation'), findsOneWidget); + expect(find.byTooltip('Sort Channels'), findsOneWidget); + expect(find.byTooltip('Sort DMs'), findsOneWidget); + + await tester.tap(find.byTooltip('Sort Channels')); + await tester.pumpAndSettle(); + expect(find.text('Sort: Recent'), findsOneWidget); + expect(find.text('Sort: A–Z'), findsOneWidget); for (final label in ['general', 'Alice']) { final text = tester.widget(find.text(label)); From c206ed724548173102b50e0e789887c893660638 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Sat, 1 Aug 2026 19:52:51 -0700 Subject: [PATCH 2/9] fix(mobile): trail sort menu checkmark Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../channels/channels_page/sections.dart | 29 +++++++++++++++---- .../features/channels/channels_page_test.dart | 6 ++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index 787a845355..5de19dc39f 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -338,17 +338,34 @@ class _SectionNameDialog extends HookWidget { const _kSortRecentMenuValue = 'sort_recent'; const _kSortAlphaMenuValue = 'sort_alpha'; +PopupMenuItem _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> _sortMenuItems(ChannelSortMode current) => [ const PopupMenuDivider(), - CheckedPopupMenuItem( + _sortMenuItem( value: _kSortRecentMenuValue, - checked: current == ChannelSortMode.recent, - child: const Text('Sort: Recent'), + label: 'Sort: Recent', + selected: current == ChannelSortMode.recent, ), - CheckedPopupMenuItem( + _sortMenuItem( value: _kSortAlphaMenuValue, - checked: current == ChannelSortMode.alpha, - child: const Text('Sort: A–Z'), + label: 'Sort: A–Z', + selected: current == ChannelSortMode.alpha, ), ]; diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 69fe3f24e8..6a60c3bcf2 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -142,6 +142,12 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Sort: Recent'), findsOneWidget); expect(find.text('Sort: A–Z'), findsOneWidget); + final selectedCheck = find.byKey(const ValueKey('sort-selected-check')); + expect(selectedCheck, findsOneWidget); + expect( + tester.getCenter(selectedCheck).dx, + greaterThan(tester.getCenter(find.text('Sort: A–Z')).dx), + ); for (final label in ['general', 'Alice']) { final text = tester.widget(find.text(label)); From f8c88507b5e1fc84980bb0b2fd2b15f423139df1 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Sat, 1 Aug 2026 20:16:19 -0700 Subject: [PATCH 3/9] fix(mobile): retry failed sort preflight Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../channel_sort/channel_sort_manager.dart | 6 ++++- .../channel_sort_manager_test.dart | 26 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart b/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart index 46cb0469e8..ea05078458 100644 --- a/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart +++ b/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart @@ -237,8 +237,12 @@ class ChannelSortManager { final generationAtStart = _generation; // A newer remote blob wins. If one arrives during this read, _generation // changes and we abort rather than overwriting it. - await _fetchAndApply(); + final preflight = await _fetchAndApply(); if (_disposed || _generation != generationAtStart) return; + if (preflight == null) { + _schedulePublish(); + return; + } try { final now = currentUnixSeconds(); final createdAt = max(now, _lastRemoteCreatedAt + 1); diff --git a/mobile/test/features/channels/channel_sort/channel_sort_manager_test.dart b/mobile/test/features/channels/channel_sort/channel_sort_manager_test.dart index cc4f425b31..313f0f7502 100644 --- a/mobile/test/features/channels/channel_sort/channel_sort_manager_test.dart +++ b/mobile/test/features/channels/channel_sort/channel_sort_manager_test.dart @@ -91,6 +91,30 @@ void main() { 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.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; + expect(payload['groups'], {'dms': 'recent'}); + subject.dispose(); + }, + ); + test('newer remote event cancels pending local whole-blob write', () async { final relay = _FakeRelaySession(); final signed = _RecordingSignedEventRelay(); @@ -156,6 +180,7 @@ class _SubmittedEvent { class _RecordingSignedEventRelay implements SignedEventRelay { final submitted = Completer<_SubmittedEvent>(); + int submitCount = 0; @override String? get pubkey => null; @@ -168,6 +193,7 @@ class _RecordingSignedEventRelay implements SignedEventRelay { int? createdAt, void Function(NostrEvent event)? onSigned, }) async { + submitCount++; if (!submitted.isCompleted) { submitted.complete(_SubmittedEvent(content, tags)); } From 6804c3cd69f03478a3e901489df3f60048d48e67 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Sat, 1 Aug 2026 20:16:57 -0700 Subject: [PATCH 4/9] fix(mobile): match relay sort tie-break Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../channel_sort/channel_sort_manager.dart | 5 +- .../channel_sort_manager_test.dart | 50 ++++++++++++++++++- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart b/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart index ea05078458..9ed4cf3639 100644 --- a/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart +++ b/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart @@ -203,7 +203,7 @@ class ChannelSortManager { final isNewer = event.createdAt > _lastRemoteCreatedAt || (event.createdAt == _lastRemoteCreatedAt && - event.id.compareTo(_lastRemoteEventId) > 0); + event.id.compareTo(_lastRemoteEventId) < 0); if (!isNewer) return; try { final parsed = jsonDecode(_crypto.decrypt(event.content)); @@ -248,6 +248,7 @@ class ChannelSortManager { final createdAt = max(now, _lastRemoteCreatedAt + 1); if (createdAt > now + _maxClockDriftSeconds) return; final ciphertext = _crypto.encrypt(jsonEncode(_store.toJson())); + String? submittedEventId; await _signedEventRelay.submit( kind: EventKind.readState, content: ciphertext, @@ -256,9 +257,11 @@ class ChannelSortManager { ['t', _dTag], ], createdAt: createdAt, + onSigned: (event) => submittedEventId = event.id, ); if (_disposed || _generation != generationAtStart) return; _lastRemoteCreatedAt = createdAt; + _lastRemoteEventId = submittedEventId ?? ''; } catch (error) { debugPrint('[ChannelSortManager] publish failed: $error'); } diff --git a/mobile/test/features/channels/channel_sort/channel_sort_manager_test.dart b/mobile/test/features/channels/channel_sort/channel_sort_manager_test.dart index 313f0f7502..217f52f21b 100644 --- a/mobile/test/features/channels/channel_sort/channel_sort_manager_test.dart +++ b/mobile/test/features/channels/channel_sort/channel_sort_manager_test.dart @@ -128,6 +128,40 @@ void main() { subject.dispose(); }); + 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( + 'keeps a lower-ID same-second 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!, id: 'a')); + expect(subject.store.groups, {'dms': ChannelSortMode.recent}); + subject.dispose(); + }, + ); + test( 'future-dated remote event is ignored and cannot wedge publishing', () async { @@ -175,7 +209,8 @@ void main() { class _SubmittedEvent { final String content; final List> tags; - const _SubmittedEvent(this.content, this.tags); + final int? createdAt; + const _SubmittedEvent(this.content, this.tags, this.createdAt); } class _RecordingSignedEventRelay implements SignedEventRelay { @@ -194,8 +229,19 @@ class _RecordingSignedEventRelay implements SignedEventRelay { 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)); + submitted.complete(_SubmittedEvent(content, tags, createdAt)); } return const NostrEvent( id: 'ack', From 91b40584ed2a5c24423fed8b49b2f9f441facbf7 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Mon, 3 Aug 2026 09:15:18 -0700 Subject: [PATCH 5/9] test(mobile): scope section menu style assertions Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../features/channels/channels_page_test.dart | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 6a60c3bcf2..91f2fc443b 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -262,16 +262,25 @@ void main() { ); } - final menuItems = tester.widgetList>( - find.descendant( - of: popover, - matching: find.byWidgetPredicate( - (widget) => widget is PopupMenuItem, - ), - ), - ); - expect(menuItems, hasLength(4)); - for (final item in menuItems) { + final actionMenuItems = tester + .widgetList>( + find.descendant( + of: popover, + matching: find.byWidgetPredicate( + (widget) => widget is PopupMenuItem, + ), + ), + ) + .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), From 4af55c694d8ab5e3074048bdc47dc4b049a00a28 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Mon, 3 Aug 2026 11:03:31 -0700 Subject: [PATCH 6/9] fix(mobile): preserve pending channel sort edits Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../channel_sort/channel_sort_manager.dart | 33 ++++++++++-- .../channel_sort/channel_sort_storage.dart | 52 +++++++++++++++++++ .../channel_sort_manager_test.dart | 34 +++++++++++- 3 files changed, 115 insertions(+), 4 deletions(-) diff --git a/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart b/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart index 9ed4cf3639..230bc96aa7 100644 --- a/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart +++ b/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart @@ -46,6 +46,7 @@ class ChannelSortManager { final Duration _publishDelay; ChannelSortStore _store; + ChannelSortSyncState _syncState; Timer? _publishDebounce; Timer? _startupRetryTimer; int _startupRetryAttempt = 0; @@ -75,7 +76,11 @@ class ChannelSortManager { _onChanged = onChanged, _startupRetryBaseDelay = startupRetryBaseDelay, _publishDelay = publishDelay, - _store = ChannelSortStorage(prefs).read(pubkey, relayUrl); + _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) => @@ -102,7 +107,8 @@ class ChannelSortManager { return; } _startupRetryAttempt = 0; - if (!firstFetch && !secondFetch && _store.groups.isNotEmpty) { + if (_syncState.hasPendingLocalChanges || + (!firstFetch && !secondFetch && _store.groups.isNotEmpty)) { _schedulePublish(); } } @@ -137,6 +143,11 @@ class ChannelSortManager { _store = liveSectionIds == null ? updated : stripOrphanedSectionModes(updated, liveSectionIds); + _syncState = ChannelSortSyncState( + updatedAt: max(currentUnixSeconds(), _syncState.updatedAt + 1), + eventId: _syncState.eventId, + hasPendingLocalChanges: true, + ); _generation++; _persist(); _schedulePublish(); @@ -200,6 +211,10 @@ class ChannelSortManager { void _applyRemote(NostrEvent event) { if (event.createdAt > currentUnixSeconds() + _maxClockDriftSeconds) return; + if (_syncState.hasPendingLocalChanges && + event.createdAt < _syncState.updatedAt) { + return; + } final isNewer = event.createdAt > _lastRemoteCreatedAt || (event.createdAt == _lastRemoteCreatedAt && @@ -211,6 +226,10 @@ class ChannelSortManager { 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; @@ -262,12 +281,20 @@ class ChannelSortManager { if (_disposed || _generation != generationAtStart) return; _lastRemoteCreatedAt = createdAt; _lastRemoteEventId = submittedEventId ?? ''; + _syncState = ChannelSortSyncState( + updatedAt: createdAt, + eventId: _lastRemoteEventId, + ); + _persist(); } catch (error) { debugPrint('[ChannelSortManager] publish failed: $error'); } } - void _persist() => _storage.write(pubkey, relayUrl, _store); + void _persist() { + _storage.write(pubkey, relayUrl, _store); + _storage.writeSyncState(pubkey, relayUrl, _syncState); + } void dispose() { if (_disposed) return; diff --git a/mobile/lib/features/channels/channel_sort/channel_sort_storage.dart b/mobile/lib/features/channels/channel_sort/channel_sort_storage.dart index 560cb6f6b2..1b031f4760 100644 --- a/mobile/lib/features/channels/channel_sort/channel_sort_storage.dart +++ b/mobile/lib/features/channels/channel_sort/channel_sort_storage.dart @@ -103,6 +103,31 @@ List sortChannelsForList( return sorted; } +class ChannelSortSyncState { + final int updatedAt; + final String eventId; + final bool hasPendingLocalChanges; + + const ChannelSortSyncState({ + this.updatedAt = 0, + this.eventId = '', + this.hasPendingLocalChanges = false, + }); + + Map toJson() => { + 'updatedAt': updatedAt, + 'eventId': eventId, + 'hasPendingLocalChanges': hasPendingLocalChanges, + }; + + factory ChannelSortSyncState.fromJson(Map json) => + ChannelSortSyncState( + updatedAt: json['updatedAt'] is int ? json['updatedAt'] as int : 0, + eventId: json['eventId'] is String ? json['eventId'] as String : '', + hasPendingLocalChanges: json['hasPendingLocalChanges'] == true, + ); +} + class ChannelSortStorage { final SharedPreferences _prefs; @@ -148,4 +173,31 @@ class ChannelSortStorage { 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 + ? 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'; } diff --git a/mobile/test/features/channels/channel_sort/channel_sort_manager_test.dart b/mobile/test/features/channels/channel_sort/channel_sort_manager_test.dart index 217f52f21b..3670a12021 100644 --- a/mobile/test/features/channels/channel_sort/channel_sort_manager_test.dart +++ b/mobile/test/features/channels/channel_sort/channel_sort_manager_test.dart @@ -115,13 +115,45 @@ void main() { }, ); + 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; + 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'}, 200)); + relay.emit( + event({ + 'dms': 'recent', + }, DateTime.now().millisecondsSinceEpoch ~/ 1000 + 1), + ); await Future.delayed(const Duration(milliseconds: 30)); expect(subject.store.groups, {'dms': ChannelSortMode.recent}); expect(signed.submitted.isCompleted, isFalse); From 651dbf76e1b75ace89ce91ac69b9e12a11037d8f Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Mon, 3 Aug 2026 13:27:03 -0700 Subject: [PATCH 7/9] fix(mobile): unify channel section menus Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../channels/channels_page/sections.dart | 13 ++++++----- .../features/channels/channels_page_test.dart | 22 +++++++++++++++---- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index 5de19dc39f..fa559c12bf 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -355,8 +355,11 @@ PopupMenuItem _sortMenuItem({ ), ); -List> _sortMenuItems(ChannelSortMode current) => [ - const PopupMenuDivider(), +List> _sortMenuItems( + ChannelSortMode current, { + bool showDivider = true, +}) => [ + if (showDivider) const PopupMenuDivider(), _sortMenuItem( value: _kSortRecentMenuValue, label: 'Sort: Recent', @@ -557,10 +560,10 @@ class _SectionHeader extends StatelessWidget { Builder( builder: (buttonContext) => IconButton( key: ValueKey('sort-menu-$label'), - tooltip: 'Sort $label', + tooltip: '$label options', visualDensity: VisualDensity.compact, icon: Icon( - LucideIcons.arrowUpDown, + LucideIcons.ellipsisVertical, size: _kChannelIconSize, color: sectionColor, ), @@ -579,7 +582,7 @@ class _SectionHeader extends StatelessWidget { side: BorderSide(color: context.colors.outline), ), surfaceKey: ValueKey('sort-popover-$label'), - items: _sortMenuItems(mode), + items: _sortMenuItems(mode, showDivider: false), ); if (value == _kSortRecentMenuValue) { onSortModeChange?.call(ChannelSortMode.recent); diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 91f2fc443b..30f8815d73 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -135,13 +135,21 @@ void main() { expect(find.text('DMs'), findsOneWidget); expect(find.text('Community'), findsOneWidget); expect(find.byTooltip('Create or start conversation'), findsOneWidget); - expect(find.byTooltip('Sort Channels'), findsOneWidget); - expect(find.byTooltip('Sort DMs'), 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('Sort Channels')); + await tester.tap(find.byTooltip('Channels options')); await tester.pumpAndSettle(); expect(find.text('Sort: Recent'), findsOneWidget); expect(find.text('Sort: A–Z'), 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( @@ -506,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( From a66df858d8081577f9a2f8bcb9e892bfb6b600da Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Mon, 3 Aug 2026 17:06:03 -0700 Subject: [PATCH 8/9] fix(mobile): prevent channel sort sync wedges Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../channel_sort/channel_sort_manager.dart | 30 +++++-- .../channel_sort/channel_sort_storage.dart | 28 ++++-- .../channel_sort_manager_test.dart | 89 +++++++++++++++---- 3 files changed, 116 insertions(+), 31 deletions(-) diff --git a/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart b/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart index 230bc96aa7..84b5d6aabf 100644 --- a/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart +++ b/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart @@ -143,10 +143,16 @@ class ChannelSortManager { _store = liveSectionIds == null ? updated : stripOrphanedSectionModes(updated, liveSectionIds); + final now = currentUnixSeconds(); + final pendingUpdatedAt = min( + max(now, _syncState.pendingUpdatedAt + 1), + now + _maxClockDriftSeconds, + ); _syncState = ChannelSortSyncState( - updatedAt: max(currentUnixSeconds(), _syncState.updatedAt + 1), + updatedAt: _syncState.updatedAt, eventId: _syncState.eventId, hasPendingLocalChanges: true, + pendingUpdatedAt: pendingUpdatedAt, ); _generation++; _persist(); @@ -212,7 +218,7 @@ class ChannelSortManager { void _applyRemote(NostrEvent event) { if (event.createdAt > currentUnixSeconds() + _maxClockDriftSeconds) return; if (_syncState.hasPendingLocalChanges && - event.createdAt < _syncState.updatedAt) { + event.createdAt <= _syncState.pendingUpdatedAt) { return; } final isNewer = @@ -265,9 +271,15 @@ class ChannelSortManager { try { final now = currentUnixSeconds(); final createdAt = max(now, _lastRemoteCreatedAt + 1); - if (createdAt > now + _maxClockDriftSeconds) return; + 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, @@ -276,14 +288,14 @@ class ChannelSortManager { ['t', _dTag], ], createdAt: createdAt, - onSigned: (event) => submittedEventId = event.id, ); if (_disposed || _generation != generationAtStart) return; - _lastRemoteCreatedAt = createdAt; - _lastRemoteEventId = submittedEventId ?? ''; + // Publishing clears durable pending state, but only remote adoption may + // advance the sync cursor. This matches desktop and prevents local + // publications from inflating the next event's timestamp. _syncState = ChannelSortSyncState( - updatedAt: createdAt, - eventId: _lastRemoteEventId, + updatedAt: _syncState.updatedAt, + eventId: _syncState.eventId, ); _persist(); } catch (error) { diff --git a/mobile/lib/features/channels/channel_sort/channel_sort_storage.dart b/mobile/lib/features/channels/channel_sort/channel_sort_storage.dart index 1b031f4760..90bd0913ef 100644 --- a/mobile/lib/features/channels/channel_sort/channel_sort_storage.dart +++ b/mobile/lib/features/channels/channel_sort/channel_sort_storage.dart @@ -107,25 +107,41 @@ 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 toJson() => { 'updatedAt': updatedAt, 'eventId': eventId, 'hasPendingLocalChanges': hasPendingLocalChanges, + 'pendingUpdatedAt': pendingUpdatedAt, }; - factory ChannelSortSyncState.fromJson(Map json) => - ChannelSortSyncState( - updatedAt: json['updatedAt'] is int ? json['updatedAt'] as int : 0, - eventId: json['eventId'] is String ? json['eventId'] as String : '', - hasPendingLocalChanges: json['hasPendingLocalChanges'] == true, - ); + factory ChannelSortSyncState.fromJson(Map 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 { diff --git a/mobile/test/features/channels/channel_sort/channel_sort_manager_test.dart b/mobile/test/features/channels/channel_sort/channel_sort_manager_test.dart index 3670a12021..b409942808 100644 --- a/mobile/test/features/channels/channel_sort/channel_sort_manager_test.dart +++ b/mobile/test/features/channels/channel_sort/channel_sort_manager_test.dart @@ -160,6 +160,66 @@ void main() { 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.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 = [ @@ -176,23 +236,20 @@ void main() { subject.dispose(); }); - test( - 'keeps a lower-ID same-second relay replacement after publishing', - () async { - final relay = _FakeRelaySession(); - final signed = _RecordingSignedEventRelay(); - final subject = manager(relay, signed); - await subject.initialize(); + 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!, id: 'a')); - expect(subject.store.groups, {'dms': ChannelSortMode.recent}); - subject.dispose(); - }, - ); + 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', From 9a643130b73409bc5248e3b2ec570735db398d4a Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Mon, 3 Aug 2026 17:08:27 -0700 Subject: [PATCH 9/9] fix(mobile): keep sort publish cursor volatile Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../channels/channel_sort/channel_sort_manager.dart | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart b/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart index 84b5d6aabf..629e1bc322 100644 --- a/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart +++ b/mobile/lib/features/channels/channel_sort/channel_sort_manager.dart @@ -280,6 +280,7 @@ class ChannelSortManager { return; } final ciphertext = _crypto.encrypt(jsonEncode(_store.toJson())); + String? submittedEventId; await _signedEventRelay.submit( kind: EventKind.readState, content: ciphertext, @@ -288,11 +289,13 @@ class ChannelSortManager { ['t', _dTag], ], createdAt: createdAt, + onSigned: (event) => submittedEventId = event.id, ); if (_disposed || _generation != generationAtStart) return; - // Publishing clears durable pending state, but only remote adoption may - // advance the sync cursor. This matches desktop and prevents local - // publications from inflating the next event's timestamp. + // 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,