mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(mobile): add channel sections with relay sync (#800)
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
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 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../../shared/crypto/nip44.dart';
|
||||
import '../../../shared/relay/relay.dart';
|
||||
import '../read_state/read_state_time.dart';
|
||||
import 'channel_sections_storage.dart';
|
||||
|
||||
const _uuid = Uuid();
|
||||
|
||||
class ChannelSectionsCrypto {
|
||||
final Uint8List _conversationKey;
|
||||
|
||||
ChannelSectionsCrypto(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 ChannelSectionsManager {
|
||||
final String pubkey;
|
||||
final ChannelSectionsStorage _storage;
|
||||
final ChannelSectionsCrypto _crypto;
|
||||
final RelaySessionNotifier? _relaySession;
|
||||
final SignedEventRelay? _signedEventRelay;
|
||||
final bool _remoteEnabled;
|
||||
final VoidCallback _onChanged;
|
||||
|
||||
ChannelSectionStore _store;
|
||||
Timer? _publishDebounce;
|
||||
int _lastRemoteCreatedAt = 0;
|
||||
void Function()? _unsubscribe;
|
||||
bool _disposed = false;
|
||||
|
||||
ChannelSectionsManager({
|
||||
required this.pubkey,
|
||||
required SharedPreferences prefs,
|
||||
required ChannelSectionsCrypto crypto,
|
||||
required RelaySessionNotifier? relaySession,
|
||||
required SignedEventRelay? signedEventRelay,
|
||||
required bool remoteEnabled,
|
||||
required VoidCallback onChanged,
|
||||
}) : _storage = ChannelSectionsStorage(prefs),
|
||||
_crypto = crypto,
|
||||
_relaySession = relaySession,
|
||||
_signedEventRelay = signedEventRelay,
|
||||
_remoteEnabled = remoteEnabled,
|
||||
_onChanged = onChanged,
|
||||
_store = ChannelSectionsStorage(prefs).read(pubkey);
|
||||
|
||||
ChannelSectionStore 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 createSection(String name) {
|
||||
if (_disposed) return;
|
||||
final maxOrder = _store.sections.fold<int>(
|
||||
-1,
|
||||
(max, s) => s.order > max ? s.order : max,
|
||||
);
|
||||
final section = ChannelSection(
|
||||
id: _uuid.v4(),
|
||||
name: name.trim(),
|
||||
order: maxOrder + 1,
|
||||
);
|
||||
_store = ChannelSectionStore(
|
||||
sections: [..._store.sections, section],
|
||||
assignments: _store.assignments,
|
||||
);
|
||||
_persist();
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void renameSection(String sectionId, String newName) {
|
||||
if (_disposed) return;
|
||||
_store = ChannelSectionStore(
|
||||
sections: [
|
||||
for (final s in _store.sections)
|
||||
if (s.id == sectionId)
|
||||
ChannelSection(id: s.id, name: newName.trim(), order: s.order)
|
||||
else
|
||||
s,
|
||||
],
|
||||
assignments: _store.assignments,
|
||||
);
|
||||
_persist();
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void deleteSection(String sectionId) {
|
||||
if (_disposed) return;
|
||||
final updatedAssignments = Map<String, String>.from(_store.assignments)
|
||||
..removeWhere((_, sid) => sid == sectionId);
|
||||
_store = ChannelSectionStore(
|
||||
sections: [
|
||||
for (final s in _store.sections)
|
||||
if (s.id != sectionId) s,
|
||||
],
|
||||
assignments: updatedAssignments,
|
||||
);
|
||||
_persist();
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void moveSectionUp(String sectionId) {
|
||||
if (_disposed) return;
|
||||
final sorted = _sortedSections();
|
||||
final idx = sorted.indexWhere((s) => s.id == sectionId);
|
||||
if (idx <= 0) return;
|
||||
_swapOrders(sorted, idx, idx - 1);
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void moveSectionDown(String sectionId) {
|
||||
if (_disposed) return;
|
||||
final sorted = _sortedSections();
|
||||
final idx = sorted.indexWhere((s) => s.id == sectionId);
|
||||
if (idx < 0 || idx >= sorted.length - 1) return;
|
||||
_swapOrders(sorted, idx, idx + 1);
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void assignChannel(String channelId, String sectionId) {
|
||||
if (_disposed) return;
|
||||
final updated = Map<String, String>.from(_store.assignments)
|
||||
..[channelId] = sectionId;
|
||||
_store = ChannelSectionStore(
|
||||
sections: _store.sections,
|
||||
assignments: updated,
|
||||
);
|
||||
_persist();
|
||||
markDirty();
|
||||
}
|
||||
|
||||
void unassignChannel(String channelId) {
|
||||
if (_disposed) return;
|
||||
final updated = Map<String, String>.from(_store.assignments)
|
||||
..remove(channelId);
|
||||
_store = ChannelSectionStore(
|
||||
sections: _store.sections,
|
||||
assignments: updated,
|
||||
);
|
||||
_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-sections'],
|
||||
},
|
||||
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-sections'],
|
||||
},
|
||||
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-sections d-tag events.
|
||||
final dTag = event.getTagValue('d');
|
||||
if (dTag != 'channel-sections') return;
|
||||
|
||||
try {
|
||||
final plaintext = _crypto.decrypt(event.content);
|
||||
final parsed = jsonDecode(plaintext);
|
||||
if (parsed is! Map<String, dynamic>) return;
|
||||
|
||||
final incoming = ChannelSectionStore.fromJson(parsed);
|
||||
|
||||
// Last-write-wins: newer createdAt wins; tie-break by event ID.
|
||||
final isNewer =
|
||||
event.createdAt > _lastRemoteCreatedAt ||
|
||||
(event.createdAt == _lastRemoteCreatedAt &&
|
||||
event.id.compareTo(_lastRemoteEventId ?? '') > 0);
|
||||
|
||||
if (isNewer) {
|
||||
_lastRemoteCreatedAt = event.createdAt;
|
||||
_lastRemoteEventId = event.id;
|
||||
_store = incoming;
|
||||
_persist();
|
||||
}
|
||||
} catch (_) {
|
||||
// Decryption failure or parse error — keep existing state.
|
||||
}
|
||||
}
|
||||
|
||||
String? _lastRemoteEventId;
|
||||
|
||||
void _handleIncomingEvent(NostrEvent event) {
|
||||
if (_disposed) return;
|
||||
_mergeEvent(event);
|
||||
if (!_disposed) _onChanged();
|
||||
}
|
||||
|
||||
Future<void> _publish({bool allowDisposed = false}) async {
|
||||
if ((!allowDisposed && _disposed) ||
|
||||
!_remoteEnabled ||
|
||||
_signedEventRelay == null) {
|
||||
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-sections'],
|
||||
['t', 'channel-sections'],
|
||||
],
|
||||
createdAt: createdAt,
|
||||
);
|
||||
|
||||
_lastRemoteCreatedAt = max(_lastRemoteCreatedAt, createdAt);
|
||||
} catch (error) {
|
||||
debugPrint('[ChannelSectionsManager] publish failed: $error');
|
||||
}
|
||||
}
|
||||
|
||||
void _persist() {
|
||||
_storage.write(pubkey, _store);
|
||||
}
|
||||
|
||||
List<ChannelSection> _sortedSections() {
|
||||
final sorted = _store.sections.toList()
|
||||
..sort((a, b) => a.order.compareTo(b.order));
|
||||
return sorted;
|
||||
}
|
||||
|
||||
void _swapOrders(List<ChannelSection> sorted, int indexA, int indexB) {
|
||||
final orderA = sorted[indexA].order;
|
||||
final orderB = sorted[indexB].order;
|
||||
final idA = sorted[indexA].id;
|
||||
final idB = sorted[indexB].id;
|
||||
|
||||
_store = ChannelSectionStore(
|
||||
sections: [
|
||||
for (final s in _store.sections)
|
||||
if (s.id == idA)
|
||||
ChannelSection(id: s.id, name: s.name, order: orderB)
|
||||
else if (s.id == idB)
|
||||
ChannelSection(id: s.id, name: s.name, order: orderA)
|
||||
else
|
||||
s,
|
||||
],
|
||||
assignments: _store.assignments,
|
||||
);
|
||||
_persist();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
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_sections_manager.dart';
|
||||
import 'channel_sections_storage.dart';
|
||||
|
||||
class ChannelSectionsState {
|
||||
final bool isReady;
|
||||
final ChannelSectionStore store;
|
||||
|
||||
/// Bumped on every change to force downstream rebuilds.
|
||||
final int version;
|
||||
|
||||
const ChannelSectionsState({
|
||||
this.isReady = false,
|
||||
this.store = const ChannelSectionStore(),
|
||||
this.version = 0,
|
||||
});
|
||||
}
|
||||
|
||||
class ChannelSectionsNotifier extends Notifier<ChannelSectionsState> {
|
||||
ChannelSectionsManager? _manager;
|
||||
|
||||
@override
|
||||
ChannelSectionsState 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 ChannelSectionsState();
|
||||
}
|
||||
|
||||
final pubkey = _safePubkeyFromNsec(nsec);
|
||||
if (pubkey == null || pubkey.isEmpty) {
|
||||
return const ChannelSectionsState();
|
||||
}
|
||||
|
||||
final ChannelSectionsCrypto crypto;
|
||||
try {
|
||||
crypto = ChannelSectionsCrypto(nsec, pubkey);
|
||||
} catch (_) {
|
||||
return const ChannelSectionsState();
|
||||
}
|
||||
|
||||
final prefs = ref.read(savedPrefsProvider);
|
||||
final signedRelay = SignedEventRelay(
|
||||
session: ref.read(relaySessionProvider.notifier),
|
||||
nsec: nsec,
|
||||
);
|
||||
|
||||
late final ChannelSectionsManager manager;
|
||||
manager = ChannelSectionsManager(
|
||||
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 ChannelSectionsState(
|
||||
isReady: false,
|
||||
store: manager.store,
|
||||
version: 1,
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CRUD delegates
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void createSection(String name) => _manager?.createSection(name);
|
||||
|
||||
void renameSection(String sectionId, String newName) =>
|
||||
_manager?.renameSection(sectionId, newName);
|
||||
|
||||
void deleteSection(String sectionId) => _manager?.deleteSection(sectionId);
|
||||
|
||||
void moveSectionUp(String sectionId) => _manager?.moveSectionUp(sectionId);
|
||||
|
||||
void moveSectionDown(String sectionId) =>
|
||||
_manager?.moveSectionDown(sectionId);
|
||||
|
||||
void assignChannel(String channelId, String sectionId) =>
|
||||
_manager?.assignChannel(channelId, sectionId);
|
||||
|
||||
void unassignChannel(String channelId) =>
|
||||
_manager?.unassignChannel(channelId);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
void _emitManagerState(ChannelSectionsManager manager) {
|
||||
if (_manager != manager) return;
|
||||
state = ChannelSectionsState(
|
||||
isReady: true,
|
||||
store: manager.store,
|
||||
version: state.version + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final channelSectionsProvider =
|
||||
NotifierProvider<ChannelSectionsNotifier, ChannelSectionsState>(
|
||||
ChannelSectionsNotifier.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,109 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
String channelSectionsKey(String pubkey) =>
|
||||
'sprout.channel-sections.v1:$pubkey';
|
||||
|
||||
class ChannelSection {
|
||||
final String id;
|
||||
final String name;
|
||||
final int order;
|
||||
|
||||
const ChannelSection({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.order,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {'id': id, 'name': name, 'order': order};
|
||||
|
||||
factory ChannelSection.fromJson(Map<String, dynamic> json) => ChannelSection(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
order: json['order'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
class ChannelSectionStore {
|
||||
final int version;
|
||||
final List<ChannelSection> sections;
|
||||
final Map<String, String> assignments;
|
||||
|
||||
const ChannelSectionStore({
|
||||
this.version = 1,
|
||||
this.sections = const [],
|
||||
this.assignments = const {},
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'version': version,
|
||||
'sections': sections.map((s) => s.toJson()).toList(),
|
||||
'assignments': assignments,
|
||||
};
|
||||
|
||||
factory ChannelSectionStore.fromJson(Map<String, dynamic> json) {
|
||||
final rawSections = json['sections'];
|
||||
final sections = <ChannelSection>[];
|
||||
if (rawSections is List) {
|
||||
for (final entry in rawSections) {
|
||||
if (entry is Map<String, dynamic> &&
|
||||
entry['id'] is String &&
|
||||
entry['name'] is String &&
|
||||
entry['order'] is int) {
|
||||
sections.add(ChannelSection.fromJson(entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final rawAssignments = json['assignments'];
|
||||
final assignments = <String, String>{};
|
||||
if (rawAssignments is Map) {
|
||||
for (final entry in rawAssignments.entries) {
|
||||
if (entry.key is String && entry.value is String) {
|
||||
assignments[entry.key as String] = entry.value as String;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strip assignments referencing sections that don't exist.
|
||||
final sectionIds = {for (final s in sections) s.id};
|
||||
assignments.removeWhere((_, sectionId) => !sectionIds.contains(sectionId));
|
||||
|
||||
return ChannelSectionStore(
|
||||
version: 1,
|
||||
sections: sections,
|
||||
assignments: assignments,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChannelSectionsStorage {
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
ChannelSectionsStorage(this._prefs);
|
||||
|
||||
ChannelSectionStore read(String pubkey) {
|
||||
final raw = _prefs.getString(channelSectionsKey(pubkey));
|
||||
if (raw == null || raw.isEmpty) {
|
||||
return const ChannelSectionStore();
|
||||
}
|
||||
|
||||
try {
|
||||
final parsed = jsonDecode(raw);
|
||||
if (parsed is! Map<String, dynamic>) {
|
||||
return const ChannelSectionStore();
|
||||
}
|
||||
if (parsed['version'] != 1) {
|
||||
return const ChannelSectionStore();
|
||||
}
|
||||
return ChannelSectionStore.fromJson(parsed);
|
||||
} catch (_) {
|
||||
return const ChannelSectionStore();
|
||||
}
|
||||
}
|
||||
|
||||
void write(String pubkey, ChannelSectionStore store) {
|
||||
_prefs.setString(channelSectionsKey(pubkey), jsonEncode(store.toJson()));
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import '../pairing/pairing_provider.dart';
|
||||
import 'channel.dart';
|
||||
import 'channel_detail_page.dart';
|
||||
import 'channel_management_provider.dart';
|
||||
import 'channel_sections/channel_sections_provider.dart';
|
||||
import 'channel_sections/channel_sections_storage.dart';
|
||||
import 'channels_provider.dart';
|
||||
import 'read_state/deferred_read_state_update.dart';
|
||||
import 'read_state/read_state_provider.dart';
|
||||
@@ -272,6 +274,7 @@ class _SliverChannelsList extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final readState = ref.watch(readStateProvider);
|
||||
final sectionsState = ref.watch(channelSectionsProvider);
|
||||
final visibleChannels = channels
|
||||
.where((channel) => channel.isMember && !channel.isArchived)
|
||||
.toList();
|
||||
@@ -333,6 +336,33 @@ class _SliverChannelsList extends HookConsumerWidget {
|
||||
}
|
||||
: const <String>{};
|
||||
|
||||
// Build sorted user-defined sections and compute which stream channels
|
||||
// belong to each section. Channels not assigned to any valid section fall
|
||||
// through to the built-in "Channels" list.
|
||||
final userSections = sectionsState.store.sections.toList()
|
||||
..sort((a, b) => a.order.compareTo(b.order));
|
||||
final sectionAssignments = sectionsState.store.assignments;
|
||||
final validSectionIds = {for (final s in userSections) s.id};
|
||||
final assignedChannelIds = {
|
||||
for (final entry in sectionAssignments.entries)
|
||||
if (validSectionIds.contains(entry.value)) entry.key,
|
||||
};
|
||||
final ungroupedStreamChannels = streamChannels
|
||||
.where((c) => !assignedChannelIds.contains(c.id))
|
||||
.toList();
|
||||
|
||||
final sectionExpandedStates = useState<Map<String, bool>>({});
|
||||
|
||||
bool sectionExpanded(String sectionId) =>
|
||||
sectionExpandedStates.value[sectionId] ?? true;
|
||||
|
||||
void toggleSection(String sectionId) {
|
||||
sectionExpandedStates.value = {
|
||||
...sectionExpandedStates.value,
|
||||
sectionId: !sectionExpanded(sectionId),
|
||||
};
|
||||
}
|
||||
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.only(top: Grid.xxs, bottom: 80),
|
||||
sliver: SliverList.list(
|
||||
@@ -340,12 +370,87 @@ class _SliverChannelsList extends HookConsumerWidget {
|
||||
if (visibleChannels.isEmpty)
|
||||
const _EmptyState()
|
||||
else ...[
|
||||
// User-defined sections for stream channels, in user-defined order.
|
||||
for (final section in userSections)
|
||||
_CustomChannelSection(
|
||||
section: section,
|
||||
channels: streamChannels
|
||||
.where((c) => sectionAssignments[c.id] == section.id)
|
||||
.toList(),
|
||||
unreadChannelIds: unreadChannelIds,
|
||||
currentPubkey: currentPubkey,
|
||||
expanded: sectionExpanded(section.id),
|
||||
isFirst: userSections.first.id == section.id,
|
||||
isLast: userSections.last.id == section.id,
|
||||
onToggle: () => toggleSection(section.id),
|
||||
onRename: () async {
|
||||
final name = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (_) => _SectionNameDialog(
|
||||
title: 'Rename Section',
|
||||
confirmLabel: 'Rename',
|
||||
initialValue: section.name,
|
||||
),
|
||||
);
|
||||
if (name != null && name.isNotEmpty) {
|
||||
ref
|
||||
.read(channelSectionsProvider.notifier)
|
||||
.renameSection(section.id, name);
|
||||
}
|
||||
},
|
||||
onDelete: () async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => AlertDialog(
|
||||
title: Text('Delete "${section.name}"?'),
|
||||
content: const Text(
|
||||
'Channels in this section will move back to the main list.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(
|
||||
'Delete',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
ref
|
||||
.read(channelSectionsProvider.notifier)
|
||||
.deleteSection(section.id);
|
||||
}
|
||||
},
|
||||
onMoveUp: () => ref
|
||||
.read(channelSectionsProvider.notifier)
|
||||
.moveSectionUp(section.id),
|
||||
onMoveDown: () => ref
|
||||
.read(channelSectionsProvider.notifier)
|
||||
.moveSectionDown(section.id),
|
||||
onSelectChannel: onSelectChannel,
|
||||
onMarkChannelRead: (channel) {
|
||||
final ts = dateTimeToUnixSeconds(channel.lastMessageAt);
|
||||
if (ts != null) {
|
||||
ref
|
||||
.read(readStateProvider.notifier)
|
||||
.markContextRead(channel.id, ts);
|
||||
}
|
||||
},
|
||||
),
|
||||
_ChannelSection(
|
||||
title: 'Channels',
|
||||
icon: LucideIcons.hash,
|
||||
expanded: channelsExpanded.value,
|
||||
onToggle: () => channelsExpanded.value = !channelsExpanded.value,
|
||||
channels: streamChannels,
|
||||
channels: ungroupedStreamChannels,
|
||||
unreadChannelIds: unreadChannelIds,
|
||||
currentPubkey: currentPubkey,
|
||||
emptyLabel: 'No stream channels yet',
|
||||
@@ -380,6 +485,230 @@ class _SliverChannelsList extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User-defined channel sections
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _CustomChannelSection extends StatelessWidget {
|
||||
final ChannelSection section;
|
||||
final List<Channel> channels;
|
||||
final Set<String> unreadChannelIds;
|
||||
final String? currentPubkey;
|
||||
final bool expanded;
|
||||
final bool isFirst;
|
||||
final bool isLast;
|
||||
final VoidCallback onToggle;
|
||||
final VoidCallback onRename;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onMoveUp;
|
||||
final VoidCallback onMoveDown;
|
||||
final Future<void> Function(Channel channel) onSelectChannel;
|
||||
final void Function(Channel channel) onMarkChannelRead;
|
||||
|
||||
const _CustomChannelSection({
|
||||
required this.section,
|
||||
required this.channels,
|
||||
required this.unreadChannelIds,
|
||||
required this.currentPubkey,
|
||||
required this.expanded,
|
||||
required this.isFirst,
|
||||
required this.isLast,
|
||||
required this.onToggle,
|
||||
required this.onRename,
|
||||
required this.onDelete,
|
||||
required this.onMoveUp,
|
||||
required this.onMoveDown,
|
||||
required this.onSelectChannel,
|
||||
required this.onMarkChannelRead,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_CustomSectionHeader(
|
||||
section: section,
|
||||
expanded: expanded,
|
||||
isFirst: isFirst,
|
||||
isLast: isLast,
|
||||
onToggle: onToggle,
|
||||
onRename: onRename,
|
||||
onDelete: onDelete,
|
||||
onMoveUp: onMoveUp,
|
||||
onMoveDown: onMoveDown,
|
||||
),
|
||||
if (expanded)
|
||||
for (final channel in channels)
|
||||
_ChannelTile(
|
||||
channel: channel,
|
||||
isUnread: unreadChannelIds.contains(channel.id),
|
||||
currentPubkey: currentPubkey,
|
||||
onTap: () => onSelectChannel(channel),
|
||||
onMarkRead: () => onMarkChannelRead(channel),
|
||||
sectionId: section.id,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CustomSectionHeader extends StatelessWidget {
|
||||
final ChannelSection section;
|
||||
final bool expanded;
|
||||
final bool isFirst;
|
||||
final bool isLast;
|
||||
final VoidCallback onToggle;
|
||||
final VoidCallback onRename;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onMoveUp;
|
||||
final VoidCallback onMoveDown;
|
||||
|
||||
const _CustomSectionHeader({
|
||||
required this.section,
|
||||
required this.expanded,
|
||||
required this.isFirst,
|
||||
required this.isLast,
|
||||
required this.onToggle,
|
||||
required this.onRename,
|
||||
required this.onDelete,
|
||||
required this.onMoveUp,
|
||||
required this.onMoveDown,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onToggle,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.xs,
|
||||
Grid.twelve,
|
||||
Grid.xs,
|
||||
Grid.half,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.folder,
|
||||
size: 14,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
Text(
|
||||
section.name.toUpperCase(),
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.8,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTapUp: (details) async {
|
||||
final overlay =
|
||||
Overlay.of(context).context.findRenderObject()!
|
||||
as RenderBox;
|
||||
final position = RelativeRect.fromRect(
|
||||
details.globalPosition & Size.zero,
|
||||
Offset.zero & overlay.size,
|
||||
);
|
||||
final value = await showMenu<String>(
|
||||
context: context,
|
||||
position: position,
|
||||
items: [
|
||||
const PopupMenuItem(value: 'rename', child: Text('Rename')),
|
||||
PopupMenuItem(
|
||||
value: 'move_up',
|
||||
enabled: !isFirst,
|
||||
child: const Text('Move Up'),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'move_down',
|
||||
enabled: !isLast,
|
||||
child: const Text('Move Down'),
|
||||
),
|
||||
const PopupMenuItem(value: 'delete', child: Text('Delete')),
|
||||
],
|
||||
);
|
||||
switch (value) {
|
||||
case 'rename':
|
||||
onRename();
|
||||
case 'move_up':
|
||||
onMoveUp();
|
||||
case 'move_down':
|
||||
onMoveDown();
|
||||
case 'delete':
|
||||
onDelete();
|
||||
}
|
||||
},
|
||||
child: Icon(
|
||||
LucideIcons.ellipsisVertical,
|
||||
size: 14,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.quarter),
|
||||
Icon(
|
||||
expanded ? LucideIcons.chevronDown : LucideIcons.chevronRight,
|
||||
size: 14,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section name dialog (create / rename)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _SectionNameDialog extends HookWidget {
|
||||
final String title;
|
||||
final String confirmLabel;
|
||||
final String initialValue;
|
||||
|
||||
const _SectionNameDialog({
|
||||
required this.title,
|
||||
required this.confirmLabel,
|
||||
this.initialValue = '',
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = useTextEditingController(text: initialValue);
|
||||
|
||||
void confirm() {
|
||||
final name = controller.text.trim();
|
||||
if (name.isNotEmpty) Navigator.of(context).pop(name);
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(title),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
onSubmitted: (_) => confirm(),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(onPressed: confirm, child: Text(confirmLabel)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Built-in channel sections (Channels / Forums / DMs)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _ChannelSection extends StatelessWidget {
|
||||
final String title;
|
||||
final IconData icon;
|
||||
@@ -437,6 +766,8 @@ class _ChannelSection extends StatelessWidget {
|
||||
isUnread: unreadChannelIds.contains(channel.id),
|
||||
currentPubkey: currentPubkey,
|
||||
onTap: () => onSelectChannel(channel),
|
||||
onMarkRead: null,
|
||||
sectionId: null,
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -530,11 +861,20 @@ class _ChannelTile extends ConsumerWidget {
|
||||
final String? currentPubkey;
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// Called when the user requests to mark this channel read (from long-press
|
||||
/// actions menu). Null for channels in built-in sections.
|
||||
final VoidCallback? onMarkRead;
|
||||
|
||||
/// The user-defined section this channel currently belongs to, or null.
|
||||
final String? sectionId;
|
||||
|
||||
const _ChannelTile({
|
||||
required this.channel,
|
||||
required this.isUnread,
|
||||
required this.currentPubkey,
|
||||
required this.onTap,
|
||||
this.onMarkRead,
|
||||
this.sectionId,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -544,6 +884,7 @@ class _ChannelTile extends ConsumerWidget {
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
onTap: onTap,
|
||||
onLongPress: () => _showChannelActions(context, ref),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: Grid.xs + Grid.xxs,
|
||||
@@ -626,6 +967,153 @@ class _ChannelTile extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showChannelActions(BuildContext context, WidgetRef ref) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) {
|
||||
final sections = ref.read(channelSectionsProvider).store.sections
|
||||
..sort((a, b) => a.order.compareTo(b.order));
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(Grid.xs, 0, Grid.xs, Grid.xs),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.folderInput),
|
||||
title: const Text('Move to section'),
|
||||
onTap: () async {
|
||||
Navigator.of(sheetContext).pop();
|
||||
await _showMoveSectionSheet(context, ref, sections);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
isUnread ? LucideIcons.checkCheck : LucideIcons.circleDot,
|
||||
),
|
||||
title: Text(isUnread ? 'Mark as read' : 'Mark as unread'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
final ts = dateTimeToUnixSeconds(channel.lastMessageAt);
|
||||
if (ts != null) {
|
||||
if (isUnread) {
|
||||
onMarkRead?.call();
|
||||
ref
|
||||
.read(readStateProvider.notifier)
|
||||
.markContextRead(channel.id, ts);
|
||||
} else {
|
||||
ref
|
||||
.read(readStateProvider.notifier)
|
||||
.markContextUnread(channel.id, ts);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showMoveSectionSheet(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
List<ChannelSection> sections,
|
||||
) async {
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(Grid.xs, 0, Grid.xs, Grid.xs),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final section in sections)
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
LucideIcons.folder,
|
||||
color: sectionId == section.id
|
||||
? Theme.of(sheetContext).colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
title: Text(section.name),
|
||||
trailing: sectionId == section.id
|
||||
? Icon(
|
||||
LucideIcons.check,
|
||||
color: Theme.of(sheetContext).colorScheme.primary,
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
ref
|
||||
.read(channelSectionsProvider.notifier)
|
||||
.assignChannel(channel.id, section.id);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.folderPlus),
|
||||
title: const Text('New section…'),
|
||||
onTap: () async {
|
||||
Navigator.of(sheetContext).pop();
|
||||
if (!context.mounted) return;
|
||||
final name = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (_) => const _SectionNameDialog(
|
||||
title: 'New Section',
|
||||
confirmLabel: 'Create',
|
||||
),
|
||||
);
|
||||
if (name != null && name.isNotEmpty) {
|
||||
ref
|
||||
.read(channelSectionsProvider.notifier)
|
||||
.createSection(name);
|
||||
// Assign after create — sections list has been mutated,
|
||||
// re-read to find the new section by name.
|
||||
final newSection = ref
|
||||
.read(channelSectionsProvider)
|
||||
.store
|
||||
.sections
|
||||
.lastWhere(
|
||||
(s) => s.name == name.trim(),
|
||||
orElse: () => const ChannelSection(
|
||||
id: '',
|
||||
name: '',
|
||||
order: -1,
|
||||
),
|
||||
);
|
||||
if (newSection.id.isNotEmpty) {
|
||||
ref
|
||||
.read(channelSectionsProvider.notifier)
|
||||
.assignChannel(channel.id, newSection.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
if (sectionId != null)
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.folderMinus),
|
||||
title: const Text('Remove from section'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
ref
|
||||
.read(channelSectionsProvider.notifier)
|
||||
.unassignChannel(channel.id);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DmAvatar extends ConsumerWidget {
|
||||
|
||||
@@ -107,6 +107,16 @@ class ReadStateManager {
|
||||
_advanceContext(contextId, unixTimestamp, publishable: true);
|
||||
}
|
||||
|
||||
void markContextUnread(String contextId, int lastMessageTimestamp) {
|
||||
if (_disposed || lastMessageTimestamp <= 0) return;
|
||||
final rollbackTo = lastMessageTimestamp - 1;
|
||||
_effectiveState[contextId] = rollbackTo;
|
||||
_publishableContextIds.add(contextId);
|
||||
_persistLocalState();
|
||||
_onChanged();
|
||||
_schedulePublish();
|
||||
}
|
||||
|
||||
void seedContextRead(String contextId, int unixTimestamp) {
|
||||
_advanceContext(contextId, unixTimestamp, publishable: false);
|
||||
}
|
||||
|
||||
@@ -121,6 +121,10 @@ class ReadStateNotifier extends Notifier<ReadStateState> {
|
||||
_manager?.markContextRead(contextId, unixTimestamp);
|
||||
}
|
||||
|
||||
void markContextUnread(String contextId, int lastMessageTimestamp) {
|
||||
_manager?.markContextUnread(contextId, lastMessageTimestamp);
|
||||
}
|
||||
|
||||
void seedContextRead(String contextId, int unixTimestamp) {
|
||||
_manager?.seedContextRead(contextId, unixTimestamp);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user