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
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:
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user