feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
@@ -0,0 +1,263 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
import '../../../shared/crypto/nip44.dart';
import '../../../shared/relay/relay.dart';
import '../../../shared/read_state/read_state_time.dart';
import 'channel_stars_storage.dart';
class ChannelStarsCrypto {
final Uint8List _conversationKey;
ChannelStarsCrypto(String nsec, String pubkey)
: _conversationKey = _deriveKey(nsec, pubkey);
static Uint8List _deriveKey(String nsec, String pubkey) {
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
return getConversationKey(privkeyHex, pubkey);
}
String encrypt(String plaintext) => nip44Encrypt(_conversationKey, plaintext);
String decrypt(String ciphertext) =>
nip44Decrypt(_conversationKey, ciphertext);
}
class ChannelStarsManager {
final String pubkey;
final ChannelStarsStorage _storage;
final ChannelStarsCrypto _crypto;
final RelaySessionNotifier? _relaySession;
final SignedEventRelay? _signedEventRelay;
final bool _remoteEnabled;
final VoidCallback _onChanged;
ChannelStarStore _store;
ChannelStarStore? _lastPublishedStore;
Timer? _publishDebounce;
int _lastRemoteCreatedAt = 0;
String? _lastRemoteEventId;
void Function()? _unsubscribe;
bool _disposed = false;
ChannelStarsManager({
required this.pubkey,
required SharedPreferences prefs,
required ChannelStarsCrypto crypto,
required RelaySessionNotifier? relaySession,
required SignedEventRelay? signedEventRelay,
required bool remoteEnabled,
required VoidCallback onChanged,
}) : _storage = ChannelStarsStorage(prefs),
_crypto = crypto,
_relaySession = relaySession,
_signedEventRelay = signedEventRelay,
_remoteEnabled = remoteEnabled,
_onChanged = onChanged,
_store = ChannelStarsStorage(prefs).read(pubkey);
ChannelStarStore get store => _store;
Future<void> initialize() async {
if (_disposed) return;
if (!_remoteEnabled || _relaySession == null) {
_onChanged();
return;
}
await _fetchAndMerge();
await _startLiveSubscription();
_onChanged();
}
void dispose({bool flushPending = true}) {
if (_disposed) return;
_disposed = true;
final hadPending = _publishDebounce != null;
_publishDebounce?.cancel();
_publishDebounce = null;
if (flushPending && hadPending && _remoteEnabled) {
unawaited(_publish(allowDisposed: true));
}
_unsubscribe?.call();
_unsubscribe = null;
}
void starChannel(String channelId) {
if (_disposed) return;
final entry = ChannelStarEntry(
starred: true,
updatedAt: currentUnixSeconds(),
);
_store = ChannelStarStore(channels: {..._store.channels, channelId: entry});
_persist();
markDirty();
}
void unstarChannel(String channelId) {
if (_disposed) return;
final entry = ChannelStarEntry(
starred: false,
updatedAt: currentUnixSeconds(),
);
_store = ChannelStarStore(channels: {..._store.channels, channelId: entry});
_persist();
markDirty();
}
void markDirty() {
if (!_remoteEnabled || _disposed) return;
_publishDebounce?.cancel();
_publishDebounce = Timer(const Duration(seconds: 5), () {
_publishDebounce = null;
unawaited(_publish());
});
}
Future<void> _fetchAndMerge() async {
if (_relaySession == null) return;
try {
final events = await _relaySession.fetchHistory(
NostrFilter(
kinds: const [EventKind.readState],
authors: [pubkey],
tags: const {
'#d': ['channel-stars'],
},
limit: 1,
),
);
_mergeEvents(events);
_persist();
if (!_disposed) _onChanged();
} catch (_) {
// Local state remains usable when relay is unavailable.
}
}
Future<void> _startLiveSubscription() async {
if (_relaySession == null) return;
try {
_unsubscribe = await _relaySession.subscribe(
NostrFilter(
kinds: const [EventKind.readState],
authors: [pubkey],
tags: const {
'#d': ['channel-stars'],
},
limit: 1,
),
_handleIncomingEvent,
);
} catch (_) {
// Non-fatal — local state and history still work.
}
}
void _mergeEvents(List<NostrEvent> events) {
for (final event in events) {
if (event.pubkey != pubkey) continue;
_mergeEvent(event);
}
}
void _mergeEvent(NostrEvent event) {
// Only process channel-stars d-tag events.
final dTag = event.getTagValue('d');
if (dTag != 'channel-stars') return;
try {
final plaintext = _crypto.decrypt(event.content);
final parsed = jsonDecode(plaintext);
if (parsed is! Map<String, dynamic>) return;
final incoming = ChannelStarStore.fromJson(parsed);
// Gate on createdAt: ignore events older than what we've already seen.
final isNewer =
event.createdAt > _lastRemoteCreatedAt ||
(event.createdAt == _lastRemoteCreatedAt &&
event.id.compareTo(_lastRemoteEventId ?? '') > 0);
if (isNewer) {
_lastRemoteCreatedAt = event.createdAt;
_lastRemoteEventId = event.id;
// Per-channel merge: keep the entry with the highest updatedAt for each channel.
_store = mergeStores(_store, incoming);
_persist();
}
} catch (_) {
// Decryption failure or parse error — keep existing state.
}
}
void _handleIncomingEvent(NostrEvent event) {
if (_disposed) return;
_mergeEvent(event);
if (!_disposed) _onChanged();
}
bool _isIdenticalToLastPublished() {
final last = _lastPublishedStore;
if (last == null) return false;
if (last.channels.length != _store.channels.length) return false;
for (final key in _store.channels.keys) {
final lastEntry = last.channels[key];
final currentEntry = _store.channels[key];
if (lastEntry == null ||
lastEntry.starred != currentEntry!.starred ||
lastEntry.updatedAt != currentEntry.updatedAt) {
return false;
}
}
return true;
}
Future<void> _publish({bool allowDisposed = false}) async {
if ((!allowDisposed && _disposed) ||
!_remoteEnabled ||
_signedEventRelay == null) {
return;
}
// Read-before-write: merge remote state before publishing
await _fetchAndMerge();
// No-op suppression: skip if nothing changed
if (_isIdenticalToLastPublished()) return;
try {
final payload = jsonEncode(_store.toJson());
final ciphertext = _crypto.encrypt(payload);
final createdAt = max(currentUnixSeconds(), _lastRemoteCreatedAt + 1);
await _signedEventRelay.submit(
kind: EventKind.readState,
content: ciphertext,
tags: [
['d', 'channel-stars'],
['t', 'channel-stars'],
],
createdAt: createdAt,
);
_lastRemoteCreatedAt = max(_lastRemoteCreatedAt, createdAt);
_lastPublishedStore = ChannelStarStore(channels: Map.of(_store.channels));
} catch (error) {
debugPrint('[ChannelStarsManager] publish failed: $error');
}
}
void _persist() {
_storage.write(pubkey, _store);
}
}
@@ -0,0 +1,115 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import '../../../shared/relay/relay.dart';
import '../../../shared/theme/theme_provider.dart';
import '../../../shared/community/community_provider.dart';
import 'channel_stars_manager.dart';
import 'channel_stars_storage.dart';
class ChannelStarsState {
final bool isReady;
final ChannelStarStore store;
/// Bumped on every change to force downstream rebuilds.
final int version;
const ChannelStarsState({
this.isReady = false,
this.store = const ChannelStarStore(),
this.version = 0,
});
}
class ChannelStarsNotifier extends Notifier<ChannelStarsState> {
ChannelStarsManager? _manager;
@override
ChannelStarsState build() {
_manager?.dispose(flushPending: false);
_manager = null;
final relayConfig = ref.watch(relayConfigProvider);
final sessionState = ref.watch(relaySessionProvider);
// Rebuild when the active community changes (pubkey may differ).
ref.watch(activeCommunityProvider);
final nsec = relayConfig.nsec?.trim();
if (nsec == null || nsec.isEmpty) {
return const ChannelStarsState();
}
final pubkey = _safePubkeyFromNsec(nsec);
if (pubkey == null || pubkey.isEmpty) {
return const ChannelStarsState();
}
final ChannelStarsCrypto crypto;
try {
crypto = ChannelStarsCrypto(nsec, pubkey);
} catch (_) {
return const ChannelStarsState();
}
final prefs = ref.read(savedPrefsProvider);
final signedRelay = SignedEventRelay(
session: ref.read(relaySessionProvider.notifier),
nsec: nsec,
);
late final ChannelStarsManager manager;
manager = ChannelStarsManager(
pubkey: pubkey,
prefs: prefs,
crypto: crypto,
relaySession: ref.read(relaySessionProvider.notifier),
signedEventRelay: signedRelay,
remoteEnabled: sessionState.status == SessionStatus.connected,
onChanged: () => _emitManagerState(manager),
);
_manager = manager;
ref.onDispose(() {
manager.dispose();
if (_manager == manager) {
_manager = null;
}
});
Future.microtask(() async {
await manager.initialize();
if (_manager != manager) return;
_emitManagerState(manager);
});
return ChannelStarsState(isReady: false, store: manager.store, version: 1);
}
void starChannel(String channelId) => _manager?.starChannel(channelId);
void unstarChannel(String channelId) => _manager?.unstarChannel(channelId);
void _emitManagerState(ChannelStarsManager manager) {
if (_manager != manager) return;
state = ChannelStarsState(
isReady: true,
store: manager.store,
version: state.version + 1,
);
}
}
final channelStarsProvider =
NotifierProvider<ChannelStarsNotifier, ChannelStarsState>(
ChannelStarsNotifier.new,
);
String? _safePubkeyFromNsec(String nsec) {
try {
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) return null;
return nostr.Keys(privkeyHex).public;
} catch (_) {
return null;
}
}
@@ -0,0 +1,91 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
String channelStarsKey(String pubkey) => 'buzz.channel-stars.v1:$pubkey';
class ChannelStarEntry {
final bool starred;
final int updatedAt;
const ChannelStarEntry({required this.starred, required this.updatedAt});
Map<String, dynamic> toJson() => {'starred': starred, 'updatedAt': updatedAt};
factory ChannelStarEntry.fromJson(Map<String, dynamic> json) =>
ChannelStarEntry(
starred: json['starred'] as bool,
updatedAt: json['updatedAt'] as int,
);
}
class ChannelStarStore {
final int version;
final Map<String, ChannelStarEntry> channels;
const ChannelStarStore({this.version = 1, this.channels = const {}});
Map<String, dynamic> toJson() => {
'version': version,
'channels': {for (final e in channels.entries) e.key: e.value.toJson()},
};
factory ChannelStarStore.fromJson(Map<String, dynamic> json) {
final rawChannels = json['channels'];
final channels = <String, ChannelStarEntry>{};
if (rawChannels is Map) {
for (final entry in rawChannels.entries) {
if (entry.key is String && entry.value is Map<String, dynamic>) {
final v = entry.value as Map<String, dynamic>;
if (v['starred'] is bool && v['updatedAt'] is int) {
channels[entry.key as String] = ChannelStarEntry.fromJson(v);
}
}
}
}
return ChannelStarStore(version: 1, channels: channels);
}
}
ChannelStarStore mergeStores(ChannelStarStore local, ChannelStarStore remote) {
// Per-channel max-updatedAt merge:
// For each channel ID in the union, keep the entry with the highest updatedAt.
final merged = <String, ChannelStarEntry>{...local.channels};
for (final entry in remote.channels.entries) {
final existing = merged[entry.key];
if (existing == null || entry.value.updatedAt > existing.updatedAt) {
merged[entry.key] = entry.value;
}
}
return ChannelStarStore(channels: merged);
}
class ChannelStarsStorage {
final SharedPreferences _prefs;
ChannelStarsStorage(this._prefs);
ChannelStarStore read(String pubkey) {
final raw = _prefs.getString(channelStarsKey(pubkey));
if (raw == null || raw.isEmpty) {
return const ChannelStarStore();
}
try {
final parsed = jsonDecode(raw);
if (parsed is! Map<String, dynamic>) {
return const ChannelStarStore();
}
if (parsed['version'] != 1) {
return const ChannelStarStore();
}
return ChannelStarStore.fromJson(parsed);
} catch (_) {
return const ChannelStarStore();
}
}
void write(String pubkey, ChannelStarStore store) {
_prefs.setString(channelStarsKey(pubkey), jsonEncode(store.toJson()));
}
}