Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
271 changes: 271 additions & 0 deletions mobile/lib/features/channels/channel_stars/channel_stars_manager.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
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 '../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;
}

// -------------------------------------------------------------------------
// CRUD
// -------------------------------------------------------------------------

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());
});
}

// -------------------------------------------------------------------------
// Remote sync
// -------------------------------------------------------------------------

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);
}
}
123 changes: 123 additions & 0 deletions mobile/lib/features/channels/channel_stars/channel_stars_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
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/workspace/workspace_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 workspace changes (pubkey may differ).
ref.watch(activeWorkspaceProvider);

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);
}

// -------------------------------------------------------------------------
// CRUD delegates
// -------------------------------------------------------------------------

void starChannel(String channelId) => _manager?.starChannel(channelId);

void unstarChannel(String channelId) => _manager?.unstarChannel(channelId);

// -------------------------------------------------------------------------
// Internal
// -------------------------------------------------------------------------

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;
}
}
Loading