diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index b1dad2a5c9..057594dfad 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -50,9 +50,13 @@ class App extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final themeMode = ref.watch(themeProvider); - final accentIndex = ref.watch(accentProvider); - final schemeName = ref.watch(schemeProvider); + final communityTheme = ref.watch(communityThemeProvider); + final themeMode = communityTheme.mode; + final accentIndex = effectiveAccentIndex( + communityTheme.theme, + communityTheme.accent, + ); + final schemeName = communityTheme.theme; final authState = ref.watch(authProvider); final resolved = resolveSchemes(schemeName, themeMode); diff --git a/mobile/lib/features/settings/accent_picker_page.dart b/mobile/lib/features/settings/accent_picker_page.dart index 6f174df741..88965e251f 100644 --- a/mobile/lib/features/settings/accent_picker_page.dart +++ b/mobile/lib/features/settings/accent_picker_page.dart @@ -15,7 +15,9 @@ class AccentPickerPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final selected = ref.watch(accentProvider); + final selected = + accentIndexForWireValue(ref.watch(communityThemeProvider).accent) ?? + defaultAccentIndex; final colorScheme = context.colors; return FrostedScaffold( @@ -31,7 +33,8 @@ class AccentPickerPage extends ConsumerWidget { color: accentColorForScheme(colorScheme, i), label: accentColors[i].name, selected: selected == i, - onTap: () => ref.read(accentProvider.notifier).setAccent(i), + onTap: () => + ref.read(communityThemeProvider.notifier).setAccent(i), ), ], ), diff --git a/mobile/lib/features/settings/settings_page/appearance_section.dart b/mobile/lib/features/settings/settings_page/appearance_section.dart index 26c9d20fe1..a1cc15639d 100644 --- a/mobile/lib/features/settings/settings_page/appearance_section.dart +++ b/mobile/lib/features/settings/settings_page/appearance_section.dart @@ -15,12 +15,16 @@ class _AppearanceSection extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final mode = ref.watch(themeProvider); - final schemeName = ref.watch(schemeProvider); - final accentIndex = ref.watch(accentProvider); + final preference = ref.watch(communityThemeProvider); + final mode = preference.mode; + final schemeName = preference.theme; + final accentIndex = effectiveAccentIndex( + preference.theme, + preference.accent, + ); return AppListCard( - label: 'Style', + label: 'Style · This community', children: [ AppListRow( icon: LucideIcons.sunMoon, @@ -38,16 +42,17 @@ class _AppearanceSection extends ConsumerWidget { MaterialPageRoute(builder: (_) => const ThemePickerPage()), ), ), - AppListRow( - icon: LucideIcons.droplet, - title: 'Accent color', - // The swatch *is* the value — naming the color as well would say the - // same thing twice, so it takes the chevron's place. - trailing: _AccentSwatch(accentIndex: accentIndex), - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const AccentPickerPage()), + if (!isBuzzTheme(effectiveTheme(schemeName, mode)?.name ?? schemeName)) + AppListRow( + icon: LucideIcons.droplet, + title: 'Accent color', + // The swatch *is* the value — naming the color as well would say the + // same thing twice, so it takes the chevron's place. + trailing: _AccentSwatch(accentIndex: accentIndex), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const AccentPickerPage()), + ), ), - ), ], ); } @@ -68,7 +73,7 @@ class _AppearanceModeSheet extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final mode = ref.watch(themeProvider); + final mode = ref.watch(communityThemeProvider).mode; return SafeArea( child: Column( @@ -96,15 +101,7 @@ class _AppearanceModeSheet extends ConsumerWidget { ) : null, onTap: () { - final schemeName = ref.read(schemeProvider); - final compatibleScheme = schemeForAppearanceMode( - schemeName, - option.mode, - ); - if (compatibleScheme != schemeName) { - ref.read(schemeProvider.notifier).setScheme(compatibleScheme); - } - ref.read(themeProvider.notifier).setMode(option.mode); + ref.read(communityThemeProvider.notifier).setMode(option.mode); Navigator.of(context).pop(); }, ), diff --git a/mobile/lib/features/settings/theme_picker_page.dart b/mobile/lib/features/settings/theme_picker_page.dart index aba6579148..bd2c8fae32 100644 --- a/mobile/lib/features/settings/theme_picker_page.dart +++ b/mobile/lib/features/settings/theme_picker_page.dart @@ -18,8 +18,9 @@ class ThemePickerPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final mode = ref.watch(themeProvider); - final selectedScheme = ref.watch(schemeProvider); + final preference = ref.watch(communityThemeProvider); + final mode = preference.mode; + final selectedScheme = preference.theme; final searchQuery = useState(''); final searchController = useTextEditingController(); final scrollController = useScrollController(); @@ -112,8 +113,8 @@ class ThemePickerPage extends HookConsumerWidget { label: labelFor(theme), selected: isSelected(theme), onTap: () => ref - .read(schemeProvider.notifier) - .setScheme(theme.name), + .read(communityThemeProvider.notifier) + .setTheme(theme.name), ); }, ), diff --git a/mobile/lib/shared/theme/accent_colors.dart b/mobile/lib/shared/theme/accent_colors.dart index d54925b71a..0688a35754 100644 --- a/mobile/lib/shared/theme/accent_colors.dart +++ b/mobile/lib/shared/theme/accent_colors.dart @@ -6,41 +6,78 @@ class AccentColor { final Color light; final Color dark; final bool useThemeForegroundInDark; + final String wireValue; const AccentColor({ required this.name, required this.light, required this.dark, this.useThemeForegroundInDark = false, + required this.wireValue, }); } const accentColors = [ - AccentColor(name: 'Blue', light: Color(0xFF3B82F6), dark: Color(0xFF60A5FA)), - AccentColor(name: 'Cyan', light: Color(0xFF06B6D4), dark: Color(0xFF22D3EE)), - AccentColor(name: 'Green', light: Color(0xFF22C55E), dark: Color(0xFF4ADE80)), + AccentColor( + name: 'Neutral', + light: Color(0xFF000000), + dark: Color(0xFFE1E4E8), + wireValue: 'neutral', + useThemeForegroundInDark: true, + ), + AccentColor( + name: 'Blue', + light: Color(0xFF3B82F6), + dark: Color(0xFF60A5FA), + wireValue: '#3b82f6', + ), + AccentColor( + name: 'Cyan', + light: Color(0xFF06B6D4), + dark: Color(0xFF22D3EE), + wireValue: '#06b6d4', + ), + AccentColor( + name: 'Green', + light: Color(0xFF22C55E), + dark: Color(0xFF4ADE80), + wireValue: '#22c55e', + ), AccentColor( name: 'Orange', light: Color(0xFFF97316), dark: Color(0xFFFB923C), + wireValue: '#f97316', + ), + AccentColor( + name: 'Red', + light: Color(0xFFEF4444), + dark: Color(0xFFF87171), + wireValue: '#ef4444', + ), + AccentColor( + name: 'Pink', + light: Color(0xFFEC4899), + dark: Color(0xFFF472B6), + wireValue: '#ec4899', + ), + AccentColor( + name: 'Lilac', + light: Color(0xFFC0A2F1), + dark: Color(0xFFC0A2F1), + wireValue: '#c0a2f1', ), - AccentColor(name: 'Red', light: Color(0xFFEF4444), dark: Color(0xFFF87171)), - AccentColor(name: 'Pink', light: Color(0xFFEC4899), dark: Color(0xFFF472B6)), AccentColor( name: 'Purple', light: Color(0xFFA855F7), dark: Color(0xFFC084FC), + wireValue: '#a855f7', ), AccentColor( name: 'Indigo', light: Color(0xFF6366F1), dark: Color(0xFF818CF8), - ), - AccentColor( - name: 'Black', - light: Color(0xFF000000), - dark: Color(0xFFFFFFFF), - useThemeForegroundInDark: true, + wireValue: '#6366f1', ), ]; @@ -59,7 +96,26 @@ Color accentColorForScheme(ColorScheme scheme, int accentIndex) { /// /// Keep this at the end of [accentColors] so existing saved accent indexes keep /// pointing at the same colors. -const defaultAccentIndex = 8; +const neutralAccentIndex = 0; +const defaultAccentIndex = neutralAccentIndex; /// Legacy default: Catppuccin Mauve/the base theme primary. const legacyDefaultAccentIndex = -1; + +int? accentIndexForWireValue(String value) { + final index = accentColors.indexWhere((accent) => accent.wireValue == value); + return index < 0 ? null : index; +} + +String legacyAccentWireValue(int? index) { + // Legacy mobile indexes 0...7 match desktop Blue...Indigo except Lilac, + // which did not exist. Black (8) has no desktop wire value, so migrate it + // deterministically to Neutral rather than publishing a mobile-only value. + if (index == 8) return 'neutral'; + if (index != null && index >= 0 && index <= 5) { + return accentColors[index + 1].wireValue; + } + if (index == 6) return '#a855f7'; + if (index == 7) return '#6366f1'; + return '#3b82f6'; +} diff --git a/mobile/lib/shared/theme/buzz_theme.dart b/mobile/lib/shared/theme/buzz_theme.dart index 1214b97790..f3866306f6 100644 --- a/mobile/lib/shared/theme/buzz_theme.dart +++ b/mobile/lib/shared/theme/buzz_theme.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'accent_colors.dart'; + /// Name of the first-party Buzz theme. Buzz reuses the GitHub Light palette for /// every base color; the one thing that sets it apart is a branded gradient /// painted across the app's top section. Mirrors desktop, where the same @@ -17,6 +19,13 @@ const buzzDarkThemeName = 'buzz-dark'; bool isBuzzTheme(String themeName) => themeName == buzzThemeName || themeName == buzzDarkThemeName; +/// Buzz renders with its fixed neutral foreground while preserving the stored +/// wire accent so the user's choice returns on another theme. +int effectiveAccentIndex(String themeName, String storedAccent) { + if (isBuzzTheme(themeName)) return neutralAccentIndex; + return accentIndexForWireValue(storedAccent) ?? defaultAccentIndex; +} + /// Gradient stops, matching desktop's `--buzz-gradient-*` custom properties. const _lightTop = Color(0xFFE6E6B6); const _lightBottom = Color(0xFFC4D0DA); diff --git a/mobile/lib/shared/theme/community_theme_preference.dart b/mobile/lib/shared/theme/community_theme_preference.dart new file mode 100644 index 0000000000..216f6a8d3c --- /dev/null +++ b/mobile/lib/shared/theme/community_theme_preference.dart @@ -0,0 +1,167 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'accent_colors.dart'; +import 'theme_catalog.dart'; +import 'theme_provider.dart' show effectiveTheme, schemeForAppearanceMode; + +const communityThemeDTag = 'community-theme'; +const defaultCommunityTheme = CommunityThemePreference( + theme: 'buzz', + accent: '#3b82f6', + followSystem: true, +); + +class CommunityThemePreference { + final int version; + final String theme; + final String accent; + final bool followSystem; + + const CommunityThemePreference({ + this.version = 1, + required this.theme, + required this.accent, + required this.followSystem, + }); + + factory CommunityThemePreference.fromJson(Map json) { + if (json['version'] != 1 || + json['theme'] is! String || + findTheme(json['theme'] as String) == null || + json['accent'] is! String || + accentIndexForWireValue(json['accent'] as String) == null || + json['followSystem'] is! bool) { + throw const FormatException('Invalid community theme preference'); + } + return CommunityThemePreference( + theme: json['theme'] as String, + accent: json['accent'] as String, + followSystem: json['followSystem'] as bool, + ); + } + + Map toJson() => { + 'version': version, + 'theme': theme, + 'accent': accent, + 'followSystem': followSystem, + }; + + ThemeMode get mode { + if (followSystem) return ThemeMode.system; + return findTheme(theme)?.isDark == true ? ThemeMode.dark : ThemeMode.light; + } + + @override + bool operator ==(Object other) => + other is CommunityThemePreference && + theme == other.theme && + accent == other.accent && + followSystem == other.followSystem; + + @override + int get hashCode => Object.hash(theme, accent, followSystem); +} + +class CommunityThemeStorage { + static const _prefix = 'buzz-community-theme.v1'; + static const _outboxPrefix = 'buzz-community-theme-outbox.v1'; + static const _migrationPrefix = 'buzz-community-theme-migrated.v1'; + static const _legacyModeKey = 'buzz_theme_mode'; + static const _legacyAccentKey = 'buzz_accent_color'; + static const _legacySchemeKey = 'buzz_color_scheme'; + + final SharedPreferences prefs; + + const CommunityThemeStorage(this.prefs); + + String key(String pubkey, String relayUrl) => + '$_prefix:$pubkey:${Uri.encodeComponent(normalizeCommunityRelayUrl(relayUrl))}'; + + String outboxKey(String pubkey, String relayUrl) => + '$_outboxPrefix:$pubkey:${Uri.encodeComponent(normalizeCommunityRelayUrl(relayUrl))}'; + + CommunityThemePreference? _readKey(String storageKey) { + try { + final raw = prefs.getString(storageKey); + if (raw == null) return null; + final decoded = jsonDecode(raw); + if (decoded is! Map) return null; + return CommunityThemePreference.fromJson(decoded); + } catch (_) { + return null; + } + } + + CommunityThemePreference? read(String pubkey, String relayUrl) => + _readKey(key(pubkey, relayUrl)); + + CommunityThemePreference? readOutbox(String pubkey, String relayUrl) => + _readKey(outboxKey(pubkey, relayUrl)); + + Future write( + String pubkey, + String relayUrl, + CommunityThemePreference preference, + ) => prefs.setString(key(pubkey, relayUrl), jsonEncode(preference.toJson())); + + Future writeOutbox( + String pubkey, + String relayUrl, + CommunityThemePreference preference, + ) => prefs.setString( + outboxKey(pubkey, relayUrl), + jsonEncode(preference.toJson()), + ); + + Future clearOutbox( + String pubkey, + String relayUrl, + CommunityThemePreference acknowledged, + ) async { + if (readOutbox(pubkey, relayUrl) == acknowledged) { + await prefs.remove(outboxKey(pubkey, relayUrl)); + } + } + + bool hasMigrated(String pubkey) => + prefs.getBool('$_migrationPrefix:$pubkey') == true; + + Future markMigrated(String pubkey) => + prefs.setBool('$_migrationPrefix:$pubkey', true); + + Future writeLegacy(CommunityThemePreference preference) async { + await prefs.setString(_legacyModeKey, preference.mode.name); + await prefs.setString(_legacySchemeKey, preference.theme); + await prefs.setInt( + _legacyAccentKey, + accentIndexForWireValue(preference.accent) ?? defaultAccentIndex, + ); + } + + CommunityThemePreference legacyPreference() { + final modeName = prefs.getString(_legacyModeKey); + final mode = + ThemeMode.values.where((value) => value.name == modeName).firstOrNull ?? + ThemeMode.system; + final storedTheme = prefs.getString(_legacySchemeKey); + final theme = findTheme(storedTheme ?? 'buzz')?.name ?? 'buzz'; + final legacyAccent = prefs.getInt(_legacyAccentKey); + final resolvedTheme = switch (mode) { + ThemeMode.system => schemeForAppearanceMode(theme, mode) ?? theme, + ThemeMode.light || + ThemeMode.dark => effectiveTheme(theme, mode)?.name ?? theme, + }; + return CommunityThemePreference( + theme: resolvedTheme, + accent: legacyAccentWireValue(legacyAccent), + followSystem: mode == ThemeMode.system, + ); + } +} + +String normalizeCommunityRelayUrl(String relayUrl) => + relayUrl.trim().replaceFirst(RegExp(r'/+$'), '').toLowerCase(); diff --git a/mobile/lib/shared/theme/community_theme_provider.dart b/mobile/lib/shared/theme/community_theme_provider.dart new file mode 100644 index 0000000000..4d66de8844 --- /dev/null +++ b/mobile/lib/shared/theme/community_theme_provider.dart @@ -0,0 +1,230 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../crypto/nip44.dart'; +import '../relay/relay.dart'; +import 'accent_colors.dart'; +import 'community_theme_preference.dart'; +import 'community_theme_sync.dart'; +import 'theme_provider.dart'; + +class CommunityThemeNotifier extends Notifier { + CommunityThemeSyncManager? _manager; + late CommunityThemeStorage _storage; + String? _pubkey; + String? _relayUrl; + Future _persistenceQueue = Future.value(); + int _localRevision = 0; + CommunityThemePreference? _scopedLocalPreference; + String? _scopedLocalPubkey; + String? _scopedLocalRelayUrl; + + @override + CommunityThemePreference build() { + _manager?.dispose(); + _manager = null; + + _storage = ref.watch(communityThemeStorageProvider); + final config = ref.watch(relayConfigProvider); + final session = ref.watch(relaySessionProvider); + final pubkey = pubkeyFromNsec(config.nsec); + _pubkey = pubkey; + _relayUrl = config.baseUrl; + + if (pubkey == null || config.nsec == null) { + final legacy = _storage.legacyPreference(); + unawaited(_storage.writeLegacy(legacy)); + return legacy; + } + + final cached = _storage.read(pubkey, config.baseUrl); + final dirty = _storage.readOutbox(pubkey, config.baseUrl); + final inMemoryLocal = + _scopedLocalPubkey == pubkey && _scopedLocalRelayUrl == config.baseUrl + ? _scopedLocalPreference + : null; + if (inMemoryLocal == null) { + _scopedLocalPreference = null; + _scopedLocalPubkey = pubkey; + _scopedLocalRelayUrl = config.baseUrl; + } + final fallback = _storage.hasMigrated(pubkey) + ? defaultCommunityTheme + : _storage.legacyPreference(); + final initial = inMemoryLocal ?? dirty ?? cached ?? fallback; + + if (session.status == SessionStatus.connected) { + late final CommunityThemeSyncManager manager; + manager = CommunityThemeSyncManager( + pubkey: pubkey, + relaySession: ref.read(relaySessionProvider.notifier), + signedEventRelay: SignedEventRelay( + session: ref.read(relaySessionProvider.notifier), + nsec: config.nsec!, + ), + crypto: _crypto(config.nsec!, pubkey), + onRemote: (remote) => _applyRemote(manager, remote), + onPublished: (preference) { + if (_scopedLocalPubkey == pubkey && + _scopedLocalRelayUrl == config.baseUrl && + _scopedLocalPreference == preference) { + _scopedLocalPreference = null; + } + unawaited(_storage.clearOutbox(pubkey, config.baseUrl, preference)); + }, + ); + _manager = manager; + final pending = inMemoryLocal ?? dirty; + if (pending != null) manager.publish(pending); + Future.microtask(() async { + final result = await manager.initialize(); + if (_manager != manager) return; + if (result.status == CommunityThemeRemoteStatus.absent) { + final seedRevision = _localRevision; + await _enqueuePersistence(() async { + if (_manager != manager || _localRevision != seedRevision) return; + final currentDirty = _storage.readOutbox(pubkey, config.baseUrl); + final seed = + currentDirty ?? _storage.read(pubkey, config.baseUrl) ?? state; + if (!await _storage.write(pubkey, config.baseUrl, seed)) return; + if (_manager != manager || _localRevision != seedRevision) return; + if (!await _storage.writeOutbox(pubkey, config.baseUrl, seed)) { + return; + } + if (_manager == manager && _localRevision == seedRevision) { + manager.publish(seed); + } + }); + } + if (result.status == CommunityThemeRemoteStatus.valid || + result.status == CommunityThemeRemoteStatus.absent) { + await _storage.markMigrated(pubkey); + } + }); + ref.onDispose(manager.dispose); + } + return initial; + } + + void setMode(ThemeMode mode) { + var theme = state.theme; + if (mode == ThemeMode.system) { + theme = schemeForAppearanceMode(theme, mode) ?? theme; + } else { + final effective = effectiveTheme(theme, mode); + if (effective != null) theme = effective.name; + } + _save( + CommunityThemePreference( + theme: theme, + accent: state.accent, + followSystem: mode == ThemeMode.system, + ), + ); + } + + void setTheme(String? theme) { + _save( + CommunityThemePreference( + theme: theme ?? defaultSchemeName, + accent: state.accent, + followSystem: state.followSystem, + ), + ); + } + + void setAccent(int index) { + if (index < 0 || index >= accentColors.length) return; + _save( + CommunityThemePreference( + theme: state.theme, + accent: accentColors[index].wireValue, + followSystem: state.followSystem, + ), + ); + } + + void _save(CommunityThemePreference preference) { + if (preference == state) return; + state = preference; + _localRevision++; + final pubkey = _pubkey; + final relayUrl = _relayUrl; + if (pubkey == null || relayUrl == null) { + unawaited(_storage.writeLegacy(preference)); + return; + } + _scopedLocalPreference = preference; + _scopedLocalPubkey = pubkey; + _scopedLocalRelayUrl = relayUrl; + _manager?.stage(preference); + unawaited( + _enqueuePersistence( + () => _persistAndPublish(pubkey, relayUrl, preference), + ), + ); + } + + Future _enqueuePersistence(Future Function() operation) { + final result = _persistenceQueue.then((_) => operation()); + _persistenceQueue = result.catchError((Object _) {}); + return result; + } + + Future _persistAndPublish( + String pubkey, + String relayUrl, + CommunityThemePreference preference, + ) async { + if (!await _storage.write(pubkey, relayUrl, preference)) return; + if (!await _storage.writeOutbox(pubkey, relayUrl, preference)) return; + if (_pubkey == pubkey && _relayUrl == relayUrl) { + final manager = _manager; + if (manager != null) { + manager.stage(preference); + manager.publishStaged(preference); + } + } + } + + void _applyRemote( + CommunityThemeSyncManager manager, + RemoteCommunityTheme remote, + ) { + if (_manager != manager) return; + final pubkey = _pubkey; + final relayUrl = _relayUrl; + if (pubkey != null && + relayUrl != null && + _storage.readOutbox(pubkey, relayUrl) != null) { + final dirty = _storage.readOutbox(pubkey, relayUrl)!; + manager.publish(dirty); + return; + } + state = remote.preference; + if (pubkey != null && relayUrl != null) { + unawaited(_storage.write(pubkey, relayUrl, remote.preference)); + } + } +} + +CommunityThemeCrypto _crypto(String nsec, String pubkey) { + final privateHex = nostr.Nip19.decode(payload: nsec).data; + final key = getConversationKey(privateHex, pubkey); + return CommunityThemeCrypto( + encrypt: (plaintext) => nip44Encrypt(key, plaintext), + decrypt: (ciphertext) => nip44Decrypt(key, ciphertext), + ); +} + +final communityThemeStorageProvider = Provider( + (ref) => CommunityThemeStorage(ref.watch(savedPrefsProvider)), +); + +final communityThemeProvider = + NotifierProvider( + CommunityThemeNotifier.new, + ); diff --git a/mobile/lib/shared/theme/community_theme_sync.dart b/mobile/lib/shared/theme/community_theme_sync.dart new file mode 100644 index 0000000000..fdbe5ae3a2 --- /dev/null +++ b/mobile/lib/shared/theme/community_theme_sync.dart @@ -0,0 +1,366 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; + +import '../relay/relay.dart'; +import 'community_theme_preference.dart'; + +class CommunityThemeCrypto { + final String Function(String) encrypt; + final String Function(String) decrypt; + + const CommunityThemeCrypto({required this.encrypt, required this.decrypt}); +} + +enum CommunityThemeRemoteStatus { valid, absent, invalid, unavailable } + +class RemoteCommunityTheme { + final CommunityThemePreference preference; + final int createdAt; + final String eventId; + + const RemoteCommunityTheme({ + required this.preference, + required this.createdAt, + required this.eventId, + }); +} + +class CommunityThemeRemoteResult { + final CommunityThemeRemoteStatus status; + final RemoteCommunityTheme? remote; + + const CommunityThemeRemoteResult(this.status, [this.remote]); +} + +class CommunityThemeSyncManager { + final String pubkey; + final RelaySessionNotifier relaySession; + final SignedEventRelay signedEventRelay; + final CommunityThemeCrypto crypto; + final Duration debounce; + final Duration publishRetryBase; + final Duration publishRetryMax; + final Duration subscriptionRetryBase; + final void Function(RemoteCommunityTheme) onRemote; + final void Function(CommunityThemePreference) onPublished; + + Timer? _publishTimer; + Timer? _subscriptionRetryTimer; + void Function()? _unsubscribe; + CommunityThemePreference? _pending; + CommunityThemePreference? _lastPublished; + int _lastCreatedAt = 0; + String _lastEventId = ''; + RemoteCommunityTheme? _lastRemote; + int _subscriptionEpoch = 0; + int _subscriptionRetryAttempt = 0; + int _publishRetryAttempt = 0; + bool _publishInFlight = false; + bool _publishRequestedWhileInFlight = false; + bool _disposed = false; + + CommunityThemeSyncManager({ + required this.pubkey, + required this.relaySession, + required this.signedEventRelay, + required this.crypto, + required this.onRemote, + this.onPublished = _ignorePublished, + this.debounce = const Duration(seconds: 2), + this.publishRetryBase = const Duration(seconds: 1), + this.publishRetryMax = const Duration(seconds: 30), + this.subscriptionRetryBase = const Duration(seconds: 1), + }); + + CommunityThemePreference? get pending => _pending; + + Future fetchRemote() async { + try { + final events = await relaySession.fetchHistory(_themeFilter(limit: 1)); + if (events.isEmpty) { + return const CommunityThemeRemoteResult( + CommunityThemeRemoteStatus.absent, + ); + } + final event = events.reduce(_newerEvent); + final remote = _decode(event); + return remote == null + ? const CommunityThemeRemoteResult(CommunityThemeRemoteStatus.invalid) + : CommunityThemeRemoteResult( + CommunityThemeRemoteStatus.valid, + remote, + ); + } catch (_) { + return const CommunityThemeRemoteResult( + CommunityThemeRemoteStatus.unavailable, + ); + } + } + + Future initialize() async { + final subscribed = await _startLiveSubscription(); + if (_disposed) { + return const CommunityThemeRemoteResult( + CommunityThemeRemoteStatus.unavailable, + ); + } + final result = await fetchRemote(); + if (_disposed) return result; + if (result.status == CommunityThemeRemoteStatus.valid) { + _accept(result.remote!); + } + final remote = _lastRemote; + if (remote != null) { + return CommunityThemeRemoteResult( + CommunityThemeRemoteStatus.valid, + remote, + ); + } + if (!subscribed && result.status == CommunityThemeRemoteStatus.absent) { + return const CommunityThemeRemoteResult( + CommunityThemeRemoteStatus.unavailable, + ); + } + return result; + } + + Future _startLiveSubscription() async { + if (_disposed) return false; + final epoch = ++_subscriptionEpoch; + try { + final unsubscribe = await relaySession.subscribe( + _themeFilter(limit: 0), + (event) { + if (_disposed || epoch != _subscriptionEpoch) return; + final remote = _decode(event); + if (remote != null) _accept(remote); + }, + onClosed: (message) => _handleSubscriptionClosed(epoch, message), + ); + if (_disposed || epoch != _subscriptionEpoch) { + unsubscribe(); + return false; + } + _unsubscribe = unsubscribe; + _subscriptionRetryAttempt = 0; + return true; + } catch (error) { + if (!_disposed && epoch == _subscriptionEpoch) { + debugPrint('[CommunityThemeSync] live subscription failed: $error'); + _scheduleSubscriptionRetry(); + } + return false; + } + } + + void _handleSubscriptionClosed(int epoch, String message) { + if (_disposed || epoch != _subscriptionEpoch) return; + debugPrint('[CommunityThemeSync] live subscription closed: $message'); + _unsubscribe = null; + _scheduleSubscriptionRetry(); + } + + void _scheduleSubscriptionRetry() { + if (_disposed || _subscriptionRetryTimer != null) return; + final multiplier = 1 << min(_subscriptionRetryAttempt, 5); + _subscriptionRetryAttempt++; + _subscriptionRetryTimer = Timer(subscriptionRetryBase * multiplier, () { + _subscriptionRetryTimer = null; + unawaited(_recoverLiveSubscription()); + }); + } + + Future _recoverLiveSubscription() async { + if (_disposed) return; + if (!await _startLiveSubscription()) return; + + // A relay CLOSED removes the retained subscription from RelaySession, so + // reconnect replay cannot recover it. Query the replacement coordinate + // after re-subscribing to close the gap while this stream was silent. + final result = await fetchRemote(); + if (_disposed) return; + if (result.status == CommunityThemeRemoteStatus.valid) { + _accept(result.remote!); + } + } + + NostrFilter _themeFilter({required int limit}) => NostrFilter( + kinds: const [EventKind.readState], + authors: [pubkey], + tags: const { + '#d': [communityThemeDTag], + }, + limit: limit, + ); + + void stage(CommunityThemePreference preference) { + if (_disposed) return; + _pending = preference; + _publishRetryAttempt = 0; + _publishTimer?.cancel(); + _publishTimer = null; + } + + void publish(CommunityThemePreference preference) { + stage(preference); + publishStaged(preference); + } + + void publishStaged(CommunityThemePreference preference) { + if (_disposed || _pending != preference) return; + _schedulePublish(debounce); + } + + void _schedulePublish(Duration delay) { + if (_disposed) return; + _publishTimer?.cancel(); + _publishTimer = Timer(delay, () { + _publishTimer = null; + unawaited(flush()); + }); + } + + void cancelPending() { + _publishTimer?.cancel(); + _publishTimer = null; + _pending = null; + } + + Future flush() async { + if (_publishInFlight) { + _publishTimer?.cancel(); + _publishTimer = null; + _publishRequestedWhileInFlight = true; + return; + } + final preference = _pending; + if (_disposed || preference == null) return; + if (preference == _lastPublished) { + _pending = null; + onPublished(preference); + return; + } + _publishInFlight = true; + try { + final content = crypto.encrypt(jsonEncode(preference.toJson())); + if (_disposed) return; + final createdAt = max( + DateTime.now().millisecondsSinceEpoch ~/ 1000, + _lastCreatedAt + 1, + ); + NostrEvent? signed; + await signedEventRelay.submit( + kind: EventKind.readState, + content: content, + tags: const [ + ['d', communityThemeDTag], + ['t', communityThemeDTag], + ], + createdAt: createdAt, + onSigned: (event) => signed = event, + ); + if (_disposed) return; + final published = signed; + if (published == null) { + throw StateError('Signed event coordinate unavailable'); + } + final publishedCoordinateIsStale = + _lastCreatedAt > published.createdAt || + (_lastCreatedAt == published.createdAt && + _lastEventId.isNotEmpty && + _lastEventId.compareTo(published.id) < 0); + if (publishedCoordinateIsStale) { + _lastPublished = null; + _publishRetryAttempt = 0; + if (_pending == preference) _schedulePublish(Duration.zero); + return; + } + _lastCreatedAt = published.createdAt; + _lastEventId = published.id; + _lastPublished = preference; + _publishRetryAttempt = 0; + if (_pending == preference) _pending = null; + onPublished(preference); + } catch (error) { + debugPrint('[CommunityThemeSync] publish failed: $error'); + if (_disposed || _pending != preference) return; + final multiplier = 1 << min(_publishRetryAttempt, 30); + _publishRetryAttempt++; + final retryMs = min( + publishRetryBase.inMilliseconds * multiplier, + publishRetryMax.inMilliseconds, + ); + _schedulePublish(Duration(milliseconds: retryMs)); + } finally { + _publishInFlight = false; + if (!_disposed && + _pending != null && + (_publishRequestedWhileInFlight || _pending != preference) && + _publishTimer == null) { + _publishRequestedWhileInFlight = false; + _schedulePublish(Duration.zero); + } else { + _publishRequestedWhileInFlight = false; + } + } + } + + RemoteCommunityTheme? _decode(NostrEvent event) { + if (event.pubkey != pubkey || + event.getTagValue('d') != communityThemeDTag) { + return null; + } + try { + final decoded = jsonDecode(crypto.decrypt(event.content)); + if (decoded is! Map) return null; + return RemoteCommunityTheme( + preference: CommunityThemePreference.fromJson(decoded), + createdAt: event.createdAt, + eventId: event.id, + ); + } catch (_) { + return null; + } + } + + void _accept(RemoteCommunityTheme remote) { + if (remote.createdAt < _lastCreatedAt || + (remote.createdAt == _lastCreatedAt && + _lastEventId.isNotEmpty && + remote.eventId.compareTo(_lastEventId) >= 0)) { + return; + } + _lastCreatedAt = remote.createdAt; + _lastEventId = remote.eventId; + _lastRemote = remote; + if (_pending != null) { + _lastPublished = null; + return; + } + _lastPublished = null; + onRemote(remote); + } + + void dispose() { + if (_disposed) return; + _disposed = true; + _subscriptionEpoch++; + _subscriptionRetryTimer?.cancel(); + _subscriptionRetryTimer = null; + cancelPending(); + _unsubscribe?.call(); + _unsubscribe = null; + } +} + +void _ignorePublished(CommunityThemePreference _) {} + +NostrEvent _newerEvent(NostrEvent left, NostrEvent right) { + if (right.createdAt != left.createdAt) { + return right.createdAt > left.createdAt ? right : left; + } + return right.id.compareTo(left.id) < 0 ? right : left; +} diff --git a/mobile/lib/shared/theme/theme.dart b/mobile/lib/shared/theme/theme.dart index 862534b4f7..0424e3b41a 100644 --- a/mobile/lib/shared/theme/theme.dart +++ b/mobile/lib/shared/theme/theme.dart @@ -4,6 +4,9 @@ export 'app_colors.dart'; export 'app_theme.dart'; export 'buzz_theme.dart'; export 'color_scheme.dart'; +export 'community_theme_preference.dart'; +export 'community_theme_provider.dart'; +export 'community_theme_sync.dart'; export 'grid.dart'; export 'message_typography.dart'; export 'theme_catalog.dart'; diff --git a/mobile/test/features/settings/theme_picker_page_test.dart b/mobile/test/features/settings/theme_picker_page_test.dart index 6f8591f285..010db98ba3 100644 --- a/mobile/test/features/settings/theme_picker_page_test.dart +++ b/mobile/test/features/settings/theme_picker_page_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:buzz/features/settings/accent_picker_page.dart'; import 'package:buzz/features/settings/theme_picker_page.dart'; +import 'package:buzz/features/settings/settings_page.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -167,6 +168,34 @@ void main() { }); }); + group('Buzz accent behavior', () { + testWidgets('settings hides accent navigation for Buzz', (tester) async { + await _pumpPicker( + tester, + const SettingsPage(profileHeader: SizedBox.shrink()), + prefs: {'buzz_color_scheme': 'buzz', 'buzz_accent_color': 4}, + ); + + expect(find.text('Accent color'), findsNothing); + }); + + testWidgets('settings restores accent navigation away from Buzz', ( + tester, + ) async { + await _pumpPicker( + tester, + const SettingsPage(profileHeader: SizedBox.shrink()), + prefs: { + 'buzz_theme_mode': 'light', + 'buzz_color_scheme': 'github-light', + 'buzz_accent_color': 4, + }, + ); + + expect(find.text('Accent color'), findsOneWidget); + }); + }); + group('AccentPickerPage', () { testWidgets('lists every accent and checks the stored one', (tester) async { await _pumpPicker( diff --git a/mobile/test/shared/crypto/nip44_interop_test.dart b/mobile/test/shared/crypto/nip44_interop_test.dart new file mode 100644 index 0000000000..be4e7d87bc --- /dev/null +++ b/mobile/test/shared/crypto/nip44_interop_test.dart @@ -0,0 +1,21 @@ +import 'package:buzz/shared/crypto/nip44.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('decrypts a desktop nostr-rs NIP-44 v2 self-encrypted payload', () { + const privateKey = + '0000000000000000000000000000000000000000000000000000000000000001'; + const publicKey = + '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798'; + const desktopCiphertext = + 'Au0C/BZ3gT83RnPFiPYGr70BuEyKDlZrk1nEJUDZbkoNgpSjE7JUKRb3VRbegcQYUvNT2Qayf3DkfuSb1M6l70IDpsQ25y8xwDA+uEreyRxDdZ5tQF+C9iB3Qr0vinFQpbR9f0SIvUahwAzyHBMdZ1butlCHi9aqv0C1/w1MWMWeoGaPm4XtkhJSPawCGMuFVw1Z8r64bxMSI6EThc4HtR9p4Q=='; + + expect( + nip44Decrypt( + getConversationKey(privateKey, publicKey), + desktopCiphertext, + ), + '{"version":1,"theme":"catppuccin-latte","accent":"#f97316","followSystem":false}', + ); + }); +} diff --git a/mobile/test/shared/theme/buzz_theme_test.dart b/mobile/test/shared/theme/buzz_theme_test.dart index 613fec1467..85e4d5ef6c 100644 --- a/mobile/test/shared/theme/buzz_theme_test.dart +++ b/mobile/test/shared/theme/buzz_theme_test.dart @@ -40,6 +40,24 @@ void main() { expect(themeSelectionLabel(buzzDarkThemeName, ThemeMode.system), 'Buzz'); }); + test('forces neutral rendering without changing the stored accent', () { + const storedAccent = '#ef4444'; + + expect( + effectiveAccentIndex(buzzThemeName, storedAccent), + neutralAccentIndex, + ); + expect( + effectiveAccentIndex(buzzDarkThemeName, storedAccent), + neutralAccentIndex, + ); + expect( + effectiveAccentIndex('github-light', storedAccent), + accentIndexForWireValue(storedAccent), + ); + expect(storedAccent, '#ef4444'); + }); + test('resolve across brightnesses like any other pair', () { final resolved = resolveSchemes(buzzThemeName, ThemeMode.system); expect(resolved.forcedMode, isNull); diff --git a/mobile/test/shared/theme/community_theme_preference_test.dart b/mobile/test/shared/theme/community_theme_preference_test.dart new file mode 100644 index 0000000000..ce6a2afaec --- /dev/null +++ b/mobile/test/shared/theme/community_theme_preference_test.dart @@ -0,0 +1,123 @@ +import 'dart:convert'; + +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + test('desktop v1 payload round-trips exactly', () { + final preference = CommunityThemePreference.fromJson({ + 'version': 1, + 'theme': 'github-dark', + 'accent': '#c0a2f1', + 'followSystem': false, + }); + + expect(preference.mode, ThemeMode.dark); + expect( + jsonEncode(preference.toJson()), + '{"version":1,"theme":"github-dark","accent":"#c0a2f1","followSystem":false}', + ); + }); + + test('rejects unknown themes, accents, and future versions', () { + for (final payload in [ + { + 'version': 2, + 'theme': 'buzz', + 'accent': '#3b82f6', + 'followSystem': true, + }, + { + 'version': 1, + 'theme': 'unknown', + 'accent': '#3b82f6', + 'followSystem': true, + }, + { + 'version': 1, + 'theme': 'buzz', + 'accent': '#000000', + 'followSystem': true, + }, + ]) { + expect( + () => CommunityThemePreference.fromJson(payload), + throwsFormatException, + ); + } + }); + + test('storage is scoped by pubkey and normalized relay URL', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final storage = CommunityThemeStorage(prefs); + const a = CommunityThemePreference( + theme: 'buzz', + accent: '#3b82f6', + followSystem: true, + ); + const b = CommunityThemePreference( + theme: 'dracula', + accent: '#ef4444', + followSystem: false, + ); + + await storage.write('pk', 'WSS://Relay.Example///', a); + await storage.write('pk', 'wss://other.example', b); + + expect(storage.read('pk', 'wss://relay.example'), a); + expect(storage.read('pk', 'wss://other.example/'), b); + expect(storage.read('other-pk', 'wss://relay.example'), isNull); + }); + + test('dirty outbox survives restart and clears only exact ack', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final storage = CommunityThemeStorage(prefs); + const pending = CommunityThemePreference( + theme: 'dracula', + accent: '#ef4444', + followSystem: false, + ); + const newer = CommunityThemePreference( + theme: 'houston', + accent: '#a855f7', + followSystem: false, + ); + + await storage.writeOutbox('pk', 'wss://relay.example', pending); + expect( + CommunityThemeStorage(prefs).readOutbox('pk', 'wss://relay.example'), + pending, + ); + await storage.writeOutbox('pk', 'wss://relay.example', newer); + await storage.clearOutbox('pk', 'wss://relay.example', pending); + expect(storage.readOutbox('pk', 'wss://relay.example'), newer); + await storage.clearOutbox('pk', 'wss://relay.example', newer); + expect(storage.readOutbox('pk', 'wss://relay.example'), isNull); + }); + + test('legacy accent indexes migrate without inventing wire values', () async { + SharedPreferences.setMockInitialValues({ + 'buzz_theme_mode': 'dark', + 'buzz_color_scheme': 'dracula', + 'buzz_accent_color': 8, + }); + final prefs = await SharedPreferences.getInstance(); + final preference = CommunityThemeStorage(prefs).legacyPreference(); + + expect( + preference, + const CommunityThemePreference( + theme: 'dracula', + accent: 'neutral', + followSystem: false, + ), + ); + expect(preference.mode, ThemeMode.dark); + expect(legacyAccentWireValue(6), '#a855f7'); + expect(legacyAccentWireValue(7), '#6366f1'); + }); +} diff --git a/mobile/test/shared/theme/community_theme_provider_test.dart b/mobile/test/shared/theme/community_theme_provider_test.dart new file mode 100644 index 0000000000..099ddd2f77 --- /dev/null +++ b/mobile/test/shared/theme/community_theme_provider_test.dart @@ -0,0 +1,311 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:buzz/shared/crypto/nip44.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme.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'; + +void main() { + test('delayed absence seeds the intervening local edit', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final keys = nostr.Keys.generate(); + final history = Completer>(); + final session = _ThemeRelaySession( + keys.nsec, + keys.public, + historyFuture: history.future, + ); + final storage = CommunityThemeStorage(prefs); + final container = ProviderContainer( + overrides: [ + communityThemeStorageProvider.overrideWithValue(storage), + relayConfigProvider.overrideWith(() => _RelayConfig(keys.nsec)), + relaySessionProvider.overrideWith(() => session), + ], + ); + addTearDown(container.dispose); + container.listen(communityThemeProvider, (_, _) {}, fireImmediately: true); + + container.read(communityThemeProvider.notifier).setTheme('dracula'); + history.complete([]); + await _waitUntil(() => session.published != null); + + expect(container.read(communityThemeProvider).theme, 'dracula'); + expect( + storage.read(keys.public, 'https://relay.example')?.theme, + 'dracula', + ); + }); + + test('edit during delayed absence seed wins durable state', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final keys = nostr.Keys.generate(); + final history = Completer>(); + final session = _ThemeRelaySession( + keys.nsec, + keys.public, + historyFuture: history.future, + ); + final storage = _DelayedThemeStorage(prefs); + final container = ProviderContainer( + overrides: [ + communityThemeStorageProvider.overrideWithValue(storage), + relayConfigProvider.overrideWith(() => _RelayConfig(keys.nsec)), + relaySessionProvider.overrideWith(() => session), + ], + ); + addTearDown(container.dispose); + container.listen(communityThemeProvider, (_, _) {}, fireImmediately: true); + + history.complete([]); + await storage.cacheWriteStarted.future; + container.read(communityThemeProvider.notifier).setTheme('dracula'); + storage.allowCacheWrite.complete(); + storage.allowOutboxWrite.complete(); + await _waitUntil(() => session.published != null); + + expect(container.read(communityThemeProvider).theme, 'dracula'); + expect( + storage.read(keys.public, 'https://relay.example')?.theme, + 'dracula', + ); + final privateHex = nostr.Nip19.decode(payload: keys.nsec).data; + final key = getConversationKey(privateHex, keys.public); + expect( + jsonDecode(nip44Decrypt(key, session.published!.content))['theme'], + 'dracula', + ); + }); + + test( + 'local edit stays authoritative before persistence through exact ack', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final keys = nostr.Keys.generate(); + final session = _ThemeRelaySession(keys.nsec, keys.public); + final storage = _DelayedThemeStorage(prefs); + final container = ProviderContainer( + overrides: [ + communityThemeStorageProvider.overrideWithValue(storage), + relayConfigProvider.overrideWith(() => _RelayConfig(keys.nsec)), + relaySessionProvider.overrideWith(() => session), + ], + ); + addTearDown(container.dispose); + + final subscription = container.listen( + communityThemeProvider, + (_, _) {}, + fireImmediately: true, + ); + addTearDown(subscription.close); + await session.subscribed.future; + + final notifier = container.read(communityThemeProvider.notifier); + notifier.setTheme('dracula'); + const local = CommunityThemePreference( + theme: 'dracula', + accent: '#3b82f6', + followSystem: true, + ); + expect(container.read(communityThemeProvider), local); + + session.emit(session.remoteEvent(theme: 'houston', id: 'remote-z')); + expect(container.read(communityThemeProvider), local); + + storage.allowCacheWrite.complete(); + await storage.outboxWriteStarted.future; + session.emit(session.remoteEvent(theme: 'solarized', id: 'remote-a')); + expect(container.read(communityThemeProvider), local); + + storage.allowOutboxWrite.complete(); + await _waitUntil(() => session.published != null); + expect(container.read(communityThemeProvider), local); + + session.emit(session.published!); + await _pumpEventQueue(); + expect(container.read(communityThemeProvider), local); + expect(storage.readOutbox(keys.public, 'https://relay.example'), isNull); + }, + ); + + test( + 'provider rebuild preserves delayed local edit and publishes on replacement manager', + () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final keys = nostr.Keys.generate(); + final session = _ThemeRelaySession(keys.nsec, keys.public); + final storage = _DelayedThemeStorage(prefs); + final container = ProviderContainer( + overrides: [ + communityThemeStorageProvider.overrideWithValue(storage), + relayConfigProvider.overrideWith(() => _RelayConfig(keys.nsec)), + relaySessionProvider.overrideWith(() => session), + ], + ); + addTearDown(container.dispose); + final subscription = container.listen( + communityThemeProvider, + (_, _) {}, + fireImmediately: true, + ); + addTearDown(subscription.close); + await session.subscribed.future; + + container.read(communityThemeProvider.notifier).setTheme('dracula'); + expect(container.read(communityThemeProvider).theme, 'dracula'); + await storage.cacheWriteStarted.future; + + session.setStatus(SessionStatus.reconnecting); + await _pumpEventQueue(); + expect(container.read(communityThemeProvider).theme, 'dracula'); + session.setStatus(SessionStatus.connected); + await _waitUntil(() => session.subscribeCalls == 2); + expect(container.read(communityThemeProvider).theme, 'dracula'); + + storage.allowCacheWrite.complete(); + storage.allowOutboxWrite.complete(); + await _waitUntil(() => session.published != null); + + final privateHex = nostr.Nip19.decode(payload: keys.nsec).data; + final key = getConversationKey(privateHex, keys.public); + expect( + jsonDecode(nip44Decrypt(key, session.published!.content))['theme'], + 'dracula', + ); + expect(container.read(communityThemeProvider).theme, 'dracula'); + }, + ); +} + +class _DelayedThemeStorage extends CommunityThemeStorage { + _DelayedThemeStorage(super.prefs); + + final allowCacheWrite = Completer(); + final allowOutboxWrite = Completer(); + final cacheWriteStarted = Completer(); + final outboxWriteStarted = Completer(); + + @override + Future write( + String pubkey, + String relayUrl, + CommunityThemePreference preference, + ) async { + if (!cacheWriteStarted.isCompleted) cacheWriteStarted.complete(); + await allowCacheWrite.future; + return super.write(pubkey, relayUrl, preference); + } + + @override + Future writeOutbox( + String pubkey, + String relayUrl, + CommunityThemePreference preference, + ) async { + if (!outboxWriteStarted.isCompleted) outboxWriteStarted.complete(); + await allowOutboxWrite.future; + return super.writeOutbox(pubkey, relayUrl, preference); + } +} + +class _RelayConfig extends RelayConfigNotifier { + _RelayConfig(this.nsec); + + final String nsec; + + @override + RelayConfig build() => + RelayConfig(baseUrl: 'https://relay.example', nsec: nsec); +} + +class _ThemeRelaySession extends RelaySessionNotifier { + _ThemeRelaySession(this.nsec, this.pubkey, {this.historyFuture}); + + final String nsec; + final String pubkey; + final Future>? historyFuture; + final subscribed = Completer(); + int subscribeCalls = 0; + void Function(NostrEvent)? _listener; + NostrEvent? published; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => historyFuture ?? [remoteEvent(theme: 'buzz', id: 'initial')]; + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async { + subscribeCalls++; + _listener = onEvent; + if (!subscribed.isCompleted) subscribed.complete(); + return () => _listener = null; + } + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async { + published = event; + return event; + } + + void emit(NostrEvent event) => _listener?.call(event); + + void setStatus(SessionStatus status) { + state = SessionState(status: status); + } + + NostrEvent remoteEvent({required String theme, required String id}) { + final privateHex = nostr.Nip19.decode(payload: nsec).data; + final key = getConversationKey(privateHex, pubkey); + final preference = CommunityThemePreference( + theme: theme, + accent: '#3b82f6', + followSystem: true, + ); + return NostrEvent( + id: id, + pubkey: pubkey, + createdAt: 1, + kind: 30078, + tags: const [ + ['d', communityThemeDTag], + ['t', communityThemeDTag], + ], + content: nip44Encrypt(key, jsonEncode(preference.toJson())), + sig: 'sig', + ); + } +} + +Future _pumpEventQueue() async { + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); +} + +Future _waitUntil(bool Function() condition) async { + final deadline = DateTime.now().add(const Duration(seconds: 3)); + while (!condition()) { + if (DateTime.now().isAfter(deadline)) fail('condition not met'); + await Future.delayed(const Duration(milliseconds: 5)); + } +} diff --git a/mobile/test/shared/theme/community_theme_sync_test.dart b/mobile/test/shared/theme/community_theme_sync_test.dart new file mode 100644 index 0000000000..fa65f0029e --- /dev/null +++ b/mobile/test/shared/theme/community_theme_sync_test.dart @@ -0,0 +1,546 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const local = CommunityThemePreference( + theme: 'buzz', + accent: '#3b82f6', + followSystem: true, + ); + + test('confirmed absence seeds exact NIP-78 coordinate', () async { + final session = _FakeSession(); + final relay = _FakeSignedRelay(); + final manager = _manager(session, relay); + + final result = await manager.initialize(); + expect(result.status, CommunityThemeRemoteStatus.absent); + manager.publish(local); + await manager.flush(); + + expect(relay.submissions, hasLength(1)); + expect(relay.submissions.single.kind, 30078); + expect( + relay.submissions.single.tags, + containsAll(>[ + ['d', 'community-theme'], + ['t', 'community-theme'], + ]), + ); + expect(jsonDecode(relay.submissions.single.content), local.toJson()); + }); + + test( + 'live replacement closes the history-to-subscription absence gap', + () async { + const replacement = CommunityThemePreference( + theme: 'dracula', + accent: '#ef4444', + followSystem: false, + ); + final applied = []; + late final _FakeSession session; + session = _FakeSession( + onFetchHistory: () { + session.emit( + _event( + id: 'replacement', + createdAt: 100, + content: jsonEncode(replacement.toJson()), + ), + ); + }, + ); + final manager = _manager( + session, + _FakeSignedRelay(), + onRemote: (remote) => applied.add(remote.preference), + ); + + final result = await manager.initialize(); + + expect(session.subscribeCalls, 1); + expect(result.status, CommunityThemeRemoteStatus.valid); + expect(result.remote?.preference, replacement); + expect(applied, [replacement]); + }, + ); + + test('invalid and unavailable records never seed', () async { + for (final session in [ + _FakeSession(history: [_event(content: '{bad json')]), + _FakeSession(error: StateError('offline')), + ]) { + final relay = _FakeSignedRelay(); + final result = await _manager(session, relay).initialize(); + expect( + result.status, + anyOf( + CommunityThemeRemoteStatus.invalid, + CommunityThemeRemoteStatus.unavailable, + ), + ); + expect(relay.submissions, isEmpty); + } + }); + + test( + 'newest valid event wins with deterministic same-second ordering', + () async { + final applied = []; + final session = _FakeSession( + history: [ + _event( + id: 'z', + createdAt: 50, + content: jsonEncode( + const CommunityThemePreference( + theme: 'dracula', + accent: '#ef4444', + followSystem: false, + ).toJson(), + ), + ), + _event(id: 'a', createdAt: 50, content: jsonEncode(local.toJson())), + ], + ); + final manager = _manager( + session, + _FakeSignedRelay(), + onRemote: (r) => applied.add(r.preference), + ); + + await manager.initialize(); + expect(applied.single.theme, 'buzz'); + + session.emit( + _event( + id: 'z', + createdAt: 50, + content: jsonEncode( + const CommunityThemePreference( + theme: 'dracula', + accent: '#ef4444', + followSystem: false, + ).toJson(), + ), + ), + ); + expect(applied, hasLength(1)); + }, + ); + + test('remote hydration never cancels a newer pending local write', () async { + final relay = _FakeSignedRelay(); + final session = _FakeSession(); + final manager = _manager(session, relay); + await manager.initialize(); + manager.cancelPending(); + manager.publish( + const CommunityThemePreference( + theme: 'dracula', + accent: '#ef4444', + followSystem: false, + ), + ); + + session.emit( + _event(id: 'remote', createdAt: 100, content: jsonEncode(local.toJson())), + ); + await manager.flush(); + expect(relay.submissions, hasLength(1)); + expect(jsonDecode(relay.submissions.single.content)['theme'], 'dracula'); + + manager.publish(local); + manager.dispose(); + await manager.flush(); + expect(relay.submissions, hasLength(1)); + }); + + test( + 'relay CLOSED resubscribes then catches up latest replacement event', + () async { + final applied = []; + final session = _FakeSession(); + final manager = _manager( + session, + _FakeSignedRelay(), + onRemote: (remote) => applied.add(remote.preference), + ); + await manager.initialize(); + manager.cancelPending(); + expect(session.subscribeCalls, 1); + + const replacement = CommunityThemePreference( + theme: 'dracula', + accent: '#ef4444', + followSystem: false, + ); + session.history = [ + _event( + id: 'replacement', + createdAt: 100, + content: jsonEncode(replacement.toJson()), + ), + ]; + session.closeLiveSubscription('rate-limited: quota exceeded'); + + await _waitUntil(() => session.subscribeCalls == 2 && applied.isNotEmpty); + expect(applied.single, replacement); + expect(session.activeListeners, 1); + manager.dispose(); + }, + ); + + test('relay CLOSED after dispose never resubscribes', () async { + final session = _FakeSession(); + final manager = _manager(session, _FakeSignedRelay()); + await manager.initialize(); + final close = session.latestClosedCallback; + + manager.dispose(); + close?.call('late close'); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(session.subscribeCalls, 1); + }); + + test('publish failure retries and acknowledges exact preference', () async { + final relay = _FakeSignedRelay(failuresRemaining: 1); + final acknowledgements = []; + final manager = _manager( + _FakeSession(), + relay, + onPublished: acknowledgements.add, + ); + manager.publish(local); + await manager.flush(); + expect(manager.pending, local); + + await _waitUntil(() => acknowledgements.length == 1); + expect(relay.attempts, 2); + expect(manager.pending, isNull); + expect(acknowledgements, [local]); + }); + + test('serializes in-flight publish before latest edit', () async { + final firstSubmission = Completer(); + final relay = _FakeSignedRelay(firstSubmissionGate: firstSubmission.future); + final manager = _manager(_FakeSession(), relay); + const latest = CommunityThemePreference( + theme: 'dracula', + accent: '#ef4444', + followSystem: false, + ); + + manager.publish(local); + final firstFlush = manager.flush(); + await _waitUntil(() => relay.attempts == 1); + manager.publish(latest); + await manager.flush(); + expect(relay.attempts, 1); + + firstSubmission.complete(); + await firstFlush; + await _waitUntil(() => relay.attempts == 2); + + expect(relay.submittedEvents, hasLength(2)); + expect( + relay.submittedEvents[1].createdAt, + greaterThan(relay.submittedEvents[0].createdAt), + ); + expect(jsonDecode(relay.submissions[1].content)['theme'], 'dracula'); + expect(manager.pending, isNull); + }); + + test( + 'republishes above remote observed while publish is in flight', + () async { + final firstSubmission = Completer(); + final relay = _FakeSignedRelay( + firstSubmissionGate: firstSubmission.future, + ); + final session = _FakeSession(); + final acknowledgements = []; + final manager = _manager( + session, + relay, + onPublished: acknowledgements.add, + ); + await manager.initialize(); + manager.publish(local); + final firstFlush = manager.flush(); + await _waitUntil(() => relay.attempts == 1); + + session.emit( + _event( + id: 'remote-winner', + createdAt: 2000000000, + content: jsonEncode( + const CommunityThemePreference( + theme: 'dracula', + accent: '#ef4444', + followSystem: false, + ).toJson(), + ), + ), + ); + firstSubmission.complete(); + await firstFlush; + + expect(acknowledgements, isEmpty); + expect(manager.pending, local); + await _waitUntil(() => relay.attempts == 2); + expect(relay.submittedEvents[1].createdAt, 2000000001); + expect(acknowledgements, [local]); + expect(manager.pending, isNull); + }, + ); + + test('remote coordinate advances pending local publish timestamp', () async { + final relay = _FakeSignedRelay(); + final session = _FakeSession(); + final manager = _manager(session, relay); + await manager.initialize(); + manager.publish(local); + + session.emit( + _event( + id: 'remote', + createdAt: 2000000000, + content: jsonEncode(local.toJson()), + ), + ); + await manager.flush(); + + expect(relay.submittedEvents.single.createdAt, 2000000001); + }); + + test( + 'remote replacement invalidates A to B to A no-op suppression', + () async { + final relay = _FakeSignedRelay(); + final session = _FakeSession(); + final manager = _manager(session, relay); + await manager.initialize(); + manager.publish(local); + await manager.flush(); + + const remotePreference = CommunityThemePreference( + theme: 'dracula', + accent: '#ef4444', + followSystem: false, + ); + session.emit( + _event( + id: 'remote', + createdAt: relay.submittedEvents.single.createdAt + 1, + content: jsonEncode(remotePreference.toJson()), + ), + ); + manager.publish(local); + await manager.flush(); + + expect(relay.submissions, hasLength(2)); + expect(manager.pending, isNull); + }, + ); + + test( + 'published coordinate rejects delayed same-second initialization result', + () async { + const stale = CommunityThemePreference( + theme: 'dracula', + accent: '#ef4444', + followSystem: false, + ); + final history = Completer>(); + final session = _FakeSession(historyFuture: history.future); + final relay = _FakeSignedRelay(eventId: 'published-a'); + final applied = []; + final manager = _manager( + session, + relay, + onRemote: (remote) => applied.add(remote.preference), + ); + + final initializing = manager.initialize(); + manager.publish(local); + await manager.flush(); + final createdAt = relay.submittedEvents.single.createdAt; + history.complete([ + _event( + id: 'published-z', + createdAt: createdAt, + content: jsonEncode(stale.toJson()), + ), + ]); + await initializing; + + expect(applied, isEmpty); + expect(manager.pending, isNull); + }, + ); +} + +CommunityThemeSyncManager _manager( + _FakeSession session, + _FakeSignedRelay relay, { + void Function(RemoteCommunityTheme)? onRemote, + void Function(CommunityThemePreference)? onPublished, +}) => CommunityThemeSyncManager( + pubkey: 'pk', + relaySession: session, + signedEventRelay: relay, + crypto: const CommunityThemeCrypto(encrypt: _identity, decrypt: _identity), + debounce: const Duration(days: 1), + publishRetryBase: const Duration(milliseconds: 1), + publishRetryMax: const Duration(milliseconds: 4), + subscriptionRetryBase: const Duration(milliseconds: 1), + onRemote: onRemote ?? (_) {}, + onPublished: onPublished ?? (_) {}, +); + +String _identity(String value) => value; + +NostrEvent _event({ + String id = 'event', + int createdAt = 1, + required String content, +}) => NostrEvent( + id: id, + pubkey: 'pk', + createdAt: createdAt, + kind: 30078, + tags: const [ + ['d', 'community-theme'], + ], + content: content, + sig: 'sig', +); + +class _FakeSession extends RelaySessionNotifier { + _FakeSession({ + List history = const [], + this.historyFuture, + this.error, + this.onFetchHistory, + }) : history = List.of(history); + List history; + final Future>? historyFuture; + final Object? error; + final void Function()? onFetchHistory; + int subscribeCalls = 0; + final List _listeners = []; + final List _closedCallbacks = []; + + int get activeListeners => _listeners.length; + void Function(String)? get latestClosedCallback => + _closedCallbacks.isEmpty ? null : _closedCallbacks.last; + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + if (error != null) throw error!; + onFetchHistory?.call(); + if (historyFuture != null) return historyFuture!; + return history; + } + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String)? onClosed, + }) async { + subscribeCalls++; + _listeners.add(onEvent); + _closedCallbacks.add(onClosed ?? (_) {}); + return () { + final index = _listeners.indexOf(onEvent); + if (index < 0) return; + _listeners.removeAt(index); + _closedCallbacks.removeAt(index); + }; + } + + void emit(NostrEvent event) { + for (final listener in List.of(_listeners)) { + listener(event); + } + } + + void closeLiveSubscription(String message) { + if (_listeners.isEmpty) return; + _listeners.removeAt(0); + _closedCallbacks.removeAt(0)(message); + } +} + +Future _waitUntil( + bool Function() condition, { + Duration timeout = const Duration(seconds: 2), +}) async { + final deadline = DateTime.now().add(timeout); + while (!condition()) { + if (DateTime.now().isAfter(deadline)) { + fail('condition not met within $timeout'); + } + await Future.delayed(const Duration(milliseconds: 1)); + } +} + +class _Submission { + final int kind; + final String content; + final List> tags; + const _Submission(this.kind, this.content, this.tags); +} + +class _FakeSignedRelay implements SignedEventRelay { + _FakeSignedRelay({ + this.failuresRemaining = 0, + this.eventId = 'event', + this.firstSubmissionGate, + }); + int failuresRemaining; + final String eventId; + final Future? firstSubmissionGate; + int attempts = 0; + final submissions = <_Submission>[]; + final submittedEvents = []; + @override + String? get pubkey => 'pk'; + @override + Future submit({ + required int kind, + required String content, + required List> tags, + int? createdAt, + void Function(NostrEvent)? onSigned, + }) async { + attempts++; + if (attempts == 1 && firstSubmissionGate != null) { + await firstSubmissionGate; + } + if (failuresRemaining > 0) { + failuresRemaining--; + throw StateError('publish failed'); + } + submissions.add(_Submission(kind, content, tags)); + final event = _event( + id: eventId, + content: content, + createdAt: createdAt ?? 0, + ); + onSigned?.call(event); + submittedEvents.add(event); + return event; + } +}