diff --git a/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart b/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart index e36a514ff66..2dc1ef83c89 100644 --- a/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart +++ b/mobile/lib/features/channels/channel_sections/channel_sections_manager.dart @@ -33,6 +33,7 @@ class ChannelSectionsCrypto { class ChannelSectionsManager { final String pubkey; + final String relayUrl; final ChannelSectionsStorage _storage; final ChannelSectionsCrypto _crypto; final RelaySessionNotifier? _relaySession; @@ -59,6 +60,7 @@ class ChannelSectionsManager { ChannelSectionsManager({ required this.pubkey, + required this.relayUrl, required SharedPreferences prefs, required ChannelSectionsCrypto crypto, required RelaySessionNotifier? relaySession, @@ -74,7 +76,7 @@ class ChannelSectionsManager { _remoteEnabled = remoteEnabled, _onChanged = onChanged, _startupRetryBaseDelay = startupRetryBaseDelay, - _store = ChannelSectionsStorage(prefs).read(pubkey); + _store = ChannelSectionsStorage(prefs).read(pubkey, relayUrl); ChannelSectionStore get store => _store; @@ -469,7 +471,7 @@ class ChannelSectionsManager { } void _persist() { - _storage.write(pubkey, _store); + _storage.write(pubkey, relayUrl, _store); } List _sortedSections() { diff --git a/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart b/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart index 2caa35c25f4..ba48f78d4f4 100644 --- a/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart +++ b/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart @@ -31,8 +31,8 @@ class ChannelSectionsNotifier extends Notifier { final relayConfig = ref.watch(relayConfigProvider); final sessionState = ref.watch(relaySessionProvider); - // Rebuild when the active community changes (pubkey may differ). - ref.watch(activeCommunityProvider); + // Rebuild when the active community changes (pubkey/relay may differ). + final activeCommunity = ref.watch(activeCommunityProvider).value; final nsec = relayConfig.nsec?.trim(); if (nsec == null || nsec.isEmpty) { @@ -51,6 +51,14 @@ class ChannelSectionsNotifier extends Notifier { return const ChannelSectionsState(); } + // Sections are relay-scoped (Desktop #1477). The active community origin is + // the only scope key — no fallback, so the one-time legacy migration can + // never be consumed under a different key while the community is loading. + final relayUrl = activeCommunity?.relayUrl.trim(); + if (relayUrl == null || relayUrl.isEmpty) { + return const ChannelSectionsState(); + } + final prefs = ref.read(savedPrefsProvider); final signedRelay = SignedEventRelay( session: ref.read(relaySessionProvider.notifier), @@ -60,6 +68,7 @@ class ChannelSectionsNotifier extends Notifier { late final ChannelSectionsManager manager; manager = ChannelSectionsManager( pubkey: pubkey, + relayUrl: relayUrl, prefs: prefs, crypto: crypto, relaySession: ref.read(relaySessionProvider.notifier), diff --git a/mobile/lib/features/channels/channel_sections/channel_sections_storage.dart b/mobile/lib/features/channels/channel_sections/channel_sections_storage.dart index 01643c6d54d..50e9e47cac0 100644 --- a/mobile/lib/features/channels/channel_sections/channel_sections_storage.dart +++ b/mobile/lib/features/channels/channel_sections/channel_sections_storage.dart @@ -2,7 +2,18 @@ import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; -String channelSectionsKey(String pubkey) => 'buzz.channel-sections.v1:$pubkey'; +/// Normalizes a relay URL for storage keys so equivalent URLs share a scope. +/// Matches desktop `normalizeRelayUrl` and mobile channel-sort. +String normalizeChannelSectionsRelayUrl(String relayUrl) => + relayUrl.trim().replaceFirst(RegExp(r'/+$'), '').toLowerCase(); + +/// Relay-scoped local storage key for channel sections. +String channelSectionsKey(String pubkey, String relayUrl) => + 'buzz.channel-sections.v1:$pubkey:${Uri.encodeComponent(normalizeChannelSectionsRelayUrl(relayUrl))}'; + +/// Pre-scoping key (account-global). Used only for one-time migration. +String legacyChannelSectionsKey(String pubkey) => + 'buzz.channel-sections.v1:$pubkey'; class ChannelSection { final String id; @@ -90,27 +101,53 @@ class ChannelSectionsStorage { ChannelSectionsStorage(this._prefs); - ChannelSectionStore read(String pubkey) { - final raw = _prefs.getString(channelSectionsKey(pubkey)); + /// Read sections for [pubkey] scoped to [relayUrl]. + /// + /// On first access for a scoped key, migrates any existing data from the + /// legacy pubkey-only key so users don't lose their sections on upgrade. + /// After a successful migration the legacy key is deleted — subsequent + /// empty scoped-key reads (a different relay) won't see legacy data. + ChannelSectionStore read(String pubkey, String relayUrl) { + final scoped = _readKey(channelSectionsKey(pubkey, relayUrl)); + if (scoped != null) return scoped; + + // One-time migration from account-global storage (pre-relay-scope). + // The first active relay claims the legacy value; removing it prevents + // the same unscoped sections from bleeding into later communities. + final legacy = _readKey(legacyChannelSectionsKey(pubkey)); + if (legacy != null && legacy.sections.isNotEmpty) { + write(pubkey, relayUrl, legacy); + _prefs.remove(legacyChannelSectionsKey(pubkey)); + return legacy; + } + + return const ChannelSectionStore(); + } + + ChannelSectionStore? _readKey(String key) { + final raw = _prefs.getString(key); if (raw == null || raw.isEmpty) { - return const ChannelSectionStore(); + return null; } try { final parsed = jsonDecode(raw); if (parsed is! Map) { - return const ChannelSectionStore(); + return null; } if (parsed['version'] != 1) { - return const ChannelSectionStore(); + return null; } return ChannelSectionStore.fromJson(parsed); } catch (_) { - return const ChannelSectionStore(); + return null; } } - void write(String pubkey, ChannelSectionStore store) { - _prefs.setString(channelSectionsKey(pubkey), jsonEncode(store.toJson())); + void write(String pubkey, String relayUrl, ChannelSectionStore store) { + _prefs.setString( + channelSectionsKey(pubkey, relayUrl), + jsonEncode(store.toJson()), + ); } } diff --git a/mobile/test/features/channels/channel_sections/channel_sections_manager_test.dart b/mobile/test/features/channels/channel_sections/channel_sections_manager_test.dart index 16d1936d397..93a2c40a431 100644 --- a/mobile/test/features/channels/channel_sections/channel_sections_manager_test.dart +++ b/mobile/test/features/channels/channel_sections/channel_sections_manager_test.dart @@ -50,6 +50,7 @@ void main() { }) { return ChannelSectionsManager( pubkey: keychain.public, + relayUrl: 'wss://relay.example', prefs: prefs, crypto: crypto, relaySession: relaySession, diff --git a/mobile/test/features/channels/channel_sections/channel_sections_provider_test.dart b/mobile/test/features/channels/channel_sections/channel_sections_provider_test.dart new file mode 100644 index 00000000000..818d6632c7d --- /dev/null +++ b/mobile/test/features/channels/channel_sections/channel_sections_provider_test.dart @@ -0,0 +1,119 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:buzz/features/channels/channel_sections/channel_sections_provider.dart'; +import 'package:buzz/features/channels/channel_sections/channel_sections_storage.dart'; +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/community/community_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Cold-start regression for the one-time legacy migration. +/// +/// `activeCommunityProvider` is a FutureProvider, so `.value` is null on the +/// first mount. The old `relayConfig.baseUrl` fallback consumed the migration +/// under a config-derived key; when the community later resolved to a +/// different origin the real key was empty and the legacy blob was gone. +void main() { + test('does not consume the legacy migration until the community relay URL ' + 'is known', () async { + final keys = nostr.Keys.generate(); + const configUrl = 'https://config.example'; + const communityUrl = 'wss://community.example'; + final community = Community.create( + name: 'Lit Box', + relayUrl: communityUrl, + nsec: keys.nsec, + ); + + SharedPreferences.setMockInitialValues({ + legacyChannelSectionsKey(keys.public): jsonEncode({ + 'version': 1, + 'sections': [ + {'id': 's1', 'name': 'estimates', 'order': 0}, + ], + 'assignments': {'chan-1': 's1'}, + }), + }); + final prefs = await SharedPreferences.getInstance(); + final communityReady = Completer(); + + final container = ProviderContainer( + overrides: [ + savedPrefsProvider.overrideWithValue(prefs), + relayConfigProvider.overrideWith( + () => _FakeRelayConfig(nsec: keys.nsec, baseUrl: configUrl), + ), + relaySessionProvider.overrideWith(_FakeRelaySession.new), + activeCommunityProvider.overrideWith((ref) => communityReady.future), + ], + ); + addTearDown(container.dispose); + + final subscription = container.listen(channelSectionsProvider, (_, _) {}); + addTearDown(subscription.close); + + final cold = container.read(channelSectionsProvider); + expect(cold.isReady, isFalse); + expect(cold.store.sections, isEmpty); + expect(prefs.getString(legacyChannelSectionsKey(keys.public)), isNotNull); + expect(prefs.getString(channelSectionsKey(keys.public, configUrl)), isNull); + expect( + prefs.getString(channelSectionsKey(keys.public, communityUrl)), + isNull, + ); + + communityReady.complete(community); + await container.read(activeCommunityProvider.future); + for ( + var i = 0; + i < 20 && !container.read(channelSectionsProvider).isReady; + i++ + ) { + await Future.delayed(Duration.zero); + } + + final ready = container.read(channelSectionsProvider); + expect(ready.isReady, isTrue); + expect(ready.store.sections.single.name, 'estimates'); + expect(prefs.getString(legacyChannelSectionsKey(keys.public)), isNull); + expect( + prefs.getString(channelSectionsKey(keys.public, communityUrl)), + isNotNull, + ); + expect(prefs.getString(channelSectionsKey(keys.public, configUrl)), isNull); + }); +} + +class _FakeRelayConfig extends RelayConfigNotifier { + _FakeRelayConfig({required this.nsec, required this.baseUrl}); + + final String nsec; + final String baseUrl; + + @override + RelayConfig build() => RelayConfig(baseUrl: baseUrl, nsec: nsec); +} + +class _FakeRelaySession extends RelaySessionNotifier { + @override + SessionState build() => + const SessionState(status: SessionStatus.disconnected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => []; + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async => () {}; +} diff --git a/mobile/test/features/channels/channel_sections/channel_sections_storage_test.dart b/mobile/test/features/channels/channel_sections/channel_sections_storage_test.dart index 09b84161c58..04f2347ac8e 100644 --- a/mobile/test/features/channels/channel_sections/channel_sections_storage_test.dart +++ b/mobile/test/features/channels/channel_sections/channel_sections_storage_test.dart @@ -1,5 +1,8 @@ +import 'dart:convert'; + import 'package:buzz/features/channels/channel_sections/channel_sections_storage.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; void main() { group('ChannelSection icon', () { @@ -40,4 +43,71 @@ void main() { expect(section.toJson()['icon'], ''); }); }); + + group('ChannelSectionsStorage', () { + test('normalizes relay scope and isolates communities', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final storage = ChannelSectionsStorage(prefs); + final store = ChannelSectionStore( + sections: const [ + ChannelSection(id: 's1', name: 'production', order: 0), + ], + ); + storage.write('pk', ' WSS://Relay.Example/ ', store); + expect( + storage.read('pk', 'wss://relay.example').sections.single.name, + 'production', + ); + expect(storage.read('pk', 'wss://other.example').sections, isEmpty); + }); + + test('migrates legacy unscoped cache into the first relay scope', () async { + SharedPreferences.setMockInitialValues({ + legacyChannelSectionsKey('pk'): jsonEncode({ + 'version': 1, + 'sections': [ + {'id': 's1', 'name': 'estimates', 'order': 0}, + ], + 'assignments': {'chan-1': 's1'}, + }), + }); + final prefs = await SharedPreferences.getInstance(); + final storage = ChannelSectionsStorage(prefs); + final migrated = storage.read('pk', 'wss://one'); + expect(migrated.sections.single.name, 'estimates'); + expect(migrated.assignments, {'chan-1': 's1'}); + expect(prefs.getString(channelSectionsKey('pk', 'wss://one')), isNotNull); + expect(prefs.getString(legacyChannelSectionsKey('pk')), isNull); + // Second community must not inherit the migrated legacy blob. + expect(storage.read('pk', 'wss://two').sections, isEmpty); + }); + + test('ignores corrupt and unsupported payloads', () async { + SharedPreferences.setMockInitialValues({ + channelSectionsKey('pk', 'wss://one'): 'nope', + channelSectionsKey('pk', 'wss://two'): '{"version":2,"sections":[]}', + }); + final prefs = await SharedPreferences.getInstance(); + final storage = ChannelSectionsStorage(prefs); + expect(storage.read('pk', 'wss://one').sections, isEmpty); + expect(storage.read('pk', 'wss://two').sections, isEmpty); + }); + + test('empty legacy store does not claim first relay', () async { + SharedPreferences.setMockInitialValues({ + legacyChannelSectionsKey('pk'): jsonEncode({ + 'version': 1, + 'sections': >[], + 'assignments': {}, + }), + }); + final prefs = await SharedPreferences.getInstance(); + final storage = ChannelSectionsStorage(prefs); + expect(storage.read('pk', 'wss://one').sections, isEmpty); + // Empty legacy is not migrated, so key may still exist — but scoped + // key must not be written for an empty claim. + expect(prefs.getString(channelSectionsKey('pk', 'wss://one')), isNull); + }); + }); }