From 4da276104dadf8301ec8da54e3af1fc3f958b125 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Thu, 30 Jul 2026 00:38:22 -0700 Subject: [PATCH 1/9] feat(mobile): sync community appearance preferences Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- mobile/lib/app.dart | 10 +- .../features/settings/accent_picker_page.dart | 7 +- .../settings_page/appearance_section.dart | 43 ++-- .../features/settings/theme_picker_page.dart | 9 +- mobile/lib/shared/theme/accent_colors.dart | 80 ++++++- mobile/lib/shared/theme/buzz_theme.dart | 8 + .../theme/community_theme_preference.dart | 138 +++++++++++ .../theme/community_theme_provider.dart | 150 ++++++++++++ .../shared/theme/community_theme_sync.dart | 217 +++++++++++++++++ mobile/lib/shared/theme/theme.dart | 3 + .../settings/theme_picker_page_test.dart | 29 +++ .../shared/crypto/nip44_interop_test.dart | 21 ++ mobile/test/shared/theme/buzz_theme_test.dart | 18 ++ .../community_theme_preference_test.dart | 96 ++++++++ .../theme/community_theme_sync_test.dart | 218 ++++++++++++++++++ 15 files changed, 1003 insertions(+), 44 deletions(-) create mode 100644 mobile/lib/shared/theme/community_theme_preference.dart create mode 100644 mobile/lib/shared/theme/community_theme_provider.dart create mode 100644 mobile/lib/shared/theme/community_theme_sync.dart create mode 100644 mobile/test/shared/crypto/nip44_interop_test.dart create mode 100644 mobile/test/shared/theme/community_theme_preference_test.dart create mode 100644 mobile/test/shared/theme/community_theme_sync_test.dart 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 92cec8ef34..416b9815e7 100644 --- a/mobile/lib/shared/theme/buzz_theme.dart +++ b/mobile/lib/shared/theme/buzz_theme.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import 'accent_colors.dart'; import 'app_colors.dart'; /// Name of the first-party Buzz theme. Buzz reuses the GitHub Light palette for @@ -63,6 +64,13 @@ Color navigationSearchSurface(BuildContext context) { Color navigationDivider(BuildContext context, double opacity) => navigationPrimaryForeground(context).withValues(alpha: opacity); +/// 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..12f1ee450c --- /dev/null +++ b/mobile/lib/shared/theme/community_theme_preference.dart @@ -0,0 +1,138 @@ +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 _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))}'; + + CommunityThemePreference? read(String pubkey, String relayUrl) { + try { + final raw = prefs.getString(key(pubkey, relayUrl)); + if (raw == null) return null; + final decoded = jsonDecode(raw); + if (decoded is! Map) return null; + return CommunityThemePreference.fromJson(decoded); + } catch (_) { + return null; + } + } + + Future write( + String pubkey, + String relayUrl, + CommunityThemePreference preference, + ) => prefs.setString(key(pubkey, relayUrl), jsonEncode(preference.toJson())); + + 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..6cc82e885a --- /dev/null +++ b/mobile/lib/shared/theme/community_theme_provider.dart @@ -0,0 +1,150 @@ +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; + + @override + CommunityThemePreference build() { + _manager?.dispose(); + _manager = null; + + _storage = CommunityThemeStorage(ref.read(savedPrefsProvider)); + 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 fallback = _storage.hasMigrated(pubkey) + ? defaultCommunityTheme + : _storage.legacyPreference(); + final initial = 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), + ); + _manager = manager; + Future.microtask(() async { + final result = await manager.initialize(initial); + if (_manager != manager) return; + if (result.status == CommunityThemeRemoteStatus.valid || + result.status == CommunityThemeRemoteStatus.absent) { + if (result.status == CommunityThemeRemoteStatus.absent) { + await _storage.write(pubkey, config.baseUrl, initial); + } + 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; + final pubkey = _pubkey; + final relayUrl = _relayUrl; + if (pubkey == null || relayUrl == null) { + unawaited(_storage.writeLegacy(preference)); + return; + } + unawaited(_storage.write(pubkey, relayUrl, preference)); + _manager?.publish(preference); + } + + void _applyRemote( + CommunityThemeSyncManager manager, + RemoteCommunityTheme remote, + ) { + if (_manager != manager) return; + state = remote.preference; + final pubkey = _pubkey; + final relayUrl = _relayUrl; + 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 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..252d662370 --- /dev/null +++ b/mobile/lib/shared/theme/community_theme_sync.dart @@ -0,0 +1,217 @@ +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 void Function(RemoteCommunityTheme) onRemote; + + Timer? _publishTimer; + void Function()? _unsubscribe; + CommunityThemePreference? _pending; + CommunityThemePreference? _lastPublished; + int _lastCreatedAt = 0; + String _lastEventId = ''; + bool _disposed = false; + + CommunityThemeSyncManager({ + required this.pubkey, + required this.relaySession, + required this.signedEventRelay, + required this.crypto, + required this.onRemote, + this.debounce = const Duration(seconds: 2), + }); + + CommunityThemePreference? get pending => _pending; + + Future fetchRemote() async { + try { + final events = await relaySession.fetchHistory( + NostrFilter( + kinds: const [EventKind.readState], + authors: [pubkey], + tags: const { + '#d': [communityThemeDTag], + }, + 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( + CommunityThemePreference local, + ) async { + final result = await fetchRemote(); + if (_disposed) return result; + if (result.status == CommunityThemeRemoteStatus.valid) { + _accept(result.remote!); + } else if (result.status == CommunityThemeRemoteStatus.absent) { + publish(local); + } + try { + _unsubscribe = await relaySession.subscribe( + NostrFilter( + kinds: const [EventKind.readState], + authors: [pubkey], + tags: const { + '#d': [communityThemeDTag], + }, + limit: 0, + ), + (event) { + if (_disposed) return; + final remote = _decode(event); + if (remote != null) _accept(remote); + }, + ); + } catch (_) { + // History and the local cache remain usable without a live subscription. + } + return result; + } + + void publish(CommunityThemePreference preference) { + if (_disposed) return; + _pending = preference; + _publishTimer?.cancel(); + _publishTimer = Timer(debounce, () { + _publishTimer = null; + unawaited(flush()); + }); + } + + void cancelPending() { + _publishTimer?.cancel(); + _publishTimer = null; + _pending = null; + } + + Future flush() async { + final preference = _pending; + if (_disposed || preference == null || preference == _lastPublished) return; + try { + final content = crypto.encrypt(jsonEncode(preference.toJson())); + if (_disposed) return; + final createdAt = max( + DateTime.now().millisecondsSinceEpoch ~/ 1000, + _lastCreatedAt + 1, + ); + await signedEventRelay.submit( + kind: EventKind.readState, + content: content, + tags: const [ + ['d', communityThemeDTag], + ['t', communityThemeDTag], + ], + createdAt: createdAt, + ); + if (_disposed) return; + _lastCreatedAt = createdAt; + _lastPublished = preference; + if (_pending == preference) _pending = null; + } catch (error) { + debugPrint('[CommunityThemeSync] publish failed: $error'); + } + } + + 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 && + remote.eventId.compareTo(_lastEventId) <= 0)) { + return; + } + _lastCreatedAt = remote.createdAt; + _lastEventId = remote.eventId; + cancelPending(); + onRemote(remote); + } + + void dispose() { + if (_disposed) return; + _disposed = true; + cancelPending(); + _unsubscribe?.call(); + _unsubscribe = null; + } +} + +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 bc9992be90..51b5ad1f3b 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..0294740244 --- /dev/null +++ b/mobile/test/shared/theme/community_theme_preference_test.dart @@ -0,0 +1,96 @@ +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('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_sync_test.dart b/mobile/test/shared/theme/community_theme_sync_test.dart new file mode 100644 index 0000000000..f6d9f4c7c8 --- /dev/null +++ b/mobile/test/shared/theme/community_theme_sync_test.dart @@ -0,0 +1,218 @@ +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(local); + expect(result.status, CommunityThemeRemoteStatus.absent); + 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('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(local); + 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: 'a', createdAt: 50, content: jsonEncode(local.toJson())), + _event( + id: 'b', + createdAt: 50, + content: jsonEncode( + const CommunityThemePreference( + theme: 'dracula', + accent: '#ef4444', + followSystem: false, + ).toJson(), + ), + ), + ], + ); + final manager = _manager( + session, + _FakeSignedRelay(), + onRemote: (r) => applied.add(r.preference), + ); + + await manager.initialize(local); + expect(applied.single.theme, 'dracula'); + + session.emit( + _event(id: 'aa', createdAt: 50, content: jsonEncode(local.toJson())), + ); + expect(applied, hasLength(1)); + }, + ); + + test( + 'remote apply cancels pending user write and dispose guards scope', + () async { + final relay = _FakeSignedRelay(); + final session = _FakeSession(); + final manager = _manager(session, relay); + await manager.initialize(local); + 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, isEmpty); + + manager.publish(local); + manager.dispose(); + await manager.flush(); + expect(relay.submissions, isEmpty); + }, + ); + + test( + 'publish failure keeps pending preference for reconnect retry', + () async { + final relay = _FakeSignedRelay(fail: true); + final manager = _manager(_FakeSession(), relay); + manager.publish(local); + await manager.flush(); + expect(manager.pending, local); + }, + ); +} + +CommunityThemeSyncManager _manager( + _FakeSession session, + _FakeSignedRelay relay, { + void Function(RemoteCommunityTheme)? onRemote, +}) => CommunityThemeSyncManager( + pubkey: 'pk', + relaySession: session, + signedEventRelay: relay, + crypto: const CommunityThemeCrypto(encrypt: _identity, decrypt: _identity), + debounce: const Duration(days: 1), + onRemote: onRemote ?? (_) {}, +); + +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({this.history = const [], this.error}); + final List history; + final Object? error; + void Function(NostrEvent)? listener; + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + if (error != null) throw error!; + return history; + } + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String)? onClosed, + }) async { + listener = onEvent; + return () => listener = null; + } + + void emit(NostrEvent event) => listener?.call(event); +} + +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.fail = false}); + final bool fail; + final submissions = <_Submission>[]; + @override + String? get pubkey => 'pk'; + @override + Future submit({ + required int kind, + required String content, + required List> tags, + int? createdAt, + void Function(NostrEvent)? onSigned, + }) async { + if (fail) throw StateError('publish failed'); + submissions.add(_Submission(kind, content, tags)); + return _event(content: content, createdAt: createdAt ?? 0); + } +} From 3cb21d9910aba360ea7c96d814d80267889d8133 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Thu, 30 Jul 2026 09:37:56 -0700 Subject: [PATCH 2/9] fix(mobile): recover closed theme subscriptions Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../shared/theme/community_theme_sync.dart | 96 +++++++++++++----- .../theme/community_theme_sync_test.dart | 97 +++++++++++++++++-- 2 files changed, 164 insertions(+), 29 deletions(-) diff --git a/mobile/lib/shared/theme/community_theme_sync.dart b/mobile/lib/shared/theme/community_theme_sync.dart index 252d662370..ee1e6e0dc5 100644 --- a/mobile/lib/shared/theme/community_theme_sync.dart +++ b/mobile/lib/shared/theme/community_theme_sync.dart @@ -41,14 +41,18 @@ class CommunityThemeSyncManager { final SignedEventRelay signedEventRelay; final CommunityThemeCrypto crypto; final Duration debounce; + final Duration subscriptionRetryBase; final void Function(RemoteCommunityTheme) onRemote; Timer? _publishTimer; + Timer? _subscriptionRetryTimer; void Function()? _unsubscribe; CommunityThemePreference? _pending; CommunityThemePreference? _lastPublished; int _lastCreatedAt = 0; String _lastEventId = ''; + int _subscriptionEpoch = 0; + int _subscriptionRetryAttempt = 0; bool _disposed = false; CommunityThemeSyncManager({ @@ -58,22 +62,14 @@ class CommunityThemeSyncManager { required this.crypto, required this.onRemote, this.debounce = const Duration(seconds: 2), + this.subscriptionRetryBase = const Duration(seconds: 1), }); CommunityThemePreference? get pending => _pending; Future fetchRemote() async { try { - final events = await relaySession.fetchHistory( - NostrFilter( - kinds: const [EventKind.readState], - authors: [pubkey], - tags: const { - '#d': [communityThemeDTag], - }, - limit: 1, - ), - ); + final events = await relaySession.fetchHistory(_themeFilter(limit: 1)); if (events.isEmpty) { return const CommunityThemeRemoteResult( CommunityThemeRemoteStatus.absent, @@ -104,28 +100,79 @@ class CommunityThemeSyncManager { } else if (result.status == CommunityThemeRemoteStatus.absent) { publish(local); } + await _startLiveSubscription(); + return result; + } + + Future _startLiveSubscription() async { + if (_disposed) return false; + final epoch = ++_subscriptionEpoch; try { - _unsubscribe = await relaySession.subscribe( - NostrFilter( - kinds: const [EventKind.readState], - authors: [pubkey], - tags: const { - '#d': [communityThemeDTag], - }, - limit: 0, - ), + final unsubscribe = await relaySession.subscribe( + _themeFilter(limit: 0), (event) { - if (_disposed) return; + if (_disposed || epoch != _subscriptionEpoch) return; final remote = _decode(event); if (remote != null) _accept(remote); }, + onClosed: (message) => _handleSubscriptionClosed(epoch, message), ); - } catch (_) { - // History and the local cache remain usable without a live subscription. + 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; } - return result; } + 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 publish(CommunityThemePreference preference) { if (_disposed) return; _pending = preference; @@ -203,6 +250,9 @@ class CommunityThemeSyncManager { void dispose() { if (_disposed) return; _disposed = true; + _subscriptionEpoch++; + _subscriptionRetryTimer?.cancel(); + _subscriptionRetryTimer = null; cancelPending(); _unsubscribe?.call(); _unsubscribe = null; diff --git a/mobile/test/shared/theme/community_theme_sync_test.dart b/mobile/test/shared/theme/community_theme_sync_test.dart index f6d9f4c7c8..e60a303066 100644 --- a/mobile/test/shared/theme/community_theme_sync_test.dart +++ b/mobile/test/shared/theme/community_theme_sync_test.dart @@ -119,6 +119,53 @@ void main() { }, ); + 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(local); + 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(local); + final close = session.latestClosedCallback; + + manager.dispose(); + close?.call('late close'); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(session.subscribeCalls, 1); + }); + test( 'publish failure keeps pending preference for reconnect retry', () async { @@ -141,6 +188,7 @@ CommunityThemeSyncManager _manager( signedEventRelay: relay, crypto: const CommunityThemeCrypto(encrypt: _identity, decrypt: _identity), debounce: const Duration(days: 1), + subscriptionRetryBase: const Duration(milliseconds: 1), onRemote: onRemote ?? (_) {}, ); @@ -163,10 +211,17 @@ NostrEvent _event({ ); class _FakeSession extends RelaySessionNotifier { - _FakeSession({this.history = const [], this.error}); - final List history; + _FakeSession({List history = const [], this.error}) + : history = List.of(history); + List history; final Object? error; - void Function(NostrEvent)? listener; + 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( @@ -183,11 +238,41 @@ class _FakeSession extends RelaySessionNotifier { void Function(NostrEvent) onEvent, { void Function(String)? onClosed, }) async { - listener = onEvent; - return () => listener = null; + 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) => listener?.call(event); + 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 { From 86167b20f39923a50a6c48a05300bc5188d4bcab Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Thu, 30 Jul 2026 13:29:10 -0700 Subject: [PATCH 3/9] fix(mobile): preserve unsynced theme edits Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../theme/community_theme_preference.dart | 33 +++++++++- .../theme/community_theme_provider.dart | 40 ++++++++--- .../shared/theme/community_theme_sync.dart | 12 ++-- .../community_theme_preference_test.dart | 27 ++++++++ .../theme/community_theme_sync_test.dart | 66 +++++++++---------- 5 files changed, 128 insertions(+), 50 deletions(-) diff --git a/mobile/lib/shared/theme/community_theme_preference.dart b/mobile/lib/shared/theme/community_theme_preference.dart index 12f1ee450c..216f6a8d3c 100644 --- a/mobile/lib/shared/theme/community_theme_preference.dart +++ b/mobile/lib/shared/theme/community_theme_preference.dart @@ -68,6 +68,7 @@ class CommunityThemePreference { 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'; @@ -80,9 +81,12 @@ class CommunityThemeStorage { String key(String pubkey, String relayUrl) => '$_prefix:$pubkey:${Uri.encodeComponent(normalizeCommunityRelayUrl(relayUrl))}'; - CommunityThemePreference? read(String pubkey, String relayUrl) { + String outboxKey(String pubkey, String relayUrl) => + '$_outboxPrefix:$pubkey:${Uri.encodeComponent(normalizeCommunityRelayUrl(relayUrl))}'; + + CommunityThemePreference? _readKey(String storageKey) { try { - final raw = prefs.getString(key(pubkey, relayUrl)); + final raw = prefs.getString(storageKey); if (raw == null) return null; final decoded = jsonDecode(raw); if (decoded is! Map) return null; @@ -92,12 +96,37 @@ class CommunityThemeStorage { } } + 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; diff --git a/mobile/lib/shared/theme/community_theme_provider.dart b/mobile/lib/shared/theme/community_theme_provider.dart index 6cc82e885a..2d0003a916 100644 --- a/mobile/lib/shared/theme/community_theme_provider.dart +++ b/mobile/lib/shared/theme/community_theme_provider.dart @@ -36,10 +36,11 @@ class CommunityThemeNotifier extends Notifier { } final cached = _storage.read(pubkey, config.baseUrl); + final dirty = _storage.readOutbox(pubkey, config.baseUrl); final fallback = _storage.hasMigrated(pubkey) ? defaultCommunityTheme : _storage.legacyPreference(); - final initial = cached ?? fallback; + final initial = dirty ?? cached ?? fallback; if (session.status == SessionStatus.connected) { late final CommunityThemeSyncManager manager; @@ -52,16 +53,21 @@ class CommunityThemeNotifier extends Notifier { ), crypto: _crypto(config.nsec!, pubkey), onRemote: (remote) => _applyRemote(manager, remote), + onPublished: (preference) => + unawaited(_storage.clearOutbox(pubkey, config.baseUrl, preference)), ); _manager = manager; + if (dirty != null) manager.publish(dirty); Future.microtask(() async { - final result = await manager.initialize(initial); + final result = await manager.initialize(); if (_manager != manager) return; + if (result.status == CommunityThemeRemoteStatus.absent) { + await _storage.write(pubkey, config.baseUrl, initial); + await _storage.writeOutbox(pubkey, config.baseUrl, initial); + if (_manager == manager) manager.publish(initial); + } if (result.status == CommunityThemeRemoteStatus.valid || result.status == CommunityThemeRemoteStatus.absent) { - if (result.status == CommunityThemeRemoteStatus.absent) { - await _storage.write(pubkey, config.baseUrl, initial); - } await _storage.markMigrated(pubkey); } }); @@ -117,8 +123,19 @@ class CommunityThemeNotifier extends Notifier { unawaited(_storage.writeLegacy(preference)); return; } - unawaited(_storage.write(pubkey, relayUrl, preference)); - _manager?.publish(preference); + unawaited(_persistAndPublish(pubkey, relayUrl, preference)); + } + + 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) { + _manager?.publish(preference); + } } void _applyRemote( @@ -126,9 +143,16 @@ class CommunityThemeNotifier extends Notifier { RemoteCommunityTheme remote, ) { if (_manager != manager) return; - state = remote.preference; 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)); } diff --git a/mobile/lib/shared/theme/community_theme_sync.dart b/mobile/lib/shared/theme/community_theme_sync.dart index ee1e6e0dc5..de818311e3 100644 --- a/mobile/lib/shared/theme/community_theme_sync.dart +++ b/mobile/lib/shared/theme/community_theme_sync.dart @@ -43,6 +43,7 @@ class CommunityThemeSyncManager { final Duration debounce; final Duration subscriptionRetryBase; final void Function(RemoteCommunityTheme) onRemote; + final void Function(CommunityThemePreference) onPublished; Timer? _publishTimer; Timer? _subscriptionRetryTimer; @@ -61,6 +62,7 @@ class CommunityThemeSyncManager { required this.signedEventRelay, required this.crypto, required this.onRemote, + this.onPublished = _ignorePublished, this.debounce = const Duration(seconds: 2), this.subscriptionRetryBase = const Duration(seconds: 1), }); @@ -90,15 +92,11 @@ class CommunityThemeSyncManager { } } - Future initialize( - CommunityThemePreference local, - ) async { + Future initialize() async { final result = await fetchRemote(); if (_disposed) return result; if (result.status == CommunityThemeRemoteStatus.valid) { _accept(result.remote!); - } else if (result.status == CommunityThemeRemoteStatus.absent) { - publish(local); } await _startLiveSubscription(); return result; @@ -212,6 +210,7 @@ class CommunityThemeSyncManager { _lastCreatedAt = createdAt; _lastPublished = preference; if (_pending == preference) _pending = null; + onPublished(preference); } catch (error) { debugPrint('[CommunityThemeSync] publish failed: $error'); } @@ -236,6 +235,7 @@ class CommunityThemeSyncManager { } void _accept(RemoteCommunityTheme remote) { + if (_pending != null) return; if (remote.createdAt < _lastCreatedAt || (remote.createdAt == _lastCreatedAt && remote.eventId.compareTo(_lastEventId) <= 0)) { @@ -259,6 +259,8 @@ class CommunityThemeSyncManager { } } +void _ignorePublished(CommunityThemePreference _) {} + NostrEvent _newerEvent(NostrEvent left, NostrEvent right) { if (right.createdAt != left.createdAt) { return right.createdAt > left.createdAt ? right : left; diff --git a/mobile/test/shared/theme/community_theme_preference_test.dart b/mobile/test/shared/theme/community_theme_preference_test.dart index 0294740244..ce6a2afaec 100644 --- a/mobile/test/shared/theme/community_theme_preference_test.dart +++ b/mobile/test/shared/theme/community_theme_preference_test.dart @@ -72,6 +72,33 @@ void main() { 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', diff --git a/mobile/test/shared/theme/community_theme_sync_test.dart b/mobile/test/shared/theme/community_theme_sync_test.dart index e60a303066..8399105258 100644 --- a/mobile/test/shared/theme/community_theme_sync_test.dart +++ b/mobile/test/shared/theme/community_theme_sync_test.dart @@ -16,8 +16,9 @@ void main() { final relay = _FakeSignedRelay(); final manager = _manager(session, relay); - final result = await manager.initialize(local); + final result = await manager.initialize(); expect(result.status, CommunityThemeRemoteStatus.absent); + manager.publish(local); await manager.flush(); expect(relay.submissions, hasLength(1)); @@ -38,7 +39,7 @@ void main() { _FakeSession(error: StateError('offline')), ]) { final relay = _FakeSignedRelay(); - final result = await _manager(session, relay).initialize(local); + final result = await _manager(session, relay).initialize(); expect( result.status, anyOf( @@ -76,7 +77,7 @@ void main() { onRemote: (r) => applied.add(r.preference), ); - await manager.initialize(local); + await manager.initialize(); expect(applied.single.theme, 'dracula'); session.emit( @@ -86,38 +87,32 @@ void main() { }, ); - test( - 'remote apply cancels pending user write and dispose guards scope', - () async { - final relay = _FakeSignedRelay(); - final session = _FakeSession(); - final manager = _manager(session, relay); - await manager.initialize(local); - manager.cancelPending(); - manager.publish( - const CommunityThemePreference( - theme: 'dracula', - accent: '#ef4444', - followSystem: false, - ), - ); + 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, isEmpty); + 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, isEmpty); - }, - ); + manager.publish(local); + manager.dispose(); + await manager.flush(); + expect(relay.submissions, hasLength(1)); + }); test( 'relay CLOSED resubscribes then catches up latest replacement event', @@ -129,7 +124,8 @@ void main() { _FakeSignedRelay(), onRemote: (remote) => applied.add(remote.preference), ); - await manager.initialize(local); + await manager.initialize(); + manager.cancelPending(); expect(session.subscribeCalls, 1); const replacement = CommunityThemePreference( @@ -156,7 +152,7 @@ void main() { test('relay CLOSED after dispose never resubscribes', () async { final session = _FakeSession(); final manager = _manager(session, _FakeSignedRelay()); - await manager.initialize(local); + await manager.initialize(); final close = session.latestClosedCallback; manager.dispose(); From 98c92ff444f9a9dafe8998fa1904d2d2e0c7b909 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Thu, 30 Jul 2026 15:15:56 -0700 Subject: [PATCH 4/9] fix(mobile): retry theme preference publishes Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../shared/theme/community_theme_sync.dart | 31 ++++++- .../theme/community_theme_sync_test.dart | 87 ++++++++++++++++--- 2 files changed, 106 insertions(+), 12 deletions(-) diff --git a/mobile/lib/shared/theme/community_theme_sync.dart b/mobile/lib/shared/theme/community_theme_sync.dart index de818311e3..1d053a023b 100644 --- a/mobile/lib/shared/theme/community_theme_sync.dart +++ b/mobile/lib/shared/theme/community_theme_sync.dart @@ -41,6 +41,8 @@ class CommunityThemeSyncManager { 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; @@ -54,6 +56,7 @@ class CommunityThemeSyncManager { String _lastEventId = ''; int _subscriptionEpoch = 0; int _subscriptionRetryAttempt = 0; + int _publishRetryAttempt = 0; bool _disposed = false; CommunityThemeSyncManager({ @@ -64,6 +67,8 @@ class CommunityThemeSyncManager { 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), }); @@ -174,8 +179,14 @@ class CommunityThemeSyncManager { void publish(CommunityThemePreference preference) { if (_disposed) return; _pending = preference; + _publishRetryAttempt = 0; + _schedulePublish(debounce); + } + + void _schedulePublish(Duration delay) { + if (_disposed) return; _publishTimer?.cancel(); - _publishTimer = Timer(debounce, () { + _publishTimer = Timer(delay, () { _publishTimer = null; unawaited(flush()); }); @@ -197,6 +208,7 @@ class CommunityThemeSyncManager { DateTime.now().millisecondsSinceEpoch ~/ 1000, _lastCreatedAt + 1, ); + NostrEvent? signed; await signedEventRelay.submit( kind: EventKind.readState, content: content, @@ -205,14 +217,29 @@ class CommunityThemeSyncManager { ['t', communityThemeDTag], ], createdAt: createdAt, + onSigned: (event) => signed = event, ); if (_disposed) return; - _lastCreatedAt = createdAt; + final published = signed; + if (published == null) { + throw StateError('Signed event coordinate unavailable'); + } + _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)); } } diff --git a/mobile/test/shared/theme/community_theme_sync_test.dart b/mobile/test/shared/theme/community_theme_sync_test.dart index 8399105258..2fb3104bc8 100644 --- a/mobile/test/shared/theme/community_theme_sync_test.dart +++ b/mobile/test/shared/theme/community_theme_sync_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:buzz/shared/relay/relay.dart'; @@ -162,14 +163,57 @@ void main() { 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( - 'publish failure keeps pending preference for reconnect retry', + 'published coordinate rejects delayed same-second initialization result', () async { - final relay = _FakeSignedRelay(fail: true); - final manager = _manager(_FakeSession(), relay); + const stale = CommunityThemePreference( + theme: 'dracula', + accent: '#ef4444', + followSystem: false, + ); + final history = Completer>(); + final session = _FakeSession(historyFuture: history.future); + final relay = _FakeSignedRelay(eventId: 'published-z'); + final applied = []; + final manager = _manager( + session, + relay, + onRemote: (remote) => applied.add(remote.preference), + ); + + final initializing = manager.initialize(); manager.publish(local); await manager.flush(); - expect(manager.pending, local); + final createdAt = relay.submittedEvents.single.createdAt; + history.complete([ + _event( + id: 'published-a', + createdAt: createdAt, + content: jsonEncode(stale.toJson()), + ), + ]); + await initializing; + + expect(applied, isEmpty); + expect(manager.pending, isNull); }, ); } @@ -178,14 +222,18 @@ 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; @@ -207,9 +255,13 @@ NostrEvent _event({ ); class _FakeSession extends RelaySessionNotifier { - _FakeSession({List history = const [], this.error}) - : history = List.of(history); + _FakeSession({ + List history = const [], + this.historyFuture, + this.error, + }) : history = List.of(history); List history; + final Future>? historyFuture; final Object? error; int subscribeCalls = 0; final List _listeners = []; @@ -225,6 +277,7 @@ class _FakeSession extends RelaySessionNotifier { Duration timeout = const Duration(seconds: 8), }) async { if (error != null) throw error!; + if (historyFuture != null) return historyFuture!; return history; } @@ -279,9 +332,12 @@ class _Submission { } class _FakeSignedRelay implements SignedEventRelay { - _FakeSignedRelay({this.fail = false}); - final bool fail; + _FakeSignedRelay({this.failuresRemaining = 0, this.eventId = 'event'}); + int failuresRemaining; + final String eventId; + int attempts = 0; final submissions = <_Submission>[]; + final submittedEvents = []; @override String? get pubkey => 'pk'; @override @@ -292,8 +348,19 @@ class _FakeSignedRelay implements SignedEventRelay { int? createdAt, void Function(NostrEvent)? onSigned, }) async { - if (fail) throw StateError('publish failed'); + attempts++; + if (failuresRemaining > 0) { + failuresRemaining--; + throw StateError('publish failed'); + } submissions.add(_Submission(kind, content, tags)); - return _event(content: content, createdAt: createdAt ?? 0); + final event = _event( + id: eventId, + content: content, + createdAt: createdAt ?? 0, + ); + onSigned?.call(event); + submittedEvents.add(event); + return event; } } From 3e181a8a7d9c57bb4960a56a3dde2e8b18ffb9df Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Mon, 3 Aug 2026 13:59:42 -0700 Subject: [PATCH 5/9] fix(mobile): preserve pending theme intent Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../theme/community_theme_provider.dart | 9 +- .../shared/theme/community_theme_sync.dart | 18 +- .../theme/community_theme_provider_test.dart | 180 ++++++++++++++++++ .../theme/community_theme_sync_test.dart | 22 ++- 4 files changed, 218 insertions(+), 11 deletions(-) create mode 100644 mobile/test/shared/theme/community_theme_provider_test.dart diff --git a/mobile/lib/shared/theme/community_theme_provider.dart b/mobile/lib/shared/theme/community_theme_provider.dart index 2d0003a916..7b89966684 100644 --- a/mobile/lib/shared/theme/community_theme_provider.dart +++ b/mobile/lib/shared/theme/community_theme_provider.dart @@ -22,7 +22,7 @@ class CommunityThemeNotifier extends Notifier { _manager?.dispose(); _manager = null; - _storage = CommunityThemeStorage(ref.read(savedPrefsProvider)); + _storage = ref.watch(communityThemeStorageProvider); final config = ref.watch(relayConfigProvider); final session = ref.watch(relaySessionProvider); final pubkey = pubkeyFromNsec(config.nsec); @@ -123,6 +123,7 @@ class CommunityThemeNotifier extends Notifier { unawaited(_storage.writeLegacy(preference)); return; } + _manager?.stage(preference); unawaited(_persistAndPublish(pubkey, relayUrl, preference)); } @@ -134,7 +135,7 @@ class CommunityThemeNotifier extends Notifier { if (!await _storage.write(pubkey, relayUrl, preference)) return; if (!await _storage.writeOutbox(pubkey, relayUrl, preference)) return; if (_pubkey == pubkey && _relayUrl == relayUrl) { - _manager?.publish(preference); + _manager?.publishStaged(preference); } } @@ -168,6 +169,10 @@ CommunityThemeCrypto _crypto(String nsec, String pubkey) { ); } +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 index 1d053a023b..e6d5a0455b 100644 --- a/mobile/lib/shared/theme/community_theme_sync.dart +++ b/mobile/lib/shared/theme/community_theme_sync.dart @@ -176,10 +176,21 @@ class CommunityThemeSyncManager { limit: limit, ); - void publish(CommunityThemePreference preference) { + 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); } @@ -265,7 +276,8 @@ class CommunityThemeSyncManager { if (_pending != null) return; if (remote.createdAt < _lastCreatedAt || (remote.createdAt == _lastCreatedAt && - remote.eventId.compareTo(_lastEventId) <= 0)) { + _lastEventId.isNotEmpty && + remote.eventId.compareTo(_lastEventId) >= 0)) { return; } _lastCreatedAt = remote.createdAt; @@ -292,5 +304,5 @@ 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; + return right.id.compareTo(left.id) < 0 ? right : left; } 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..63261fc0b3 --- /dev/null +++ b/mobile/test/shared/theme/community_theme_provider_test.dart @@ -0,0 +1,180 @@ +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( + '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); + }, + ); +} + +class _DelayedThemeStorage extends CommunityThemeStorage { + _DelayedThemeStorage(super.prefs); + + final allowCacheWrite = Completer(); + final allowOutboxWrite = Completer(); + final outboxWriteStarted = Completer(); + + @override + Future write( + String pubkey, + String relayUrl, + CommunityThemePreference preference, + ) async { + 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); + + final String nsec; + final String pubkey; + final subscribed = Completer(); + 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 => [remoteEvent(theme: 'buzz', id: 'initial')]; + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async { + _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); + + 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 index 2fb3104bc8..b7e3f8d2e5 100644 --- a/mobile/test/shared/theme/community_theme_sync_test.dart +++ b/mobile/test/shared/theme/community_theme_sync_test.dart @@ -58,9 +58,8 @@ void main() { final applied = []; final session = _FakeSession( history: [ - _event(id: 'a', createdAt: 50, content: jsonEncode(local.toJson())), _event( - id: 'b', + id: 'z', createdAt: 50, content: jsonEncode( const CommunityThemePreference( @@ -70,6 +69,7 @@ void main() { ).toJson(), ), ), + _event(id: 'a', createdAt: 50, content: jsonEncode(local.toJson())), ], ); final manager = _manager( @@ -79,10 +79,20 @@ void main() { ); await manager.initialize(); - expect(applied.single.theme, 'dracula'); + expect(applied.single.theme, 'buzz'); session.emit( - _event(id: 'aa', createdAt: 50, content: jsonEncode(local.toJson())), + _event( + id: 'z', + createdAt: 50, + content: jsonEncode( + const CommunityThemePreference( + theme: 'dracula', + accent: '#ef4444', + followSystem: false, + ).toJson(), + ), + ), ); expect(applied, hasLength(1)); }, @@ -191,7 +201,7 @@ void main() { ); final history = Completer>(); final session = _FakeSession(historyFuture: history.future); - final relay = _FakeSignedRelay(eventId: 'published-z'); + final relay = _FakeSignedRelay(eventId: 'published-a'); final applied = []; final manager = _manager( session, @@ -205,7 +215,7 @@ void main() { final createdAt = relay.submittedEvents.single.createdAt; history.complete([ _event( - id: 'published-a', + id: 'published-z', createdAt: createdAt, content: jsonEncode(stale.toJson()), ), From bba48155ef5efdf67fa13ecab47feac71f6ddb19 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Mon, 3 Aug 2026 18:22:47 -0700 Subject: [PATCH 6/9] fix(mobile): converge theme publishes Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../theme/community_theme_provider.dart | 9 +- .../shared/theme/community_theme_sync.dart | 34 ++++++- .../theme/community_theme_provider_test.dart | 37 +++++++- .../theme/community_theme_sync_test.dart | 89 ++++++++++++++++++- 4 files changed, 160 insertions(+), 9 deletions(-) diff --git a/mobile/lib/shared/theme/community_theme_provider.dart b/mobile/lib/shared/theme/community_theme_provider.dart index 7b89966684..9abc53a86d 100644 --- a/mobile/lib/shared/theme/community_theme_provider.dart +++ b/mobile/lib/shared/theme/community_theme_provider.dart @@ -62,9 +62,12 @@ class CommunityThemeNotifier extends Notifier { final result = await manager.initialize(); if (_manager != manager) return; if (result.status == CommunityThemeRemoteStatus.absent) { - await _storage.write(pubkey, config.baseUrl, initial); - await _storage.writeOutbox(pubkey, config.baseUrl, initial); - if (_manager == manager) manager.publish(initial); + final currentDirty = _storage.readOutbox(pubkey, config.baseUrl); + final seed = + currentDirty ?? _storage.read(pubkey, config.baseUrl) ?? state; + await _storage.write(pubkey, config.baseUrl, seed); + await _storage.writeOutbox(pubkey, config.baseUrl, seed); + if (_manager == manager) manager.publish(seed); } if (result.status == CommunityThemeRemoteStatus.valid || result.status == CommunityThemeRemoteStatus.absent) { diff --git a/mobile/lib/shared/theme/community_theme_sync.dart b/mobile/lib/shared/theme/community_theme_sync.dart index e6d5a0455b..7ee7988a33 100644 --- a/mobile/lib/shared/theme/community_theme_sync.dart +++ b/mobile/lib/shared/theme/community_theme_sync.dart @@ -57,6 +57,8 @@ class CommunityThemeSyncManager { int _subscriptionEpoch = 0; int _subscriptionRetryAttempt = 0; int _publishRetryAttempt = 0; + bool _publishInFlight = false; + bool _publishRequestedWhileInFlight = false; bool _disposed = false; CommunityThemeSyncManager({ @@ -210,8 +212,20 @@ class CommunityThemeSyncManager { } Future flush() async { + if (_publishInFlight) { + _publishTimer?.cancel(); + _publishTimer = null; + _publishRequestedWhileInFlight = true; + return; + } final preference = _pending; - if (_disposed || preference == null || preference == _lastPublished) return; + 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; @@ -251,6 +265,17 @@ class CommunityThemeSyncManager { 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; + } } } @@ -273,7 +298,6 @@ class CommunityThemeSyncManager { } void _accept(RemoteCommunityTheme remote) { - if (_pending != null) return; if (remote.createdAt < _lastCreatedAt || (remote.createdAt == _lastCreatedAt && _lastEventId.isNotEmpty && @@ -282,7 +306,11 @@ class CommunityThemeSyncManager { } _lastCreatedAt = remote.createdAt; _lastEventId = remote.eventId; - cancelPending(); + if (_pending != null) { + _lastPublished = null; + return; + } + _lastPublished = null; onRemote(remote); } diff --git a/mobile/test/shared/theme/community_theme_provider_test.dart b/mobile/test/shared/theme/community_theme_provider_test.dart index 63261fc0b3..dc5328e9f4 100644 --- a/mobile/test/shared/theme/community_theme_provider_test.dart +++ b/mobile/test/shared/theme/community_theme_provider_test.dart @@ -10,6 +10,38 @@ 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( 'local edit stays authoritative before persistence through exact ack', () async { @@ -104,10 +136,11 @@ class _RelayConfig extends RelayConfigNotifier { } class _ThemeRelaySession extends RelaySessionNotifier { - _ThemeRelaySession(this.nsec, this.pubkey); + _ThemeRelaySession(this.nsec, this.pubkey, {this.historyFuture}); final String nsec; final String pubkey; + final Future>? historyFuture; final subscribed = Completer(); void Function(NostrEvent)? _listener; NostrEvent? published; @@ -119,7 +152,7 @@ class _ThemeRelaySession extends RelaySessionNotifier { Future> fetchHistory( NostrFilter filter, { Duration timeout = const Duration(seconds: 8), - }) async => [remoteEvent(theme: 'buzz', id: 'initial')]; + }) async => historyFuture ?? [remoteEvent(theme: 'buzz', id: 'initial')]; @override Future subscribe( diff --git a/mobile/test/shared/theme/community_theme_sync_test.dart b/mobile/test/shared/theme/community_theme_sync_test.dart index b7e3f8d2e5..c5b14727cb 100644 --- a/mobile/test/shared/theme/community_theme_sync_test.dart +++ b/mobile/test/shared/theme/community_theme_sync_test.dart @@ -191,6 +191,85 @@ void main() { 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('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 { @@ -342,9 +421,14 @@ class _Submission { } class _FakeSignedRelay implements SignedEventRelay { - _FakeSignedRelay({this.failuresRemaining = 0, this.eventId = 'event'}); + _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 = []; @@ -359,6 +443,9 @@ class _FakeSignedRelay implements SignedEventRelay { void Function(NostrEvent)? onSigned, }) async { attempts++; + if (attempts == 1 && firstSubmissionGate != null) { + await firstSubmissionGate; + } if (failuresRemaining > 0) { failuresRemaining--; throw StateError('publish failed'); From e8b2dc71a7dcf88584bc65a3fd7edbb913145d8c Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 10:08:18 -0700 Subject: [PATCH 7/9] fix(mobile): preserve themes across relay races Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../theme/community_theme_provider.dart | 36 ++++++++++++--- .../shared/theme/community_theme_sync.dart | 11 +++++ .../theme/community_theme_provider_test.dart | 41 +++++++++++++++++ .../theme/community_theme_sync_test.dart | 44 +++++++++++++++++++ 4 files changed, 125 insertions(+), 7 deletions(-) diff --git a/mobile/lib/shared/theme/community_theme_provider.dart b/mobile/lib/shared/theme/community_theme_provider.dart index 9abc53a86d..36ae0e4ac3 100644 --- a/mobile/lib/shared/theme/community_theme_provider.dart +++ b/mobile/lib/shared/theme/community_theme_provider.dart @@ -16,6 +16,8 @@ class CommunityThemeNotifier extends Notifier { late CommunityThemeStorage _storage; String? _pubkey; String? _relayUrl; + Future _persistenceQueue = Future.value(); + int _localRevision = 0; @override CommunityThemePreference build() { @@ -62,12 +64,21 @@ class CommunityThemeNotifier extends Notifier { final result = await manager.initialize(); if (_manager != manager) return; if (result.status == CommunityThemeRemoteStatus.absent) { - final currentDirty = _storage.readOutbox(pubkey, config.baseUrl); - final seed = - currentDirty ?? _storage.read(pubkey, config.baseUrl) ?? state; - await _storage.write(pubkey, config.baseUrl, seed); - await _storage.writeOutbox(pubkey, config.baseUrl, seed); - if (_manager == manager) manager.publish(seed); + 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) { @@ -120,6 +131,7 @@ class CommunityThemeNotifier extends Notifier { void _save(CommunityThemePreference preference) { if (preference == state) return; state = preference; + _localRevision++; final pubkey = _pubkey; final relayUrl = _relayUrl; if (pubkey == null || relayUrl == null) { @@ -127,7 +139,17 @@ class CommunityThemeNotifier extends Notifier { return; } _manager?.stage(preference); - unawaited(_persistAndPublish(pubkey, relayUrl, 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( diff --git a/mobile/lib/shared/theme/community_theme_sync.dart b/mobile/lib/shared/theme/community_theme_sync.dart index 7ee7988a33..3e72cc9aa8 100644 --- a/mobile/lib/shared/theme/community_theme_sync.dart +++ b/mobile/lib/shared/theme/community_theme_sync.dart @@ -249,6 +249,17 @@ class CommunityThemeSyncManager { 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; diff --git a/mobile/test/shared/theme/community_theme_provider_test.dart b/mobile/test/shared/theme/community_theme_provider_test.dart index dc5328e9f4..f76dabd803 100644 --- a/mobile/test/shared/theme/community_theme_provider_test.dart +++ b/mobile/test/shared/theme/community_theme_provider_test.dart @@ -42,6 +42,45 @@ void main() { ); }); + 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', + ); + expect( + storage.readOutbox(keys.public, 'https://relay.example')?.theme, + 'dracula', + ); + }); + test( 'local edit stays authoritative before persistence through exact ack', () async { @@ -101,6 +140,7 @@ class _DelayedThemeStorage extends CommunityThemeStorage { final allowCacheWrite = Completer(); final allowOutboxWrite = Completer(); + final cacheWriteStarted = Completer(); final outboxWriteStarted = Completer(); @override @@ -109,6 +149,7 @@ class _DelayedThemeStorage extends CommunityThemeStorage { String relayUrl, CommunityThemePreference preference, ) async { + if (!cacheWriteStarted.isCompleted) cacheWriteStarted.complete(); await allowCacheWrite.future; return super.write(pubkey, relayUrl, preference); } diff --git a/mobile/test/shared/theme/community_theme_sync_test.dart b/mobile/test/shared/theme/community_theme_sync_test.dart index c5b14727cb..7b829ad207 100644 --- a/mobile/test/shared/theme/community_theme_sync_test.dart +++ b/mobile/test/shared/theme/community_theme_sync_test.dart @@ -221,6 +221,50 @@ void main() { 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(); From 58330de59ee93672b79ff16c1ae3d6a6f54c2411 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 10:24:46 -0700 Subject: [PATCH 8/9] test(mobile): assert published absence seed Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- mobile/test/shared/theme/community_theme_provider_test.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mobile/test/shared/theme/community_theme_provider_test.dart b/mobile/test/shared/theme/community_theme_provider_test.dart index f76dabd803..14990414c4 100644 --- a/mobile/test/shared/theme/community_theme_provider_test.dart +++ b/mobile/test/shared/theme/community_theme_provider_test.dart @@ -75,8 +75,10 @@ void main() { 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( - storage.readOutbox(keys.public, 'https://relay.example')?.theme, + jsonDecode(nip44Decrypt(key, session.published!.content))['theme'], 'dracula', ); }); From 650d151cd6bc845f343930ed94d16c5ec018450f Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 4 Aug 2026 11:19:43 -0700 Subject: [PATCH 9/9] fix(mobile): preserve theme sync across rebuilds Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../theme/community_theme_provider.dart | 36 ++++++++++-- .../shared/theme/community_theme_sync.dart | 21 ++++++- .../theme/community_theme_provider_test.dart | 55 +++++++++++++++++++ .../theme/community_theme_sync_test.dart | 39 +++++++++++++ 4 files changed, 145 insertions(+), 6 deletions(-) diff --git a/mobile/lib/shared/theme/community_theme_provider.dart b/mobile/lib/shared/theme/community_theme_provider.dart index 36ae0e4ac3..4d66de8844 100644 --- a/mobile/lib/shared/theme/community_theme_provider.dart +++ b/mobile/lib/shared/theme/community_theme_provider.dart @@ -18,6 +18,9 @@ class CommunityThemeNotifier extends Notifier { String? _relayUrl; Future _persistenceQueue = Future.value(); int _localRevision = 0; + CommunityThemePreference? _scopedLocalPreference; + String? _scopedLocalPubkey; + String? _scopedLocalRelayUrl; @override CommunityThemePreference build() { @@ -39,10 +42,19 @@ class CommunityThemeNotifier extends Notifier { 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 = dirty ?? cached ?? fallback; + final initial = inMemoryLocal ?? dirty ?? cached ?? fallback; if (session.status == SessionStatus.connected) { late final CommunityThemeSyncManager manager; @@ -55,11 +67,18 @@ class CommunityThemeNotifier extends Notifier { ), crypto: _crypto(config.nsec!, pubkey), onRemote: (remote) => _applyRemote(manager, remote), - onPublished: (preference) => - unawaited(_storage.clearOutbox(pubkey, config.baseUrl, preference)), + onPublished: (preference) { + if (_scopedLocalPubkey == pubkey && + _scopedLocalRelayUrl == config.baseUrl && + _scopedLocalPreference == preference) { + _scopedLocalPreference = null; + } + unawaited(_storage.clearOutbox(pubkey, config.baseUrl, preference)); + }, ); _manager = manager; - if (dirty != null) manager.publish(dirty); + final pending = inMemoryLocal ?? dirty; + if (pending != null) manager.publish(pending); Future.microtask(() async { final result = await manager.initialize(); if (_manager != manager) return; @@ -138,6 +157,9 @@ class CommunityThemeNotifier extends Notifier { unawaited(_storage.writeLegacy(preference)); return; } + _scopedLocalPreference = preference; + _scopedLocalPubkey = pubkey; + _scopedLocalRelayUrl = relayUrl; _manager?.stage(preference); unawaited( _enqueuePersistence( @@ -160,7 +182,11 @@ class CommunityThemeNotifier extends Notifier { if (!await _storage.write(pubkey, relayUrl, preference)) return; if (!await _storage.writeOutbox(pubkey, relayUrl, preference)) return; if (_pubkey == pubkey && _relayUrl == relayUrl) { - _manager?.publishStaged(preference); + final manager = _manager; + if (manager != null) { + manager.stage(preference); + manager.publishStaged(preference); + } } } diff --git a/mobile/lib/shared/theme/community_theme_sync.dart b/mobile/lib/shared/theme/community_theme_sync.dart index 3e72cc9aa8..fdbe5ae3a2 100644 --- a/mobile/lib/shared/theme/community_theme_sync.dart +++ b/mobile/lib/shared/theme/community_theme_sync.dart @@ -54,6 +54,7 @@ class CommunityThemeSyncManager { CommunityThemePreference? _lastPublished; int _lastCreatedAt = 0; String _lastEventId = ''; + RemoteCommunityTheme? _lastRemote; int _subscriptionEpoch = 0; int _subscriptionRetryAttempt = 0; int _publishRetryAttempt = 0; @@ -100,12 +101,29 @@ class CommunityThemeSyncManager { } 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!); } - await _startLiveSubscription(); + final remote = _lastRemote; + if (remote != null) { + return CommunityThemeRemoteResult( + CommunityThemeRemoteStatus.valid, + remote, + ); + } + if (!subscribed && result.status == CommunityThemeRemoteStatus.absent) { + return const CommunityThemeRemoteResult( + CommunityThemeRemoteStatus.unavailable, + ); + } return result; } @@ -317,6 +335,7 @@ class CommunityThemeSyncManager { } _lastCreatedAt = remote.createdAt; _lastEventId = remote.eventId; + _lastRemote = remote; if (_pending != null) { _lastPublished = null; return; diff --git a/mobile/test/shared/theme/community_theme_provider_test.dart b/mobile/test/shared/theme/community_theme_provider_test.dart index 14990414c4..099ddd2f77 100644 --- a/mobile/test/shared/theme/community_theme_provider_test.dart +++ b/mobile/test/shared/theme/community_theme_provider_test.dart @@ -135,6 +135,55 @@ void main() { 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 { @@ -185,6 +234,7 @@ class _ThemeRelaySession extends RelaySessionNotifier { final String pubkey; final Future>? historyFuture; final subscribed = Completer(); + int subscribeCalls = 0; void Function(NostrEvent)? _listener; NostrEvent? published; @@ -203,6 +253,7 @@ class _ThemeRelaySession extends RelaySessionNotifier { void Function(NostrEvent) onEvent, { void Function(String message)? onClosed, }) async { + subscribeCalls++; _listener = onEvent; if (!subscribed.isCompleted) subscribed.complete(); return () => _listener = null; @@ -219,6 +270,10 @@ class _ThemeRelaySession extends RelaySessionNotifier { 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); diff --git a/mobile/test/shared/theme/community_theme_sync_test.dart b/mobile/test/shared/theme/community_theme_sync_test.dart index 7b829ad207..fa65f0029e 100644 --- a/mobile/test/shared/theme/community_theme_sync_test.dart +++ b/mobile/test/shared/theme/community_theme_sync_test.dart @@ -34,6 +34,42 @@ void main() { 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')]), @@ -392,10 +428,12 @@ class _FakeSession extends RelaySessionNotifier { 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 = []; @@ -410,6 +448,7 @@ class _FakeSession extends RelaySessionNotifier { Duration timeout = const Duration(seconds: 8), }) async { if (error != null) throw error!; + onFetchHistory?.call(); if (historyFuture != null) return historyFuture!; return history; }