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,322 @@
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_sort_storage.dart';
const _dTag = 'channel-sort';
const _maxClockDriftSeconds = 300;
class ChannelSortCrypto {
final Uint8List _conversationKey;
ChannelSortCrypto(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);
}
/// Desktop-compatible encrypted NIP-78 sync for per-group sort preferences.
/// Remote state is ordinary whole-blob LWW: unlike the rejected #2829 design,
/// local state never vetoes a newer remote blob or leapfrogs an unseen edit.
class ChannelSortManager {
final String pubkey;
final String relayUrl;
final ChannelSortStorage _storage;
final ChannelSortCrypto _crypto;
final RelaySessionNotifier? _relaySession;
final SignedEventRelay? _signedEventRelay;
final bool _remoteEnabled;
final VoidCallback _onChanged;
final Duration _startupRetryBaseDelay;
final Duration _publishDelay;
ChannelSortStore _store;
ChannelSortSyncState _syncState;
Timer? _publishDebounce;
Timer? _startupRetryTimer;
int _startupRetryAttempt = 0;
int _lastRemoteCreatedAt = 0;
String _lastRemoteEventId = '';
int _generation = 0;
void Function()? _unsubscribe;
bool _disposed = false;
ChannelSortManager({
required this.pubkey,
required this.relayUrl,
required SharedPreferences prefs,
required ChannelSortCrypto crypto,
required RelaySessionNotifier? relaySession,
required SignedEventRelay? signedEventRelay,
required bool remoteEnabled,
required VoidCallback onChanged,
@visibleForTesting
Duration startupRetryBaseDelay = const Duration(seconds: 2),
@visibleForTesting Duration publishDelay = const Duration(seconds: 2),
}) : _storage = ChannelSortStorage(prefs),
_crypto = crypto,
_relaySession = relaySession,
_signedEventRelay = signedEventRelay,
_remoteEnabled = remoteEnabled,
_onChanged = onChanged,
_startupRetryBaseDelay = startupRetryBaseDelay,
_publishDelay = publishDelay,
_store = ChannelSortStorage(prefs).read(pubkey, relayUrl),
_syncState = ChannelSortStorage(prefs).readSyncState(pubkey, relayUrl) {
_lastRemoteCreatedAt = _syncState.updatedAt;
_lastRemoteEventId = _syncState.eventId;
}
ChannelSortStore get store => _store;
ChannelSortMode sortModeFor(String groupKey) =>
_store.groups[groupKey] ?? kDefaultSortMode;
Future<void> initialize() async {
if (_disposed || !_remoteEnabled || _relaySession == null) {
if (!_disposed) _onChanged();
return;
}
await _syncWithRelay();
if (!_disposed) _onChanged();
}
Future<void> _syncWithRelay() async {
final firstFetch = await _fetchAndApply();
final subscribed = _unsubscribe != null || await _startLiveSubscription();
// Fetch again after the subscription is ready. This closes the event gap
// between history and live setup (and catches anything published while a
// rate-limited subscription was retrying).
final secondFetch = subscribed ? await _fetchAndApply() : null;
if (firstFetch == null || !subscribed || secondFetch == null) {
_scheduleStartupRetry();
return;
}
_startupRetryAttempt = 0;
if (_syncState.hasPendingLocalChanges ||
(!firstFetch && !secondFetch && _store.groups.isNotEmpty)) {
_schedulePublish();
}
}
void _scheduleStartupRetry() {
if (_disposed) return;
_startupRetryTimer?.cancel();
final delayMs = min(
_startupRetryBaseDelay.inMilliseconds << min(_startupRetryAttempt, 5),
30000,
);
_startupRetryAttempt++;
_startupRetryTimer = Timer(Duration(milliseconds: delayMs), () {
_startupRetryTimer = null;
unawaited(
_syncWithRelay().then((_) {
if (!_disposed) _onChanged();
}),
);
});
}
void setSortModeFor(
String groupKey,
ChannelSortMode mode, {
Iterable<String>? liveSectionIds,
}) {
if (_disposed || _store.groups[groupKey] == mode) return;
final updated = ChannelSortStore(
groups: {..._store.groups, groupKey: mode},
);
_store = liveSectionIds == null
? updated
: stripOrphanedSectionModes(updated, liveSectionIds);
final now = currentUnixSeconds();
final pendingUpdatedAt = min(
max(now, _syncState.pendingUpdatedAt + 1),
now + _maxClockDriftSeconds,
);
_syncState = ChannelSortSyncState(
updatedAt: _syncState.updatedAt,
eventId: _syncState.eventId,
hasPendingLocalChanges: true,
pendingUpdatedAt: pendingUpdatedAt,
);
_generation++;
_persist();
_schedulePublish();
_onChanged();
}
void _schedulePublish() {
if (!_remoteEnabled || _disposed) return;
_publishDebounce?.cancel();
_publishDebounce = Timer(_publishDelay, () {
_publishDebounce = null;
unawaited(_publish());
});
}
Future<bool?> _fetchAndApply() async {
if (_relaySession == null) return null;
try {
final events = await _relaySession.fetchHistory(_filter());
var found = false;
for (final event in events) {
if (event.pubkey != pubkey || event.getTagValue('d') != _dTag) continue;
found = true;
_applyRemote(event);
}
return found;
} catch (error) {
debugPrint('[ChannelSortManager] fetch failed: $error');
return null;
}
}
Future<bool> _startLiveSubscription() async {
if (_relaySession == null) return false;
try {
_unsubscribe = await _relaySession.subscribe(
_filter(),
_handleIncomingEvent,
onClosed: (_) {
if (_disposed) return;
_unsubscribe?.call();
_unsubscribe = null;
_scheduleStartupRetry();
},
);
return true;
} catch (error) {
debugPrint('[ChannelSortManager] subscribe failed: $error');
return false;
}
}
NostrFilter _filter() => NostrFilter(
kinds: const [EventKind.readState],
authors: [pubkey],
tags: const {
'#d': [_dTag],
},
limit: 1,
);
void _applyRemote(NostrEvent event) {
if (event.createdAt > currentUnixSeconds() + _maxClockDriftSeconds) return;
if (_syncState.hasPendingLocalChanges &&
event.createdAt <= _syncState.pendingUpdatedAt) {
return;
}
final isNewer =
event.createdAt > _lastRemoteCreatedAt ||
(event.createdAt == _lastRemoteCreatedAt &&
event.id.compareTo(_lastRemoteEventId) < 0);
if (!isNewer) return;
try {
final parsed = jsonDecode(_crypto.decrypt(event.content));
if (parsed is! Map<String, dynamic> || parsed['version'] != 1) return;
final incoming = ChannelSortStore.fromJson(parsed);
_lastRemoteCreatedAt = event.createdAt;
_lastRemoteEventId = event.id;
_syncState = ChannelSortSyncState(
updatedAt: event.createdAt,
eventId: event.id,
);
_publishDebounce?.cancel();
_publishDebounce = null;
_store = incoming;
_generation++;
_persist();
} catch (_) {
// Ignore malformed or undecryptable blobs without advancing the cursor.
}
}
void _handleIncomingEvent(NostrEvent event) {
if (_disposed ||
event.pubkey != pubkey ||
event.getTagValue('d') != _dTag) {
return;
}
final before = _generation;
_applyRemote(event);
if (_generation != before && !_disposed) _onChanged();
}
Future<void> _publish() async {
if (_disposed || !_remoteEnabled || _signedEventRelay == null) return;
final generationAtStart = _generation;
// A newer remote blob wins. If one arrives during this read, _generation
// changes and we abort rather than overwriting it.
final preflight = await _fetchAndApply();
if (_disposed || _generation != generationAtStart) return;
if (preflight == null) {
_schedulePublish();
return;
}
try {
final now = currentUnixSeconds();
final createdAt = max(now, _lastRemoteCreatedAt + 1);
if (createdAt > now + _maxClockDriftSeconds) {
debugPrint(
'[ChannelSortManager] publish delayed: relay cursor '
'is ${createdAt - now}s ahead of local time',
);
_schedulePublish();
return;
}
final ciphertext = _crypto.encrypt(jsonEncode(_store.toJson()));
String? submittedEventId;
await _signedEventRelay.submit(
kind: EventKind.readState,
content: ciphertext,
tags: const [
['d', _dTag],
['t', _dTag],
],
createdAt: createdAt,
onSigned: (event) => submittedEventId = event.id,
);
if (_disposed || _generation != generationAtStart) return;
// Keep local publications strictly ordered for this manager lifetime, as
// desktop does, but never persist that volatile publication cursor.
_lastRemoteCreatedAt = createdAt;
_lastRemoteEventId = submittedEventId ?? '';
_syncState = ChannelSortSyncState(
updatedAt: _syncState.updatedAt,
eventId: _syncState.eventId,
);
_persist();
} catch (error) {
debugPrint('[ChannelSortManager] publish failed: $error');
}
}
void _persist() {
_storage.write(pubkey, relayUrl, _store);
_storage.writeSyncState(pubkey, relayUrl, _syncState);
}
void dispose() {
if (_disposed) return;
_disposed = true;
_publishDebounce?.cancel();
_startupRetryTimer?.cancel();
_unsubscribe?.call();
_unsubscribe = null;
}
}
@@ -0,0 +1,126 @@
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_sort_manager.dart';
import 'channel_sort_storage.dart';
class ChannelSortState {
final bool isReady;
final ChannelSortStore store;
/// Bumped on every change to force downstream rebuilds.
final int version;
const ChannelSortState({
this.isReady = false,
this.store = const ChannelSortStore(),
this.version = 0,
});
ChannelSortMode sortModeFor(String groupKey) =>
store.groups[groupKey] ?? kDefaultSortMode;
}
class ChannelSortNotifier extends Notifier<ChannelSortState> {
ChannelSortManager? _manager;
@override
ChannelSortState build() {
_manager?.dispose();
_manager = null;
final relayConfig = ref.watch(relayConfigProvider);
final sessionState = ref.watch(relaySessionProvider);
final activeCommunity = ref.watch(activeCommunityProvider).value;
final nsec = relayConfig.nsec?.trim();
if (nsec == null || nsec.isEmpty) {
return const ChannelSortState();
}
final pubkey = _safePubkeyFromNsec(nsec);
if (pubkey == null || pubkey.isEmpty) {
return const ChannelSortState();
}
final ChannelSortCrypto crypto;
try {
crypto = ChannelSortCrypto(nsec, pubkey);
} catch (_) {
return const ChannelSortState();
}
final relayUrl = activeCommunity?.relayUrl.trim();
if (relayUrl == null || relayUrl.isEmpty) {
return const ChannelSortState();
}
final prefs = ref.read(savedPrefsProvider);
final signedRelay = SignedEventRelay(
session: ref.read(relaySessionProvider.notifier),
nsec: nsec,
);
late final ChannelSortManager manager;
manager = ChannelSortManager(
pubkey: pubkey,
relayUrl: relayUrl,
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 ChannelSortState(isReady: false, store: manager.store, version: 1);
}
void setSortModeFor(
String groupKey,
ChannelSortMode mode, {
Iterable<String>? liveSectionIds,
}) =>
_manager?.setSortModeFor(groupKey, mode, liveSectionIds: liveSectionIds);
void _emitManagerState(ChannelSortManager manager) {
if (_manager != manager) return;
state = ChannelSortState(
isReady: true,
store: manager.store,
version: state.version + 1,
);
}
}
final channelSortProvider =
NotifierProvider<ChannelSortNotifier, ChannelSortState>(
ChannelSortNotifier.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,219 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../channel.dart';
String normalizeChannelSortRelayUrl(String relayUrl) =>
relayUrl.trim().replaceFirst(RegExp(r'/+$'), '').toLowerCase();
String channelSortKey(String pubkey, String relayUrl) =>
'buzz.channel-sort.v1:$pubkey:${Uri.encodeComponent(normalizeChannelSortRelayUrl(relayUrl))}';
String legacyChannelSortKey(String pubkey) => 'buzz.channel-sort.v1:$pubkey';
/// Per-group sidebar sort mode. The wire values match desktop exactly.
enum ChannelSortMode {
alpha('alpha'),
recent('recent');
final String wireValue;
const ChannelSortMode(this.wireValue);
static ChannelSortMode? fromWire(Object? value) {
if (value == 'alpha') return ChannelSortMode.alpha;
if (value == 'recent') return ChannelSortMode.recent;
return null;
}
}
const ChannelSortMode kDefaultSortMode = ChannelSortMode.alpha;
String sectionSortGroupKey(String sectionId) => 'section:$sectionId';
class ChannelSortStore {
final int version;
final Map<String, ChannelSortMode> groups;
const ChannelSortStore({this.version = 1, this.groups = const {}});
Map<String, dynamic> toJson() => {
'version': version,
'groups': {
for (final entry in groups.entries) entry.key: entry.value.wireValue,
},
};
factory ChannelSortStore.fromJson(Map<String, dynamic> json) {
final groups = <String, ChannelSortMode>{};
final rawGroups = json['groups'];
if (rawGroups is Map) {
for (final entry in rawGroups.entries) {
final mode = ChannelSortMode.fromWire(entry.value);
if (entry.key is String && mode != null) {
groups[entry.key as String] = mode;
}
}
}
return ChannelSortStore(groups: groups);
}
}
ChannelSortStore stripOrphanedSectionModes(
ChannelSortStore store,
Iterable<String> liveSectionIds,
) {
final liveKeys = {for (final id in liveSectionIds) sectionSortGroupKey(id)};
final kept = <String, ChannelSortMode>{
for (final entry in store.groups.entries)
if (!entry.key.startsWith('section:') || liveKeys.contains(entry.key))
entry.key: entry.value,
};
if (kept.length == store.groups.length) return store;
return ChannelSortStore(groups: kept);
}
/// Mobile and desktop both use a case-insensitive deterministic ordering.
/// The id tie-break keeps equal folded names stable across clients.
int compareChannelsByName(Channel left, Channel right) {
final name = left.name.toLowerCase().compareTo(right.name.toLowerCase());
return name != 0 ? name : left.id.compareTo(right.id);
}
List<Channel> sortChannelsForList(
List<Channel> channels,
ChannelSortMode mode,
) {
final sorted = channels.toList();
if (mode == ChannelSortMode.alpha) {
sorted.sort(compareChannelsByName);
return sorted;
}
sorted.sort((left, right) {
final leftMs = left.lastMessageAt?.millisecondsSinceEpoch;
final rightMs = right.lastMessageAt?.millisecondsSinceEpoch;
if (leftMs != null && rightMs != null && leftMs != rightMs) {
return rightMs.compareTo(leftMs);
}
if (leftMs != null && rightMs == null) return -1;
if (leftMs == null && rightMs != null) return 1;
return compareChannelsByName(left, right);
});
return sorted;
}
class ChannelSortSyncState {
final int updatedAt;
final String eventId;
final bool hasPendingLocalChanges;
final int pendingUpdatedAt;
const ChannelSortSyncState({
this.updatedAt = 0,
this.eventId = '',
this.hasPendingLocalChanges = false,
this.pendingUpdatedAt = 0,
});
Map<String, dynamic> toJson() => {
'updatedAt': updatedAt,
'eventId': eventId,
'hasPendingLocalChanges': hasPendingLocalChanges,
'pendingUpdatedAt': pendingUpdatedAt,
};
factory ChannelSortSyncState.fromJson(Map<String, dynamic> json) {
final updatedAt = json['updatedAt'] is int ? json['updatedAt'] as int : 0;
final hasPendingLocalChanges = json['hasPendingLocalChanges'] == true;
final storedPendingUpdatedAt = json['pendingUpdatedAt'];
// Older builds used updatedAt for both the remote cursor and local edit
// stamp. Preserve the pending guard while resetting that ambiguous cursor.
final isLegacyPending =
hasPendingLocalChanges && storedPendingUpdatedAt is! int;
return ChannelSortSyncState(
updatedAt: isLegacyPending ? 0 : updatedAt,
eventId: isLegacyPending
? ''
: (json['eventId'] is String ? json['eventId'] as String : ''),
hasPendingLocalChanges: hasPendingLocalChanges,
pendingUpdatedAt: storedPendingUpdatedAt is int
? storedPendingUpdatedAt
: (hasPendingLocalChanges ? updatedAt : 0),
);
}
}
class ChannelSortStorage {
final SharedPreferences _prefs;
ChannelSortStorage(this._prefs);
ChannelSortStore read(String pubkey, String relayUrl) {
final scopedKey = channelSortKey(pubkey, relayUrl);
final scoped = _readKey(scopedKey);
if (scoped != null) return scoped;
// One-time read-through migration from #2829 development builds. The
// first active relay claims the legacy value; removing it prevents the
// same unscoped preferences from bleeding into later communities.
final legacyKey = legacyChannelSortKey(pubkey);
final legacy = _readKey(legacyKey);
if (legacy != null) {
write(pubkey, relayUrl, legacy);
_prefs.remove(legacyKey);
return legacy;
}
return const ChannelSortStore();
}
ChannelSortStore? _readKey(String key) {
final raw = _prefs.getString(key);
if (raw == null || raw.isEmpty) {
return null;
}
try {
final parsed = jsonDecode(raw);
if (parsed is! Map<String, dynamic> || parsed['version'] != 1) {
return null;
}
return ChannelSortStore.fromJson(parsed);
} catch (_) {
return null;
}
}
void write(String pubkey, String relayUrl, ChannelSortStore store) {
_prefs.setString(
channelSortKey(pubkey, relayUrl),
jsonEncode(store.toJson()),
);
}
ChannelSortSyncState readSyncState(String pubkey, String relayUrl) {
final raw = _prefs.getString(_syncStateKey(pubkey, relayUrl));
if (raw == null || raw.isEmpty) return const ChannelSortSyncState();
try {
final parsed = jsonDecode(raw);
return parsed is Map<String, dynamic>
? ChannelSortSyncState.fromJson(parsed)
: const ChannelSortSyncState();
} catch (_) {
return const ChannelSortSyncState();
}
}
void writeSyncState(
String pubkey,
String relayUrl,
ChannelSortSyncState state,
) {
_prefs.setString(
_syncStateKey(pubkey, relayUrl),
jsonEncode(state.toJson()),
);
}
String _syncStateKey(String pubkey, String relayUrl) =>
'${channelSortKey(pubkey, relayUrl)}:sync';
}