mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent
43cced308d
commit
05150c1188
+7
-3
@@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user