feat(mobile): sync themes per community (#3767)

**Category:** new-feature
**User Impact:** Mobile now keeps each community’s appearance in sync
with desktop, including theme, accent, and system-mode preference.

**Problem:** Appearance choices were device-local, so the same account
could look different between desktop and mobile. Live sync could also
stop after the relay closed a subscription.

**Solution:** Store each community’s encrypted appearance preference on
its relay using the shared desktop wire contract, restore it from a
local identity-scoped cache, and apply replacement events live. Closed
subscriptions now recover with guarded backoff and fetch the latest
preference so no update is lost during the gap.

<details>
<summary>File changes</summary>

**mobile/lib/app.dart**
Connects community appearance state to the authenticated app lifecycle.

**mobile/lib/features/settings/accent_picker_page.dart**
Aligns mobile accent choices and selection behavior with the shared
catalog.

**mobile/lib/features/settings/settings_page/appearance_section.dart**
Clarifies the active appearance and hides accent controls when the Buzz
theme owns its neutral accent.

**mobile/lib/features/settings/theme_picker_page.dart**
Persists catalog theme choices through the community-scoped provider.

**mobile/lib/shared/theme/accent_colors.dart**
Matches desktop’s accent catalog and wire values.

**mobile/lib/shared/theme/buzz_theme.dart**
Keeps Buzz visually neutral without discarding the user’s stored accent
for other themes.

**mobile/lib/shared/theme/community_theme_preference.dart**
Defines and validates the versioned desktop-compatible appearance
payload.

**mobile/lib/shared/theme/community_theme_provider.dart**
Coordinates cache-first appearance loading with account and community
changes.

**mobile/lib/shared/theme/community_theme_sync.dart**
Adds encrypted NIP-78 relay persistence, live replacement handling,
deterministic ordering, safe seeding, and resilient subscription
recovery.

**mobile/lib/shared/theme/theme.dart**
Exports the community appearance modules.

**mobile/test/features/settings/theme_picker_page_test.dart**
Covers the updated settings behavior.

**mobile/test/shared/crypto/nip44_interop_test.dart**
Proves Dart decrypts a desktop-produced nostr-rs NIP-44 v2 preference.

**mobile/test/shared/theme/buzz_theme_test.dart**
Covers Buzz’s neutral rendering and stored-accent restoration.

**mobile/test/shared/theme/community_theme_preference_test.dart**
Covers wire parsing, validation, migration, and future-version handling.

**mobile/test/shared/theme/community_theme_sync_test.dart**
Covers cache/relay lifecycle, replacement ordering, switching races,
absence-only seeding, and closed-subscription recovery.

</details>

## Reproduction steps

1. Sign into desktop and mobile with the same account and join the same
community relay.
2. On desktop, choose a distinctive non-Buzz theme and accent; mobile
should update without a local toggle.
3. Restart mobile and confirm it restores the same appearance.
4. Change the mobile theme and accent and confirm desktop follows.
5. Leave mobile idle or backgrounded through a relay reconnect, then
change desktop again; mobile should resubscribe and catch up
automatically.
6. Switch communities and confirm each community restores only its own
appearance.

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
This commit is contained in:
Taylor Ho
2026-08-05 13:18:53 -07:00
committed by GitHub
co-authored by npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent 43cced308d
commit 05150c1188
16 changed files with 1927 additions and 44 deletions
+7 -3
View File
@@ -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);
@@ -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),
),
],
),
@@ -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<void>(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<void>(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<void>(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();
},
),
@@ -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),
);
},
),
+68 -12
View File
@@ -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';
}
+8
View File
@@ -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);
@@ -0,0 +1,167 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'accent_colors.dart';
import 'theme_catalog.dart';
import 'theme_provider.dart' show effectiveTheme, schemeForAppearanceMode;
const communityThemeDTag = 'community-theme';
const defaultCommunityTheme = CommunityThemePreference(
theme: 'buzz',
accent: '#3b82f6',
followSystem: true,
);
class CommunityThemePreference {
final int version;
final String theme;
final String accent;
final bool followSystem;
const CommunityThemePreference({
this.version = 1,
required this.theme,
required this.accent,
required this.followSystem,
});
factory CommunityThemePreference.fromJson(Map<String, dynamic> 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<String, dynamic> toJson() => {
'version': version,
'theme': theme,
'accent': accent,
'followSystem': followSystem,
};
ThemeMode get mode {
if (followSystem) return ThemeMode.system;
return findTheme(theme)?.isDark == true ? ThemeMode.dark : ThemeMode.light;
}
@override
bool operator ==(Object other) =>
other is CommunityThemePreference &&
theme == other.theme &&
accent == other.accent &&
followSystem == other.followSystem;
@override
int get hashCode => Object.hash(theme, accent, followSystem);
}
class CommunityThemeStorage {
static const _prefix = 'buzz-community-theme.v1';
static const _outboxPrefix = 'buzz-community-theme-outbox.v1';
static const _migrationPrefix = 'buzz-community-theme-migrated.v1';
static const _legacyModeKey = 'buzz_theme_mode';
static const _legacyAccentKey = 'buzz_accent_color';
static const _legacySchemeKey = 'buzz_color_scheme';
final SharedPreferences prefs;
const CommunityThemeStorage(this.prefs);
String key(String pubkey, String relayUrl) =>
'$_prefix:$pubkey:${Uri.encodeComponent(normalizeCommunityRelayUrl(relayUrl))}';
String outboxKey(String pubkey, String relayUrl) =>
'$_outboxPrefix:$pubkey:${Uri.encodeComponent(normalizeCommunityRelayUrl(relayUrl))}';
CommunityThemePreference? _readKey(String storageKey) {
try {
final raw = prefs.getString(storageKey);
if (raw == null) return null;
final decoded = jsonDecode(raw);
if (decoded is! Map<String, dynamic>) return null;
return CommunityThemePreference.fromJson(decoded);
} catch (_) {
return null;
}
}
CommunityThemePreference? read(String pubkey, String relayUrl) =>
_readKey(key(pubkey, relayUrl));
CommunityThemePreference? readOutbox(String pubkey, String relayUrl) =>
_readKey(outboxKey(pubkey, relayUrl));
Future<bool> write(
String pubkey,
String relayUrl,
CommunityThemePreference preference,
) => prefs.setString(key(pubkey, relayUrl), jsonEncode(preference.toJson()));
Future<bool> writeOutbox(
String pubkey,
String relayUrl,
CommunityThemePreference preference,
) => prefs.setString(
outboxKey(pubkey, relayUrl),
jsonEncode(preference.toJson()),
);
Future<void> clearOutbox(
String pubkey,
String relayUrl,
CommunityThemePreference acknowledged,
) async {
if (readOutbox(pubkey, relayUrl) == acknowledged) {
await prefs.remove(outboxKey(pubkey, relayUrl));
}
}
bool hasMigrated(String pubkey) =>
prefs.getBool('$_migrationPrefix:$pubkey') == true;
Future<bool> markMigrated(String pubkey) =>
prefs.setBool('$_migrationPrefix:$pubkey', true);
Future<void> 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();
@@ -0,0 +1,230 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../crypto/nip44.dart';
import '../relay/relay.dart';
import 'accent_colors.dart';
import 'community_theme_preference.dart';
import 'community_theme_sync.dart';
import 'theme_provider.dart';
class CommunityThemeNotifier extends Notifier<CommunityThemePreference> {
CommunityThemeSyncManager? _manager;
late CommunityThemeStorage _storage;
String? _pubkey;
String? _relayUrl;
Future<void> _persistenceQueue = Future<void>.value();
int _localRevision = 0;
CommunityThemePreference? _scopedLocalPreference;
String? _scopedLocalPubkey;
String? _scopedLocalRelayUrl;
@override
CommunityThemePreference build() {
_manager?.dispose();
_manager = null;
_storage = ref.watch(communityThemeStorageProvider);
final config = ref.watch(relayConfigProvider);
final session = ref.watch(relaySessionProvider);
final pubkey = pubkeyFromNsec(config.nsec);
_pubkey = pubkey;
_relayUrl = config.baseUrl;
if (pubkey == null || config.nsec == null) {
final legacy = _storage.legacyPreference();
unawaited(_storage.writeLegacy(legacy));
return legacy;
}
final cached = _storage.read(pubkey, config.baseUrl);
final dirty = _storage.readOutbox(pubkey, config.baseUrl);
final inMemoryLocal =
_scopedLocalPubkey == pubkey && _scopedLocalRelayUrl == config.baseUrl
? _scopedLocalPreference
: null;
if (inMemoryLocal == null) {
_scopedLocalPreference = null;
_scopedLocalPubkey = pubkey;
_scopedLocalRelayUrl = config.baseUrl;
}
final fallback = _storage.hasMigrated(pubkey)
? defaultCommunityTheme
: _storage.legacyPreference();
final initial = inMemoryLocal ?? dirty ?? cached ?? fallback;
if (session.status == SessionStatus.connected) {
late final CommunityThemeSyncManager manager;
manager = CommunityThemeSyncManager(
pubkey: pubkey,
relaySession: ref.read(relaySessionProvider.notifier),
signedEventRelay: SignedEventRelay(
session: ref.read(relaySessionProvider.notifier),
nsec: config.nsec!,
),
crypto: _crypto(config.nsec!, pubkey),
onRemote: (remote) => _applyRemote(manager, remote),
onPublished: (preference) {
if (_scopedLocalPubkey == pubkey &&
_scopedLocalRelayUrl == config.baseUrl &&
_scopedLocalPreference == preference) {
_scopedLocalPreference = null;
}
unawaited(_storage.clearOutbox(pubkey, config.baseUrl, preference));
},
);
_manager = manager;
final pending = inMemoryLocal ?? dirty;
if (pending != null) manager.publish(pending);
Future.microtask(() async {
final result = await manager.initialize();
if (_manager != manager) return;
if (result.status == CommunityThemeRemoteStatus.absent) {
final seedRevision = _localRevision;
await _enqueuePersistence(() async {
if (_manager != manager || _localRevision != seedRevision) return;
final currentDirty = _storage.readOutbox(pubkey, config.baseUrl);
final seed =
currentDirty ?? _storage.read(pubkey, config.baseUrl) ?? state;
if (!await _storage.write(pubkey, config.baseUrl, seed)) return;
if (_manager != manager || _localRevision != seedRevision) return;
if (!await _storage.writeOutbox(pubkey, config.baseUrl, seed)) {
return;
}
if (_manager == manager && _localRevision == seedRevision) {
manager.publish(seed);
}
});
}
if (result.status == CommunityThemeRemoteStatus.valid ||
result.status == CommunityThemeRemoteStatus.absent) {
await _storage.markMigrated(pubkey);
}
});
ref.onDispose(manager.dispose);
}
return initial;
}
void setMode(ThemeMode mode) {
var theme = state.theme;
if (mode == ThemeMode.system) {
theme = schemeForAppearanceMode(theme, mode) ?? theme;
} else {
final effective = effectiveTheme(theme, mode);
if (effective != null) theme = effective.name;
}
_save(
CommunityThemePreference(
theme: theme,
accent: state.accent,
followSystem: mode == ThemeMode.system,
),
);
}
void setTheme(String? theme) {
_save(
CommunityThemePreference(
theme: theme ?? defaultSchemeName,
accent: state.accent,
followSystem: state.followSystem,
),
);
}
void setAccent(int index) {
if (index < 0 || index >= accentColors.length) return;
_save(
CommunityThemePreference(
theme: state.theme,
accent: accentColors[index].wireValue,
followSystem: state.followSystem,
),
);
}
void _save(CommunityThemePreference preference) {
if (preference == state) return;
state = preference;
_localRevision++;
final pubkey = _pubkey;
final relayUrl = _relayUrl;
if (pubkey == null || relayUrl == null) {
unawaited(_storage.writeLegacy(preference));
return;
}
_scopedLocalPreference = preference;
_scopedLocalPubkey = pubkey;
_scopedLocalRelayUrl = relayUrl;
_manager?.stage(preference);
unawaited(
_enqueuePersistence(
() => _persistAndPublish(pubkey, relayUrl, preference),
),
);
}
Future<void> _enqueuePersistence(Future<void> Function() operation) {
final result = _persistenceQueue.then((_) => operation());
_persistenceQueue = result.catchError((Object _) {});
return result;
}
Future<void> _persistAndPublish(
String pubkey,
String relayUrl,
CommunityThemePreference preference,
) async {
if (!await _storage.write(pubkey, relayUrl, preference)) return;
if (!await _storage.writeOutbox(pubkey, relayUrl, preference)) return;
if (_pubkey == pubkey && _relayUrl == relayUrl) {
final manager = _manager;
if (manager != null) {
manager.stage(preference);
manager.publishStaged(preference);
}
}
}
void _applyRemote(
CommunityThemeSyncManager manager,
RemoteCommunityTheme remote,
) {
if (_manager != manager) return;
final pubkey = _pubkey;
final relayUrl = _relayUrl;
if (pubkey != null &&
relayUrl != null &&
_storage.readOutbox(pubkey, relayUrl) != null) {
final dirty = _storage.readOutbox(pubkey, relayUrl)!;
manager.publish(dirty);
return;
}
state = remote.preference;
if (pubkey != null && relayUrl != null) {
unawaited(_storage.write(pubkey, relayUrl, remote.preference));
}
}
}
CommunityThemeCrypto _crypto(String nsec, String pubkey) {
final privateHex = nostr.Nip19.decode(payload: nsec).data;
final key = getConversationKey(privateHex, pubkey);
return CommunityThemeCrypto(
encrypt: (plaintext) => nip44Encrypt(key, plaintext),
decrypt: (ciphertext) => nip44Decrypt(key, ciphertext),
);
}
final communityThemeStorageProvider = Provider<CommunityThemeStorage>(
(ref) => CommunityThemeStorage(ref.watch(savedPrefsProvider)),
);
final communityThemeProvider =
NotifierProvider<CommunityThemeNotifier, CommunityThemePreference>(
CommunityThemeNotifier.new,
);
@@ -0,0 +1,366 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import '../relay/relay.dart';
import 'community_theme_preference.dart';
class CommunityThemeCrypto {
final String Function(String) encrypt;
final String Function(String) decrypt;
const CommunityThemeCrypto({required this.encrypt, required this.decrypt});
}
enum CommunityThemeRemoteStatus { valid, absent, invalid, unavailable }
class RemoteCommunityTheme {
final CommunityThemePreference preference;
final int createdAt;
final String eventId;
const RemoteCommunityTheme({
required this.preference,
required this.createdAt,
required this.eventId,
});
}
class CommunityThemeRemoteResult {
final CommunityThemeRemoteStatus status;
final RemoteCommunityTheme? remote;
const CommunityThemeRemoteResult(this.status, [this.remote]);
}
class CommunityThemeSyncManager {
final String pubkey;
final RelaySessionNotifier relaySession;
final SignedEventRelay signedEventRelay;
final CommunityThemeCrypto crypto;
final Duration debounce;
final Duration publishRetryBase;
final Duration publishRetryMax;
final Duration subscriptionRetryBase;
final void Function(RemoteCommunityTheme) onRemote;
final void Function(CommunityThemePreference) onPublished;
Timer? _publishTimer;
Timer? _subscriptionRetryTimer;
void Function()? _unsubscribe;
CommunityThemePreference? _pending;
CommunityThemePreference? _lastPublished;
int _lastCreatedAt = 0;
String _lastEventId = '';
RemoteCommunityTheme? _lastRemote;
int _subscriptionEpoch = 0;
int _subscriptionRetryAttempt = 0;
int _publishRetryAttempt = 0;
bool _publishInFlight = false;
bool _publishRequestedWhileInFlight = false;
bool _disposed = false;
CommunityThemeSyncManager({
required this.pubkey,
required this.relaySession,
required this.signedEventRelay,
required this.crypto,
required this.onRemote,
this.onPublished = _ignorePublished,
this.debounce = const Duration(seconds: 2),
this.publishRetryBase = const Duration(seconds: 1),
this.publishRetryMax = const Duration(seconds: 30),
this.subscriptionRetryBase = const Duration(seconds: 1),
});
CommunityThemePreference? get pending => _pending;
Future<CommunityThemeRemoteResult> fetchRemote() async {
try {
final events = await relaySession.fetchHistory(_themeFilter(limit: 1));
if (events.isEmpty) {
return const CommunityThemeRemoteResult(
CommunityThemeRemoteStatus.absent,
);
}
final event = events.reduce(_newerEvent);
final remote = _decode(event);
return remote == null
? const CommunityThemeRemoteResult(CommunityThemeRemoteStatus.invalid)
: CommunityThemeRemoteResult(
CommunityThemeRemoteStatus.valid,
remote,
);
} catch (_) {
return const CommunityThemeRemoteResult(
CommunityThemeRemoteStatus.unavailable,
);
}
}
Future<CommunityThemeRemoteResult> initialize() async {
final subscribed = await _startLiveSubscription();
if (_disposed) {
return const CommunityThemeRemoteResult(
CommunityThemeRemoteStatus.unavailable,
);
}
final result = await fetchRemote();
if (_disposed) return result;
if (result.status == CommunityThemeRemoteStatus.valid) {
_accept(result.remote!);
}
final remote = _lastRemote;
if (remote != null) {
return CommunityThemeRemoteResult(
CommunityThemeRemoteStatus.valid,
remote,
);
}
if (!subscribed && result.status == CommunityThemeRemoteStatus.absent) {
return const CommunityThemeRemoteResult(
CommunityThemeRemoteStatus.unavailable,
);
}
return result;
}
Future<bool> _startLiveSubscription() async {
if (_disposed) return false;
final epoch = ++_subscriptionEpoch;
try {
final unsubscribe = await relaySession.subscribe(
_themeFilter(limit: 0),
(event) {
if (_disposed || epoch != _subscriptionEpoch) return;
final remote = _decode(event);
if (remote != null) _accept(remote);
},
onClosed: (message) => _handleSubscriptionClosed(epoch, message),
);
if (_disposed || epoch != _subscriptionEpoch) {
unsubscribe();
return false;
}
_unsubscribe = unsubscribe;
_subscriptionRetryAttempt = 0;
return true;
} catch (error) {
if (!_disposed && epoch == _subscriptionEpoch) {
debugPrint('[CommunityThemeSync] live subscription failed: $error');
_scheduleSubscriptionRetry();
}
return false;
}
}
void _handleSubscriptionClosed(int epoch, String message) {
if (_disposed || epoch != _subscriptionEpoch) return;
debugPrint('[CommunityThemeSync] live subscription closed: $message');
_unsubscribe = null;
_scheduleSubscriptionRetry();
}
void _scheduleSubscriptionRetry() {
if (_disposed || _subscriptionRetryTimer != null) return;
final multiplier = 1 << min(_subscriptionRetryAttempt, 5);
_subscriptionRetryAttempt++;
_subscriptionRetryTimer = Timer(subscriptionRetryBase * multiplier, () {
_subscriptionRetryTimer = null;
unawaited(_recoverLiveSubscription());
});
}
Future<void> _recoverLiveSubscription() async {
if (_disposed) return;
if (!await _startLiveSubscription()) return;
// A relay CLOSED removes the retained subscription from RelaySession, so
// reconnect replay cannot recover it. Query the replacement coordinate
// after re-subscribing to close the gap while this stream was silent.
final result = await fetchRemote();
if (_disposed) return;
if (result.status == CommunityThemeRemoteStatus.valid) {
_accept(result.remote!);
}
}
NostrFilter _themeFilter({required int limit}) => NostrFilter(
kinds: const [EventKind.readState],
authors: [pubkey],
tags: const {
'#d': [communityThemeDTag],
},
limit: limit,
);
void stage(CommunityThemePreference preference) {
if (_disposed) return;
_pending = preference;
_publishRetryAttempt = 0;
_publishTimer?.cancel();
_publishTimer = null;
}
void publish(CommunityThemePreference preference) {
stage(preference);
publishStaged(preference);
}
void publishStaged(CommunityThemePreference preference) {
if (_disposed || _pending != preference) return;
_schedulePublish(debounce);
}
void _schedulePublish(Duration delay) {
if (_disposed) return;
_publishTimer?.cancel();
_publishTimer = Timer(delay, () {
_publishTimer = null;
unawaited(flush());
});
}
void cancelPending() {
_publishTimer?.cancel();
_publishTimer = null;
_pending = null;
}
Future<void> flush() async {
if (_publishInFlight) {
_publishTimer?.cancel();
_publishTimer = null;
_publishRequestedWhileInFlight = true;
return;
}
final preference = _pending;
if (_disposed || preference == null) return;
if (preference == _lastPublished) {
_pending = null;
onPublished(preference);
return;
}
_publishInFlight = true;
try {
final content = crypto.encrypt(jsonEncode(preference.toJson()));
if (_disposed) return;
final createdAt = max(
DateTime.now().millisecondsSinceEpoch ~/ 1000,
_lastCreatedAt + 1,
);
NostrEvent? signed;
await signedEventRelay.submit(
kind: EventKind.readState,
content: content,
tags: const [
['d', communityThemeDTag],
['t', communityThemeDTag],
],
createdAt: createdAt,
onSigned: (event) => signed = event,
);
if (_disposed) return;
final published = signed;
if (published == null) {
throw StateError('Signed event coordinate unavailable');
}
final publishedCoordinateIsStale =
_lastCreatedAt > published.createdAt ||
(_lastCreatedAt == published.createdAt &&
_lastEventId.isNotEmpty &&
_lastEventId.compareTo(published.id) < 0);
if (publishedCoordinateIsStale) {
_lastPublished = null;
_publishRetryAttempt = 0;
if (_pending == preference) _schedulePublish(Duration.zero);
return;
}
_lastCreatedAt = published.createdAt;
_lastEventId = published.id;
_lastPublished = preference;
_publishRetryAttempt = 0;
if (_pending == preference) _pending = null;
onPublished(preference);
} catch (error) {
debugPrint('[CommunityThemeSync] publish failed: $error');
if (_disposed || _pending != preference) return;
final multiplier = 1 << min(_publishRetryAttempt, 30);
_publishRetryAttempt++;
final retryMs = min(
publishRetryBase.inMilliseconds * multiplier,
publishRetryMax.inMilliseconds,
);
_schedulePublish(Duration(milliseconds: retryMs));
} finally {
_publishInFlight = false;
if (!_disposed &&
_pending != null &&
(_publishRequestedWhileInFlight || _pending != preference) &&
_publishTimer == null) {
_publishRequestedWhileInFlight = false;
_schedulePublish(Duration.zero);
} else {
_publishRequestedWhileInFlight = false;
}
}
}
RemoteCommunityTheme? _decode(NostrEvent event) {
if (event.pubkey != pubkey ||
event.getTagValue('d') != communityThemeDTag) {
return null;
}
try {
final decoded = jsonDecode(crypto.decrypt(event.content));
if (decoded is! Map<String, dynamic>) return null;
return RemoteCommunityTheme(
preference: CommunityThemePreference.fromJson(decoded),
createdAt: event.createdAt,
eventId: event.id,
);
} catch (_) {
return null;
}
}
void _accept(RemoteCommunityTheme remote) {
if (remote.createdAt < _lastCreatedAt ||
(remote.createdAt == _lastCreatedAt &&
_lastEventId.isNotEmpty &&
remote.eventId.compareTo(_lastEventId) >= 0)) {
return;
}
_lastCreatedAt = remote.createdAt;
_lastEventId = remote.eventId;
_lastRemote = remote;
if (_pending != null) {
_lastPublished = null;
return;
}
_lastPublished = null;
onRemote(remote);
}
void dispose() {
if (_disposed) return;
_disposed = true;
_subscriptionEpoch++;
_subscriptionRetryTimer?.cancel();
_subscriptionRetryTimer = null;
cancelPending();
_unsubscribe?.call();
_unsubscribe = null;
}
}
void _ignorePublished(CommunityThemePreference _) {}
NostrEvent _newerEvent(NostrEvent left, NostrEvent right) {
if (right.createdAt != left.createdAt) {
return right.createdAt > left.createdAt ? right : left;
}
return right.id.compareTo(left.id) < 0 ? right : left;
}
+3
View File
@@ -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';
@@ -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(
@@ -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}',
);
});
}
@@ -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);
@@ -0,0 +1,123 @@
import 'dart:convert';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
test('desktop v1 payload round-trips exactly', () {
final preference = CommunityThemePreference.fromJson({
'version': 1,
'theme': 'github-dark',
'accent': '#c0a2f1',
'followSystem': false,
});
expect(preference.mode, ThemeMode.dark);
expect(
jsonEncode(preference.toJson()),
'{"version":1,"theme":"github-dark","accent":"#c0a2f1","followSystem":false}',
);
});
test('rejects unknown themes, accents, and future versions', () {
for (final payload in [
{
'version': 2,
'theme': 'buzz',
'accent': '#3b82f6',
'followSystem': true,
},
{
'version': 1,
'theme': 'unknown',
'accent': '#3b82f6',
'followSystem': true,
},
{
'version': 1,
'theme': 'buzz',
'accent': '#000000',
'followSystem': true,
},
]) {
expect(
() => CommunityThemePreference.fromJson(payload),
throwsFormatException,
);
}
});
test('storage is scoped by pubkey and normalized relay URL', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final storage = CommunityThemeStorage(prefs);
const a = CommunityThemePreference(
theme: 'buzz',
accent: '#3b82f6',
followSystem: true,
);
const b = CommunityThemePreference(
theme: 'dracula',
accent: '#ef4444',
followSystem: false,
);
await storage.write('pk', 'WSS://Relay.Example///', a);
await storage.write('pk', 'wss://other.example', b);
expect(storage.read('pk', 'wss://relay.example'), a);
expect(storage.read('pk', 'wss://other.example/'), b);
expect(storage.read('other-pk', 'wss://relay.example'), isNull);
});
test('dirty outbox survives restart and clears only exact ack', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final storage = CommunityThemeStorage(prefs);
const pending = CommunityThemePreference(
theme: 'dracula',
accent: '#ef4444',
followSystem: false,
);
const newer = CommunityThemePreference(
theme: 'houston',
accent: '#a855f7',
followSystem: false,
);
await storage.writeOutbox('pk', 'wss://relay.example', pending);
expect(
CommunityThemeStorage(prefs).readOutbox('pk', 'wss://relay.example'),
pending,
);
await storage.writeOutbox('pk', 'wss://relay.example', newer);
await storage.clearOutbox('pk', 'wss://relay.example', pending);
expect(storage.readOutbox('pk', 'wss://relay.example'), newer);
await storage.clearOutbox('pk', 'wss://relay.example', newer);
expect(storage.readOutbox('pk', 'wss://relay.example'), isNull);
});
test('legacy accent indexes migrate without inventing wire values', () async {
SharedPreferences.setMockInitialValues({
'buzz_theme_mode': 'dark',
'buzz_color_scheme': 'dracula',
'buzz_accent_color': 8,
});
final prefs = await SharedPreferences.getInstance();
final preference = CommunityThemeStorage(prefs).legacyPreference();
expect(
preference,
const CommunityThemePreference(
theme: 'dracula',
accent: 'neutral',
followSystem: false,
),
);
expect(preference.mode, ThemeMode.dark);
expect(legacyAccentWireValue(6), '#a855f7');
expect(legacyAccentWireValue(7), '#6366f1');
});
}
@@ -0,0 +1,311 @@
import 'dart:async';
import 'dart:convert';
import 'package:buzz/shared/crypto/nip44.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
void main() {
test('delayed absence seeds the intervening local edit', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final keys = nostr.Keys.generate();
final history = Completer<List<NostrEvent>>();
final session = _ThemeRelaySession(
keys.nsec,
keys.public,
historyFuture: history.future,
);
final storage = CommunityThemeStorage(prefs);
final container = ProviderContainer(
overrides: [
communityThemeStorageProvider.overrideWithValue(storage),
relayConfigProvider.overrideWith(() => _RelayConfig(keys.nsec)),
relaySessionProvider.overrideWith(() => session),
],
);
addTearDown(container.dispose);
container.listen(communityThemeProvider, (_, _) {}, fireImmediately: true);
container.read(communityThemeProvider.notifier).setTheme('dracula');
history.complete([]);
await _waitUntil(() => session.published != null);
expect(container.read(communityThemeProvider).theme, 'dracula');
expect(
storage.read(keys.public, 'https://relay.example')?.theme,
'dracula',
);
});
test('edit during delayed absence seed wins durable state', () async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final keys = nostr.Keys.generate();
final history = Completer<List<NostrEvent>>();
final session = _ThemeRelaySession(
keys.nsec,
keys.public,
historyFuture: history.future,
);
final storage = _DelayedThemeStorage(prefs);
final container = ProviderContainer(
overrides: [
communityThemeStorageProvider.overrideWithValue(storage),
relayConfigProvider.overrideWith(() => _RelayConfig(keys.nsec)),
relaySessionProvider.overrideWith(() => session),
],
);
addTearDown(container.dispose);
container.listen(communityThemeProvider, (_, _) {}, fireImmediately: true);
history.complete([]);
await storage.cacheWriteStarted.future;
container.read(communityThemeProvider.notifier).setTheme('dracula');
storage.allowCacheWrite.complete();
storage.allowOutboxWrite.complete();
await _waitUntil(() => session.published != null);
expect(container.read(communityThemeProvider).theme, 'dracula');
expect(
storage.read(keys.public, 'https://relay.example')?.theme,
'dracula',
);
final privateHex = nostr.Nip19.decode(payload: keys.nsec).data;
final key = getConversationKey(privateHex, keys.public);
expect(
jsonDecode(nip44Decrypt(key, session.published!.content))['theme'],
'dracula',
);
});
test(
'local edit stays authoritative before persistence through exact ack',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final keys = nostr.Keys.generate();
final session = _ThemeRelaySession(keys.nsec, keys.public);
final storage = _DelayedThemeStorage(prefs);
final container = ProviderContainer(
overrides: [
communityThemeStorageProvider.overrideWithValue(storage),
relayConfigProvider.overrideWith(() => _RelayConfig(keys.nsec)),
relaySessionProvider.overrideWith(() => session),
],
);
addTearDown(container.dispose);
final subscription = container.listen(
communityThemeProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(subscription.close);
await session.subscribed.future;
final notifier = container.read(communityThemeProvider.notifier);
notifier.setTheme('dracula');
const local = CommunityThemePreference(
theme: 'dracula',
accent: '#3b82f6',
followSystem: true,
);
expect(container.read(communityThemeProvider), local);
session.emit(session.remoteEvent(theme: 'houston', id: 'remote-z'));
expect(container.read(communityThemeProvider), local);
storage.allowCacheWrite.complete();
await storage.outboxWriteStarted.future;
session.emit(session.remoteEvent(theme: 'solarized', id: 'remote-a'));
expect(container.read(communityThemeProvider), local);
storage.allowOutboxWrite.complete();
await _waitUntil(() => session.published != null);
expect(container.read(communityThemeProvider), local);
session.emit(session.published!);
await _pumpEventQueue();
expect(container.read(communityThemeProvider), local);
expect(storage.readOutbox(keys.public, 'https://relay.example'), isNull);
},
);
test(
'provider rebuild preserves delayed local edit and publishes on replacement manager',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
final keys = nostr.Keys.generate();
final session = _ThemeRelaySession(keys.nsec, keys.public);
final storage = _DelayedThemeStorage(prefs);
final container = ProviderContainer(
overrides: [
communityThemeStorageProvider.overrideWithValue(storage),
relayConfigProvider.overrideWith(() => _RelayConfig(keys.nsec)),
relaySessionProvider.overrideWith(() => session),
],
);
addTearDown(container.dispose);
final subscription = container.listen(
communityThemeProvider,
(_, _) {},
fireImmediately: true,
);
addTearDown(subscription.close);
await session.subscribed.future;
container.read(communityThemeProvider.notifier).setTheme('dracula');
expect(container.read(communityThemeProvider).theme, 'dracula');
await storage.cacheWriteStarted.future;
session.setStatus(SessionStatus.reconnecting);
await _pumpEventQueue();
expect(container.read(communityThemeProvider).theme, 'dracula');
session.setStatus(SessionStatus.connected);
await _waitUntil(() => session.subscribeCalls == 2);
expect(container.read(communityThemeProvider).theme, 'dracula');
storage.allowCacheWrite.complete();
storage.allowOutboxWrite.complete();
await _waitUntil(() => session.published != null);
final privateHex = nostr.Nip19.decode(payload: keys.nsec).data;
final key = getConversationKey(privateHex, keys.public);
expect(
jsonDecode(nip44Decrypt(key, session.published!.content))['theme'],
'dracula',
);
expect(container.read(communityThemeProvider).theme, 'dracula');
},
);
}
class _DelayedThemeStorage extends CommunityThemeStorage {
_DelayedThemeStorage(super.prefs);
final allowCacheWrite = Completer<void>();
final allowOutboxWrite = Completer<void>();
final cacheWriteStarted = Completer<void>();
final outboxWriteStarted = Completer<void>();
@override
Future<bool> write(
String pubkey,
String relayUrl,
CommunityThemePreference preference,
) async {
if (!cacheWriteStarted.isCompleted) cacheWriteStarted.complete();
await allowCacheWrite.future;
return super.write(pubkey, relayUrl, preference);
}
@override
Future<bool> writeOutbox(
String pubkey,
String relayUrl,
CommunityThemePreference preference,
) async {
if (!outboxWriteStarted.isCompleted) outboxWriteStarted.complete();
await allowOutboxWrite.future;
return super.writeOutbox(pubkey, relayUrl, preference);
}
}
class _RelayConfig extends RelayConfigNotifier {
_RelayConfig(this.nsec);
final String nsec;
@override
RelayConfig build() =>
RelayConfig(baseUrl: 'https://relay.example', nsec: nsec);
}
class _ThemeRelaySession extends RelaySessionNotifier {
_ThemeRelaySession(this.nsec, this.pubkey, {this.historyFuture});
final String nsec;
final String pubkey;
final Future<List<NostrEvent>>? historyFuture;
final subscribed = Completer<void>();
int subscribeCalls = 0;
void Function(NostrEvent)? _listener;
NostrEvent? published;
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async => historyFuture ?? [remoteEvent(theme: 'buzz', id: 'initial')];
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String message)? onClosed,
}) async {
subscribeCalls++;
_listener = onEvent;
if (!subscribed.isCompleted) subscribed.complete();
return () => _listener = null;
}
@override
Future<NostrEvent> publish(
NostrEvent event, {
Duration timeout = const Duration(seconds: 8),
}) async {
published = event;
return event;
}
void emit(NostrEvent event) => _listener?.call(event);
void setStatus(SessionStatus status) {
state = SessionState(status: status);
}
NostrEvent remoteEvent({required String theme, required String id}) {
final privateHex = nostr.Nip19.decode(payload: nsec).data;
final key = getConversationKey(privateHex, pubkey);
final preference = CommunityThemePreference(
theme: theme,
accent: '#3b82f6',
followSystem: true,
);
return NostrEvent(
id: id,
pubkey: pubkey,
createdAt: 1,
kind: 30078,
tags: const [
['d', communityThemeDTag],
['t', communityThemeDTag],
],
content: nip44Encrypt(key, jsonEncode(preference.toJson())),
sig: 'sig',
);
}
}
Future<void> _pumpEventQueue() async {
await Future<void>.delayed(Duration.zero);
await Future<void>.delayed(Duration.zero);
}
Future<void> _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<void>.delayed(const Duration(milliseconds: 5));
}
}
@@ -0,0 +1,546 @@
import 'dart:async';
import 'dart:convert';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
const local = CommunityThemePreference(
theme: 'buzz',
accent: '#3b82f6',
followSystem: true,
);
test('confirmed absence seeds exact NIP-78 coordinate', () async {
final session = _FakeSession();
final relay = _FakeSignedRelay();
final manager = _manager(session, relay);
final result = await manager.initialize();
expect(result.status, CommunityThemeRemoteStatus.absent);
manager.publish(local);
await manager.flush();
expect(relay.submissions, hasLength(1));
expect(relay.submissions.single.kind, 30078);
expect(
relay.submissions.single.tags,
containsAll(<List<String>>[
['d', 'community-theme'],
['t', 'community-theme'],
]),
);
expect(jsonDecode(relay.submissions.single.content), local.toJson());
});
test(
'live replacement closes the history-to-subscription absence gap',
() async {
const replacement = CommunityThemePreference(
theme: 'dracula',
accent: '#ef4444',
followSystem: false,
);
final applied = <CommunityThemePreference>[];
late final _FakeSession session;
session = _FakeSession(
onFetchHistory: () {
session.emit(
_event(
id: 'replacement',
createdAt: 100,
content: jsonEncode(replacement.toJson()),
),
);
},
);
final manager = _manager(
session,
_FakeSignedRelay(),
onRemote: (remote) => applied.add(remote.preference),
);
final result = await manager.initialize();
expect(session.subscribeCalls, 1);
expect(result.status, CommunityThemeRemoteStatus.valid);
expect(result.remote?.preference, replacement);
expect(applied, [replacement]);
},
);
test('invalid and unavailable records never seed', () async {
for (final session in [
_FakeSession(history: [_event(content: '{bad json')]),
_FakeSession(error: StateError('offline')),
]) {
final relay = _FakeSignedRelay();
final result = await _manager(session, relay).initialize();
expect(
result.status,
anyOf(
CommunityThemeRemoteStatus.invalid,
CommunityThemeRemoteStatus.unavailable,
),
);
expect(relay.submissions, isEmpty);
}
});
test(
'newest valid event wins with deterministic same-second ordering',
() async {
final applied = <CommunityThemePreference>[];
final session = _FakeSession(
history: [
_event(
id: 'z',
createdAt: 50,
content: jsonEncode(
const CommunityThemePreference(
theme: 'dracula',
accent: '#ef4444',
followSystem: false,
).toJson(),
),
),
_event(id: 'a', createdAt: 50, content: jsonEncode(local.toJson())),
],
);
final manager = _manager(
session,
_FakeSignedRelay(),
onRemote: (r) => applied.add(r.preference),
);
await manager.initialize();
expect(applied.single.theme, 'buzz');
session.emit(
_event(
id: 'z',
createdAt: 50,
content: jsonEncode(
const CommunityThemePreference(
theme: 'dracula',
accent: '#ef4444',
followSystem: false,
).toJson(),
),
),
);
expect(applied, hasLength(1));
},
);
test('remote hydration never cancels a newer pending local write', () async {
final relay = _FakeSignedRelay();
final session = _FakeSession();
final manager = _manager(session, relay);
await manager.initialize();
manager.cancelPending();
manager.publish(
const CommunityThemePreference(
theme: 'dracula',
accent: '#ef4444',
followSystem: false,
),
);
session.emit(
_event(id: 'remote', createdAt: 100, content: jsonEncode(local.toJson())),
);
await manager.flush();
expect(relay.submissions, hasLength(1));
expect(jsonDecode(relay.submissions.single.content)['theme'], 'dracula');
manager.publish(local);
manager.dispose();
await manager.flush();
expect(relay.submissions, hasLength(1));
});
test(
'relay CLOSED resubscribes then catches up latest replacement event',
() async {
final applied = <CommunityThemePreference>[];
final session = _FakeSession();
final manager = _manager(
session,
_FakeSignedRelay(),
onRemote: (remote) => applied.add(remote.preference),
);
await manager.initialize();
manager.cancelPending();
expect(session.subscribeCalls, 1);
const replacement = CommunityThemePreference(
theme: 'dracula',
accent: '#ef4444',
followSystem: false,
);
session.history = [
_event(
id: 'replacement',
createdAt: 100,
content: jsonEncode(replacement.toJson()),
),
];
session.closeLiveSubscription('rate-limited: quota exceeded');
await _waitUntil(() => session.subscribeCalls == 2 && applied.isNotEmpty);
expect(applied.single, replacement);
expect(session.activeListeners, 1);
manager.dispose();
},
);
test('relay CLOSED after dispose never resubscribes', () async {
final session = _FakeSession();
final manager = _manager(session, _FakeSignedRelay());
await manager.initialize();
final close = session.latestClosedCallback;
manager.dispose();
close?.call('late close');
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(session.subscribeCalls, 1);
});
test('publish failure retries and acknowledges exact preference', () async {
final relay = _FakeSignedRelay(failuresRemaining: 1);
final acknowledgements = <CommunityThemePreference>[];
final manager = _manager(
_FakeSession(),
relay,
onPublished: acknowledgements.add,
);
manager.publish(local);
await manager.flush();
expect(manager.pending, local);
await _waitUntil(() => acknowledgements.length == 1);
expect(relay.attempts, 2);
expect(manager.pending, isNull);
expect(acknowledgements, [local]);
});
test('serializes in-flight publish before latest edit', () async {
final firstSubmission = Completer<void>();
final relay = _FakeSignedRelay(firstSubmissionGate: firstSubmission.future);
final manager = _manager(_FakeSession(), relay);
const latest = CommunityThemePreference(
theme: 'dracula',
accent: '#ef4444',
followSystem: false,
);
manager.publish(local);
final firstFlush = manager.flush();
await _waitUntil(() => relay.attempts == 1);
manager.publish(latest);
await manager.flush();
expect(relay.attempts, 1);
firstSubmission.complete();
await firstFlush;
await _waitUntil(() => relay.attempts == 2);
expect(relay.submittedEvents, hasLength(2));
expect(
relay.submittedEvents[1].createdAt,
greaterThan(relay.submittedEvents[0].createdAt),
);
expect(jsonDecode(relay.submissions[1].content)['theme'], 'dracula');
expect(manager.pending, isNull);
});
test(
'republishes above remote observed while publish is in flight',
() async {
final firstSubmission = Completer<void>();
final relay = _FakeSignedRelay(
firstSubmissionGate: firstSubmission.future,
);
final session = _FakeSession();
final acknowledgements = <CommunityThemePreference>[];
final manager = _manager(
session,
relay,
onPublished: acknowledgements.add,
);
await manager.initialize();
manager.publish(local);
final firstFlush = manager.flush();
await _waitUntil(() => relay.attempts == 1);
session.emit(
_event(
id: 'remote-winner',
createdAt: 2000000000,
content: jsonEncode(
const CommunityThemePreference(
theme: 'dracula',
accent: '#ef4444',
followSystem: false,
).toJson(),
),
),
);
firstSubmission.complete();
await firstFlush;
expect(acknowledgements, isEmpty);
expect(manager.pending, local);
await _waitUntil(() => relay.attempts == 2);
expect(relay.submittedEvents[1].createdAt, 2000000001);
expect(acknowledgements, [local]);
expect(manager.pending, isNull);
},
);
test('remote coordinate advances pending local publish timestamp', () async {
final relay = _FakeSignedRelay();
final session = _FakeSession();
final manager = _manager(session, relay);
await manager.initialize();
manager.publish(local);
session.emit(
_event(
id: 'remote',
createdAt: 2000000000,
content: jsonEncode(local.toJson()),
),
);
await manager.flush();
expect(relay.submittedEvents.single.createdAt, 2000000001);
});
test(
'remote replacement invalidates A to B to A no-op suppression',
() async {
final relay = _FakeSignedRelay();
final session = _FakeSession();
final manager = _manager(session, relay);
await manager.initialize();
manager.publish(local);
await manager.flush();
const remotePreference = CommunityThemePreference(
theme: 'dracula',
accent: '#ef4444',
followSystem: false,
);
session.emit(
_event(
id: 'remote',
createdAt: relay.submittedEvents.single.createdAt + 1,
content: jsonEncode(remotePreference.toJson()),
),
);
manager.publish(local);
await manager.flush();
expect(relay.submissions, hasLength(2));
expect(manager.pending, isNull);
},
);
test(
'published coordinate rejects delayed same-second initialization result',
() async {
const stale = CommunityThemePreference(
theme: 'dracula',
accent: '#ef4444',
followSystem: false,
);
final history = Completer<List<NostrEvent>>();
final session = _FakeSession(historyFuture: history.future);
final relay = _FakeSignedRelay(eventId: 'published-a');
final applied = <CommunityThemePreference>[];
final manager = _manager(
session,
relay,
onRemote: (remote) => applied.add(remote.preference),
);
final initializing = manager.initialize();
manager.publish(local);
await manager.flush();
final createdAt = relay.submittedEvents.single.createdAt;
history.complete([
_event(
id: 'published-z',
createdAt: createdAt,
content: jsonEncode(stale.toJson()),
),
]);
await initializing;
expect(applied, isEmpty);
expect(manager.pending, isNull);
},
);
}
CommunityThemeSyncManager _manager(
_FakeSession session,
_FakeSignedRelay relay, {
void Function(RemoteCommunityTheme)? onRemote,
void Function(CommunityThemePreference)? onPublished,
}) => CommunityThemeSyncManager(
pubkey: 'pk',
relaySession: session,
signedEventRelay: relay,
crypto: const CommunityThemeCrypto(encrypt: _identity, decrypt: _identity),
debounce: const Duration(days: 1),
publishRetryBase: const Duration(milliseconds: 1),
publishRetryMax: const Duration(milliseconds: 4),
subscriptionRetryBase: const Duration(milliseconds: 1),
onRemote: onRemote ?? (_) {},
onPublished: onPublished ?? (_) {},
);
String _identity(String value) => value;
NostrEvent _event({
String id = 'event',
int createdAt = 1,
required String content,
}) => NostrEvent(
id: id,
pubkey: 'pk',
createdAt: createdAt,
kind: 30078,
tags: const [
['d', 'community-theme'],
],
content: content,
sig: 'sig',
);
class _FakeSession extends RelaySessionNotifier {
_FakeSession({
List<NostrEvent> history = const [],
this.historyFuture,
this.error,
this.onFetchHistory,
}) : history = List.of(history);
List<NostrEvent> history;
final Future<List<NostrEvent>>? historyFuture;
final Object? error;
final void Function()? onFetchHistory;
int subscribeCalls = 0;
final List<void Function(NostrEvent)> _listeners = [];
final List<void Function(String)> _closedCallbacks = [];
int get activeListeners => _listeners.length;
void Function(String)? get latestClosedCallback =>
_closedCallbacks.isEmpty ? null : _closedCallbacks.last;
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
if (error != null) throw error!;
onFetchHistory?.call();
if (historyFuture != null) return historyFuture!;
return history;
}
@override
Future<void Function()> subscribe(
NostrFilter filter,
void Function(NostrEvent) onEvent, {
void Function(String)? onClosed,
}) async {
subscribeCalls++;
_listeners.add(onEvent);
_closedCallbacks.add(onClosed ?? (_) {});
return () {
final index = _listeners.indexOf(onEvent);
if (index < 0) return;
_listeners.removeAt(index);
_closedCallbacks.removeAt(index);
};
}
void emit(NostrEvent event) {
for (final listener in List.of(_listeners)) {
listener(event);
}
}
void closeLiveSubscription(String message) {
if (_listeners.isEmpty) return;
_listeners.removeAt(0);
_closedCallbacks.removeAt(0)(message);
}
}
Future<void> _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<void>.delayed(const Duration(milliseconds: 1));
}
}
class _Submission {
final int kind;
final String content;
final List<List<String>> tags;
const _Submission(this.kind, this.content, this.tags);
}
class _FakeSignedRelay implements SignedEventRelay {
_FakeSignedRelay({
this.failuresRemaining = 0,
this.eventId = 'event',
this.firstSubmissionGate,
});
int failuresRemaining;
final String eventId;
final Future<void>? firstSubmissionGate;
int attempts = 0;
final submissions = <_Submission>[];
final submittedEvents = <NostrEvent>[];
@override
String? get pubkey => 'pk';
@override
Future<NostrEvent> submit({
required int kind,
required String content,
required List<List<String>> tags,
int? createdAt,
void Function(NostrEvent)? onSigned,
}) async {
attempts++;
if (attempts == 1 && firstSubmissionGate != null) {
await firstSubmissionGate;
}
if (failuresRemaining > 0) {
failuresRemaining--;
throw StateError('publish failed');
}
submissions.add(_Submission(kind, content, tags));
final event = _event(
id: eventId,
content: content,
createdAt: createdAt ?? 0,
);
onSigned?.call(event);
submittedEvents.add(event);
return event;
}
}