Skip to content

Commit 0f08428

Browse files
wesbillmanBrain
authored andcommitted
feat(mobile): star channels (Slack-style favorites) (#863)
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
1 parent 5e0844e commit 0f08428

5 files changed

Lines changed: 660 additions & 2 deletions

File tree

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
import 'dart:async';
2+
import 'dart:convert';
3+
import 'dart:math';
4+
5+
import 'package:flutter/foundation.dart';
6+
import 'package:nostr/nostr.dart' as nostr;
7+
import 'package:shared_preferences/shared_preferences.dart';
8+
9+
import '../../../shared/crypto/nip44.dart';
10+
import '../../../shared/relay/relay.dart';
11+
import '../read_state/read_state_time.dart';
12+
import 'channel_stars_storage.dart';
13+
14+
class ChannelStarsCrypto {
15+
final Uint8List _conversationKey;
16+
17+
ChannelStarsCrypto(String nsec, String pubkey)
18+
: _conversationKey = _deriveKey(nsec, pubkey);
19+
20+
static Uint8List _deriveKey(String nsec, String pubkey) {
21+
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
22+
return getConversationKey(privkeyHex, pubkey);
23+
}
24+
25+
String encrypt(String plaintext) => nip44Encrypt(_conversationKey, plaintext);
26+
27+
String decrypt(String ciphertext) =>
28+
nip44Decrypt(_conversationKey, ciphertext);
29+
}
30+
31+
class ChannelStarsManager {
32+
final String pubkey;
33+
final ChannelStarsStorage _storage;
34+
final ChannelStarsCrypto _crypto;
35+
final RelaySessionNotifier? _relaySession;
36+
final SignedEventRelay? _signedEventRelay;
37+
final bool _remoteEnabled;
38+
final VoidCallback _onChanged;
39+
40+
ChannelStarStore _store;
41+
ChannelStarStore? _lastPublishedStore;
42+
Timer? _publishDebounce;
43+
int _lastRemoteCreatedAt = 0;
44+
String? _lastRemoteEventId;
45+
void Function()? _unsubscribe;
46+
bool _disposed = false;
47+
48+
ChannelStarsManager({
49+
required this.pubkey,
50+
required SharedPreferences prefs,
51+
required ChannelStarsCrypto crypto,
52+
required RelaySessionNotifier? relaySession,
53+
required SignedEventRelay? signedEventRelay,
54+
required bool remoteEnabled,
55+
required VoidCallback onChanged,
56+
}) : _storage = ChannelStarsStorage(prefs),
57+
_crypto = crypto,
58+
_relaySession = relaySession,
59+
_signedEventRelay = signedEventRelay,
60+
_remoteEnabled = remoteEnabled,
61+
_onChanged = onChanged,
62+
_store = ChannelStarsStorage(prefs).read(pubkey);
63+
64+
ChannelStarStore get store => _store;
65+
66+
Future<void> initialize() async {
67+
if (_disposed) return;
68+
69+
if (!_remoteEnabled || _relaySession == null) {
70+
_onChanged();
71+
return;
72+
}
73+
74+
await _fetchAndMerge();
75+
await _startLiveSubscription();
76+
_onChanged();
77+
}
78+
79+
void dispose({bool flushPending = true}) {
80+
if (_disposed) return;
81+
_disposed = true;
82+
83+
final hadPending = _publishDebounce != null;
84+
_publishDebounce?.cancel();
85+
_publishDebounce = null;
86+
87+
if (flushPending && hadPending && _remoteEnabled) {
88+
unawaited(_publish(allowDisposed: true));
89+
}
90+
91+
_unsubscribe?.call();
92+
_unsubscribe = null;
93+
}
94+
95+
// -------------------------------------------------------------------------
96+
// CRUD
97+
// -------------------------------------------------------------------------
98+
99+
void starChannel(String channelId) {
100+
if (_disposed) return;
101+
final entry = ChannelStarEntry(
102+
starred: true,
103+
updatedAt: currentUnixSeconds(),
104+
);
105+
_store = ChannelStarStore(channels: {..._store.channels, channelId: entry});
106+
_persist();
107+
markDirty();
108+
}
109+
110+
void unstarChannel(String channelId) {
111+
if (_disposed) return;
112+
final entry = ChannelStarEntry(
113+
starred: false,
114+
updatedAt: currentUnixSeconds(),
115+
);
116+
_store = ChannelStarStore(channels: {..._store.channels, channelId: entry});
117+
_persist();
118+
markDirty();
119+
}
120+
121+
void markDirty() {
122+
if (!_remoteEnabled || _disposed) return;
123+
_publishDebounce?.cancel();
124+
_publishDebounce = Timer(const Duration(seconds: 5), () {
125+
_publishDebounce = null;
126+
unawaited(_publish());
127+
});
128+
}
129+
130+
// -------------------------------------------------------------------------
131+
// Remote sync
132+
// -------------------------------------------------------------------------
133+
134+
Future<void> _fetchAndMerge() async {
135+
if (_relaySession == null) return;
136+
try {
137+
final events = await _relaySession.fetchHistory(
138+
NostrFilter(
139+
kinds: const [EventKind.readState],
140+
authors: [pubkey],
141+
tags: const {
142+
'#d': ['channel-stars'],
143+
},
144+
limit: 1,
145+
),
146+
);
147+
_mergeEvents(events);
148+
_persist();
149+
if (!_disposed) _onChanged();
150+
} catch (_) {
151+
// Local state remains usable when relay is unavailable.
152+
}
153+
}
154+
155+
Future<void> _startLiveSubscription() async {
156+
if (_relaySession == null) return;
157+
try {
158+
_unsubscribe = await _relaySession.subscribe(
159+
NostrFilter(
160+
kinds: const [EventKind.readState],
161+
authors: [pubkey],
162+
tags: const {
163+
'#d': ['channel-stars'],
164+
},
165+
limit: 1,
166+
),
167+
_handleIncomingEvent,
168+
);
169+
} catch (_) {
170+
// Non-fatal — local state and history still work.
171+
}
172+
}
173+
174+
void _mergeEvents(List<NostrEvent> events) {
175+
for (final event in events) {
176+
if (event.pubkey != pubkey) continue;
177+
_mergeEvent(event);
178+
}
179+
}
180+
181+
void _mergeEvent(NostrEvent event) {
182+
// Only process channel-stars d-tag events.
183+
final dTag = event.getTagValue('d');
184+
if (dTag != 'channel-stars') return;
185+
186+
try {
187+
final plaintext = _crypto.decrypt(event.content);
188+
final parsed = jsonDecode(plaintext);
189+
if (parsed is! Map<String, dynamic>) return;
190+
191+
final incoming = ChannelStarStore.fromJson(parsed);
192+
193+
// Gate on createdAt: ignore events older than what we've already seen.
194+
final isNewer =
195+
event.createdAt > _lastRemoteCreatedAt ||
196+
(event.createdAt == _lastRemoteCreatedAt &&
197+
event.id.compareTo(_lastRemoteEventId ?? '') > 0);
198+
199+
if (isNewer) {
200+
_lastRemoteCreatedAt = event.createdAt;
201+
_lastRemoteEventId = event.id;
202+
// Per-channel merge: keep the entry with the highest updatedAt for each channel.
203+
_store = mergeStores(_store, incoming);
204+
_persist();
205+
}
206+
} catch (_) {
207+
// Decryption failure or parse error — keep existing state.
208+
}
209+
}
210+
211+
void _handleIncomingEvent(NostrEvent event) {
212+
if (_disposed) return;
213+
_mergeEvent(event);
214+
if (!_disposed) _onChanged();
215+
}
216+
217+
bool _isIdenticalToLastPublished() {
218+
final last = _lastPublishedStore;
219+
if (last == null) return false;
220+
if (last.channels.length != _store.channels.length) return false;
221+
for (final key in _store.channels.keys) {
222+
final lastEntry = last.channels[key];
223+
final currentEntry = _store.channels[key];
224+
if (lastEntry == null ||
225+
lastEntry.starred != currentEntry!.starred ||
226+
lastEntry.updatedAt != currentEntry.updatedAt) {
227+
return false;
228+
}
229+
}
230+
return true;
231+
}
232+
233+
Future<void> _publish({bool allowDisposed = false}) async {
234+
if ((!allowDisposed && _disposed) ||
235+
!_remoteEnabled ||
236+
_signedEventRelay == null) {
237+
return;
238+
}
239+
240+
// Read-before-write: merge remote state before publishing
241+
await _fetchAndMerge();
242+
243+
// No-op suppression: skip if nothing changed
244+
if (_isIdenticalToLastPublished()) return;
245+
246+
try {
247+
final payload = jsonEncode(_store.toJson());
248+
final ciphertext = _crypto.encrypt(payload);
249+
final createdAt = max(currentUnixSeconds(), _lastRemoteCreatedAt + 1);
250+
251+
await _signedEventRelay.submit(
252+
kind: EventKind.readState,
253+
content: ciphertext,
254+
tags: [
255+
['d', 'channel-stars'],
256+
['t', 'channel-stars'],
257+
],
258+
createdAt: createdAt,
259+
);
260+
261+
_lastRemoteCreatedAt = max(_lastRemoteCreatedAt, createdAt);
262+
_lastPublishedStore = ChannelStarStore(channels: Map.of(_store.channels));
263+
} catch (error) {
264+
debugPrint('[ChannelStarsManager] publish failed: $error');
265+
}
266+
}
267+
268+
void _persist() {
269+
_storage.write(pubkey, _store);
270+
}
271+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import 'package:hooks_riverpod/hooks_riverpod.dart';
2+
import 'package:nostr/nostr.dart' as nostr;
3+
4+
import '../../../shared/relay/relay.dart';
5+
import '../../../shared/theme/theme_provider.dart';
6+
import '../../../shared/workspace/workspace_provider.dart';
7+
import 'channel_stars_manager.dart';
8+
import 'channel_stars_storage.dart';
9+
10+
class ChannelStarsState {
11+
final bool isReady;
12+
final ChannelStarStore store;
13+
14+
/// Bumped on every change to force downstream rebuilds.
15+
final int version;
16+
17+
const ChannelStarsState({
18+
this.isReady = false,
19+
this.store = const ChannelStarStore(),
20+
this.version = 0,
21+
});
22+
}
23+
24+
class ChannelStarsNotifier extends Notifier<ChannelStarsState> {
25+
ChannelStarsManager? _manager;
26+
27+
@override
28+
ChannelStarsState build() {
29+
_manager?.dispose(flushPending: false);
30+
_manager = null;
31+
32+
final relayConfig = ref.watch(relayConfigProvider);
33+
final sessionState = ref.watch(relaySessionProvider);
34+
// Rebuild when the active workspace changes (pubkey may differ).
35+
ref.watch(activeWorkspaceProvider);
36+
37+
final nsec = relayConfig.nsec?.trim();
38+
if (nsec == null || nsec.isEmpty) {
39+
return const ChannelStarsState();
40+
}
41+
42+
final pubkey = _safePubkeyFromNsec(nsec);
43+
if (pubkey == null || pubkey.isEmpty) {
44+
return const ChannelStarsState();
45+
}
46+
47+
final ChannelStarsCrypto crypto;
48+
try {
49+
crypto = ChannelStarsCrypto(nsec, pubkey);
50+
} catch (_) {
51+
return const ChannelStarsState();
52+
}
53+
54+
final prefs = ref.read(savedPrefsProvider);
55+
final signedRelay = SignedEventRelay(
56+
session: ref.read(relaySessionProvider.notifier),
57+
nsec: nsec,
58+
);
59+
60+
late final ChannelStarsManager manager;
61+
manager = ChannelStarsManager(
62+
pubkey: pubkey,
63+
prefs: prefs,
64+
crypto: crypto,
65+
relaySession: ref.read(relaySessionProvider.notifier),
66+
signedEventRelay: signedRelay,
67+
remoteEnabled: sessionState.status == SessionStatus.connected,
68+
onChanged: () => _emitManagerState(manager),
69+
);
70+
_manager = manager;
71+
72+
ref.onDispose(() {
73+
manager.dispose();
74+
if (_manager == manager) {
75+
_manager = null;
76+
}
77+
});
78+
79+
Future.microtask(() async {
80+
await manager.initialize();
81+
if (_manager != manager) return;
82+
_emitManagerState(manager);
83+
});
84+
85+
return ChannelStarsState(isReady: false, store: manager.store, version: 1);
86+
}
87+
88+
// -------------------------------------------------------------------------
89+
// CRUD delegates
90+
// -------------------------------------------------------------------------
91+
92+
void starChannel(String channelId) => _manager?.starChannel(channelId);
93+
94+
void unstarChannel(String channelId) => _manager?.unstarChannel(channelId);
95+
96+
// -------------------------------------------------------------------------
97+
// Internal
98+
// -------------------------------------------------------------------------
99+
100+
void _emitManagerState(ChannelStarsManager manager) {
101+
if (_manager != manager) return;
102+
state = ChannelStarsState(
103+
isReady: true,
104+
store: manager.store,
105+
version: state.version + 1,
106+
);
107+
}
108+
}
109+
110+
final channelStarsProvider =
111+
NotifierProvider<ChannelStarsNotifier, ChannelStarsState>(
112+
ChannelStarsNotifier.new,
113+
);
114+
115+
String? _safePubkeyFromNsec(String nsec) {
116+
try {
117+
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
118+
if (privkeyHex.isEmpty) return null;
119+
return nostr.Keys(privkeyHex).public;
120+
} catch (_) {
121+
return null;
122+
}
123+
}

0 commit comments

Comments
 (0)