Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class ChannelSectionsCrypto {

class ChannelSectionsManager {
final String pubkey;
final String relayUrl;
final ChannelSectionsStorage _storage;
final ChannelSectionsCrypto _crypto;
final RelaySessionNotifier? _relaySession;
Expand All @@ -59,6 +60,7 @@ class ChannelSectionsManager {

ChannelSectionsManager({
required this.pubkey,
required this.relayUrl,
required SharedPreferences prefs,
required ChannelSectionsCrypto crypto,
required RelaySessionNotifier? relaySession,
Expand All @@ -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;

Expand Down Expand Up @@ -469,7 +471,7 @@ class ChannelSectionsManager {
}

void _persist() {
_storage.write(pubkey, _store);
_storage.write(pubkey, relayUrl, _store);
}

List<ChannelSection> _sortedSections() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ class ChannelSectionsNotifier extends Notifier<ChannelSectionsState> {

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) {
Expand All @@ -51,6 +51,14 @@ class ChannelSectionsNotifier extends Notifier<ChannelSectionsState> {
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),
Expand All @@ -60,6 +68,7 @@ class ChannelSectionsNotifier extends Notifier<ChannelSectionsState> {
late final ChannelSectionsManager manager;
manager = ChannelSectionsManager(
pubkey: pubkey,
relayUrl: relayUrl,
prefs: prefs,
crypto: crypto,
relaySession: ref.read(relaySessionProvider.notifier),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String, dynamic>) {
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()),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ void main() {
}) {
return ChannelSectionsManager(
pubkey: keychain.public,
relayUrl: 'wss://relay.example',
prefs: prefs,
crypto: crypto,
relaySession: relaySession,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Community?>();

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<void>.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<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async => [];

@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async => () {};
}
Original file line number Diff line number Diff line change
@@ -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', () {
Expand Down Expand Up @@ -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': <Map<String, dynamic>>[],
'assignments': <String, String>{},
}),
});
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);
});
});
}