diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 1940ae1d0ad..482fc4fb4b0 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -59,6 +59,14 @@ pub fn sign_event( Ok(event.as_json()) } +#[tauri::command] +pub fn get_nsec(state: State<'_, AppState>) -> Result { + let keys = state.keys.lock().map_err(|error| error.to_string())?; + keys.secret_key() + .to_bech32() + .map_err(|error| format!("encode nsec: {error}")) +} + #[tauri::command] pub fn create_auth_event( challenge: String, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 97206a95a82..57e2ccfcec2 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -530,6 +530,7 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ get_identity, + get_nsec, get_profile, update_profile, get_user_profile, diff --git a/desktop/src/features/settings/ui/MobilePairingCard.tsx b/desktop/src/features/settings/ui/MobilePairingCard.tsx index d2b7a1bf78d..fc129ec4661 100644 --- a/desktop/src/features/settings/ui/MobilePairingCard.tsx +++ b/desktop/src/features/settings/ui/MobilePairingCard.tsx @@ -3,7 +3,7 @@ import { QRCodeSVG } from "qrcode.react"; import { Check, Copy, Loader2, Smartphone, TriangleAlert } from "lucide-react"; import { useMintTokenMutation } from "@/features/tokens/hooks"; -import { getRelayHttpUrl } from "@/shared/api/tauri"; +import { getNsec, getRelayHttpUrl } from "@/shared/api/tauri"; import type { TokenScope } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; import { @@ -31,6 +31,7 @@ type PairingPayload = { relayUrl: string; token: string; pubkey: string; + nsec: string; }; function PairingDialog({ @@ -51,19 +52,21 @@ function PairingDialog({ const [copied, setCopied] = useState(false); const generate = useCallback(async (pubkey: string) => { - const [tokenResult, relayUrl] = await Promise.all([ + const [tokenResult, relayUrl, nsec] = await Promise.all([ mintRef.current({ name: `mobile-${Date.now()}`, scopes: [...MOBILE_SCOPES], expiresInDays: EXPIRES_IN_DAYS, }), getRelayHttpUrl(), + getNsec(), ]); const payload: PairingPayload = { relayUrl, token: tokenResult.token, pubkey, + nsec, }; return `sprout://${toBase64Url(JSON.stringify(payload))}`; }, []); diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index daa55e4e2c9..d373ccac976 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -451,6 +451,10 @@ export async function getIdentity(): Promise { }; } +export async function getNsec(): Promise { + return invokeTauri("get_nsec"); +} + export async function getProfile(): Promise { const profile = await invokeTauri("get_profile"); return fromRawProfile(profile); diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index b7cca0a4283..9e2317ca7dd 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - connectivity_plus (0.0.1): + - Flutter - Flutter (1.0.0) - flutter_secure_storage (6.0.0): - Flutter @@ -56,6 +58,7 @@ PODS: - FlutterMacOS DEPENDENCIES: + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - Flutter (from `Flutter`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`) @@ -76,6 +79,8 @@ SPEC REPOS: - PromisesObjC EXTERNAL SOURCES: + connectivity_plus: + :path: ".symlinks/plugins/connectivity_plus/ios" Flutter: :path: Flutter flutter_secure_storage: @@ -86,6 +91,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" SPEC CHECKSUMS: + connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index e843fa09f70..d92969e0c61 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -5,6 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'features/home/home_page.dart'; import 'features/pairing/pairing_page.dart'; import 'shared/auth/auth.dart'; +import 'shared/relay/relay.dart'; import 'shared/theme/theme.dart'; class App extends HookConsumerWidget { @@ -19,6 +20,13 @@ class App extends HookConsumerWidget { final lightScheme = applyAccent(lightColorScheme, accentIndex); final darkScheme = applyAccent(darkColorScheme, accentIndex); + // Eagerly initialize websocket session and lifecycle observer when + // authenticated. These providers connect and manage the websocket. + if (authState.value?.status == AuthStatus.authenticated) { + ref.watch(relaySessionProvider); + ref.watch(appLifecycleProvider); + } + return MaterialApp( title: 'Sprout', theme: AppTheme.light(colorScheme: lightScheme), diff --git a/mobile/lib/features/channels/channel.dart b/mobile/lib/features/channels/channel.dart index 5a828a43b4b..f6f5dcff56b 100644 --- a/mobile/lib/features/channels/channel.dart +++ b/mobile/lib/features/channels/channel.dart @@ -61,4 +61,21 @@ class Channel { bool get isForum => channelType == 'forum'; bool get isDm => channelType == 'dm'; bool get isPrivate => visibility == 'private'; + + Channel copyWith({DateTime? lastMessageAt}) => Channel( + id: id, + name: name, + channelType: channelType, + visibility: visibility, + description: description, + topic: topic, + purpose: purpose, + createdBy: createdBy, + createdAt: createdAt, + memberCount: memberCount, + lastMessageAt: lastMessageAt ?? this.lastMessageAt, + isMember: isMember, + ttlSeconds: ttlSeconds, + ttlDeadline: ttlDeadline, + ); } diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart new file mode 100644 index 00000000000..4ddd1bd52f7 --- /dev/null +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -0,0 +1,411 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/relay/relay.dart'; +import '../../shared/theme/theme.dart'; +import '../profile/user_cache_provider.dart'; +import '../profile/user_profile.dart'; +import 'channel.dart'; +import 'channel_messages_provider.dart'; +import 'channel_typing_provider.dart'; +import 'send_message_provider.dart'; + +/// Fetch channel members and preload their profiles into the user cache. +Future _preloadMembers(WidgetRef ref, String channelId) async { + try { + final client = ref.read(relayClientProvider); + final json = + await client.get('/api/channels/$channelId/members') + as Map; + final members = json['members'] as List? ?? []; + final pubkeys = members + .map((m) => (m as Map)['pubkey'] as String) + .toList(); + if (pubkeys.isNotEmpty) { + ref.read(userCacheProvider.notifier).preload(pubkeys); + } + } catch (_) { + // Non-fatal — mentions will just fall back to cache from messages. + } +} + +class ChannelDetailPage extends HookConsumerWidget { + final Channel channel; + + const ChannelDetailPage({super.key, required this.channel}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final messagesState = ref.watch(channelMessagesProvider(channel.id)); + final typingEntries = ref.watch(channelTypingProvider(channel.id)); + + // Preload channel member profiles so @mentions resolve correctly. + useEffect(() { + _preloadMembers(ref, channel.id); + return null; + }, [channel.id]); + + return Scaffold( + appBar: AppBar( + title: Row( + children: [ + Icon( + channel.isPrivate ? LucideIcons.lock : LucideIcons.hash, + size: 18, + color: context.colors.onSurfaceVariant, + ), + const SizedBox(width: Grid.half), + Expanded( + child: Text(channel.name, overflow: TextOverflow.ellipsis), + ), + ], + ), + ), + body: Column( + children: [ + Expanded( + child: messagesState.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Text( + 'Failed to load messages', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.error, + ), + ), + ), + data: (messages) => + _MessageList(messages: messages, channelId: channel.id), + ), + ), + if (typingEntries.isNotEmpty) + _TypingIndicator(entries: typingEntries), + _ComposeBar(channelId: channel.id), + ], + ), + ); + } +} + +class _MessageList extends ConsumerWidget { + final List messages; + final String channelId; + + const _MessageList({required this.messages, required this.channelId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (messages.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.messageSquare, + size: Grid.xl, + color: context.colors.outline, + ), + const SizedBox(height: Grid.xxs), + Text( + 'No messages yet', + style: context.textTheme.bodyLarge?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Grid.half), + Text( + 'Be the first to say something!', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.outline, + ), + ), + ], + ), + ); + } + + // Filter to only renderable message events for display and grouping. + final displayMessages = messages + .where( + (e) => + e.kind == EventKind.streamMessage || + e.kind == EventKind.streamMessageV2, + ) + .toList(); + + return ListView.builder( + reverse: true, + padding: const EdgeInsets.symmetric( + horizontal: Grid.xs, + vertical: Grid.xxs, + ), + itemCount: displayMessages.length, + itemBuilder: (context, index) { + // Reversed list: index 0 = newest (bottom of screen). + final chronIdx = displayMessages.length - 1 - index; + final message = displayMessages[chronIdx]; + // The message visually above is the one earlier in time. + final prevMessage = chronIdx > 0 ? displayMessages[chronIdx - 1] : null; + + final showAuthor = + prevMessage == null || + prevMessage.pubkey.toLowerCase() != message.pubkey.toLowerCase() || + (message.createdAt - prevMessage.createdAt) > 300; + + return _MessageBubble(event: message, showAuthor: showAuthor); + }, + ); + } +} + +class _MessageBubble extends ConsumerWidget { + final NostrEvent event; + final bool showAuthor; + + const _MessageBubble({required this.event, required this.showAuthor}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Look up the user profile for display name. + final userCache = ref.watch(userCacheProvider); + final profile = + userCache[event.pubkey.toLowerCase()] ?? + ref.read(userCacheProvider.notifier).get(event.pubkey.toLowerCase()); + final displayName = profile?.label ?? _shortPubkey(event.pubkey); + + return Padding( + padding: EdgeInsets.only(top: showAuthor ? Grid.xxs : Grid.quarter), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + _UserAvatar(profile: profile, pubkey: event.pubkey) + else + const SizedBox(width: 28), + const SizedBox(width: Grid.xxs), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + Padding( + padding: const EdgeInsets.only(bottom: Grid.quarter), + child: Row( + children: [ + Text( + displayName, + style: context.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w600, + color: context.colors.onSurface, + ), + ), + const SizedBox(width: Grid.xxs), + Text( + _formatTime(event.createdAt), + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.outline, + ), + ), + ], + ), + ), + Text( + event.content, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurface, + ), + ), + ], + ), + ), + ], + ), + ); + } + + static String _shortPubkey(String pubkey) { + if (pubkey.length > 12) { + return '${pubkey.substring(0, 8)}…'; + } + return pubkey; + } + + static String _formatTime(int createdAt) { + final dt = DateTime.fromMillisecondsSinceEpoch( + createdAt * 1000, + isUtc: true, + ).toLocal(); + final now = DateTime.now(); + final diff = now.difference(dt); + + if (diff.inDays > 0) { + return '${dt.month}/${dt.day} ${_pad(dt.hour)}:${_pad(dt.minute)}'; + } + return '${_pad(dt.hour)}:${_pad(dt.minute)}'; + } + + static String _pad(int n) => n.toString().padLeft(2, '0'); +} + +class _UserAvatar extends StatelessWidget { + final UserProfile? profile; + final String pubkey; + + const _UserAvatar({required this.profile, required this.pubkey}); + + @override + Widget build(BuildContext context) { + final initial = + profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); + final avatarUrl = profile?.avatarUrl; + + return CircleAvatar( + radius: 14, + backgroundColor: context.colors.primaryContainer, + backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, + child: avatarUrl == null + ? Text( + initial, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ) + : null, + ); + } +} + +class _TypingIndicator extends ConsumerWidget { + final List entries; + + const _TypingIndicator({required this.entries}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final userCache = ref.watch(userCacheProvider); + final names = entries.map((e) { + final profile = + userCache[e.pubkey.toLowerCase()] ?? + ref.read(userCacheProvider.notifier).get(e.pubkey.toLowerCase()); + return profile?.label ?? _shortPubkey(e.pubkey); + }).toList(); + final text = switch (names.length) { + 1 => '${names[0]} is typing…', + 2 => '${names[0]} and ${names[1]} are typing…', + _ => '${names[0]} and ${names.length - 1} others are typing…', + }; + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: Grid.xs, + vertical: Grid.quarter + 2, + ), + child: Text( + text, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.outline, + fontStyle: FontStyle.italic, + ), + ), + ); + } + + static String _shortPubkey(String pubkey) { + if (pubkey.length > 12) return '${pubkey.substring(0, 8)}…'; + return pubkey; + } +} + +class _ComposeBar extends HookConsumerWidget { + final String channelId; + + const _ComposeBar({required this.channelId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = useTextEditingController(); + final isSending = useState(false); + + Future send() async { + final text = controller.text.trim(); + if (text.isEmpty || isSending.value) return; + + isSending.value = true; + try { + await ref + .read(sendMessageProvider) + .call(channelId: channelId, content: text); + controller.clear(); + } finally { + isSending.value = false; + } + } + + return Container( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: context.colors.outlineVariant)), + color: context.colors.surface, + ), + padding: EdgeInsets.only( + left: Grid.xs, + right: Grid.xxs, + top: Grid.xxs, + bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs, + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: controller, + textInputAction: TextInputAction.send, + onSubmitted: (_) => send(), + minLines: 1, + maxLines: 5, + decoration: InputDecoration( + hintText: 'Message…', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(Radii.lg), + borderSide: BorderSide(color: context.colors.outlineVariant), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(Radii.lg), + borderSide: BorderSide(color: context.colors.outlineVariant), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(Radii.lg), + borderSide: BorderSide(color: context.colors.primary), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: Grid.twelve, + vertical: Grid.xxs, + ), + isDense: true, + ), + ), + ), + const SizedBox(width: Grid.half), + IconButton( + onPressed: isSending.value ? null : send, + icon: isSending.value + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: context.colors.primary, + ), + ) + : Icon( + LucideIcons.sendHorizontal, + color: context.colors.primary, + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/features/channels/channel_messages_provider.dart b/mobile/lib/features/channels/channel_messages_provider.dart new file mode 100644 index 00000000000..ac9e1da4aff --- /dev/null +++ b/mobile/lib/features/channels/channel_messages_provider.dart @@ -0,0 +1,114 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/relay/relay.dart'; + +/// Provides the message list for a specific channel. Fetches history on init, +/// then subscribes to live events via the websocket session. +class ChannelMessagesNotifier extends Notifier>> { + final String channelId; + void Function()? _unsubscribe; + + ChannelMessagesNotifier(this.channelId); + + @override + AsyncValue> build() { + final sessionState = ref.watch(relaySessionProvider); + ref.onDispose(() { + _unsubscribe?.call(); + _unsubscribe = null; + }); + + if (sessionState.status != SessionStatus.connected) { + return const AsyncData([]); + } + + _init(); + return const AsyncLoading(); + } + + Future _init() async { + try { + final session = ref.read(relaySessionProvider.notifier); + + // 1. Fetch recent history via REQ/EOSE. + final history = await session.fetchHistory( + NostrFilter( + kinds: EventKind.channelEventKinds, + tags: { + '#h': [channelId], + }, + limit: 50, + ), + ); + + // 2. Subscribe to live events for this channel. + _unsubscribe = await session.subscribe( + NostrFilter( + kinds: EventKind.channelEventKinds, + tags: { + '#h': [channelId], + }, + limit: 0, + ), + _handleLiveEvent, + ); + + history.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + state = AsyncData(history); + } catch (e, st) { + state = AsyncError(e, st); + } + } + + void _handleLiveEvent(NostrEvent event) { + state = state.whenData((events) => _mergeEvent(events, event)); + } + + /// Merge a new event into the sorted list, deduplicating by ID. + static List _mergeEvent( + List current, + NostrEvent incoming, + ) { + if (current.any((e) => e.id == incoming.id)) return current; + final updated = [...current, incoming]; + updated.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + return updated; + } + + /// Fetch older messages (pagination). Call this when the user scrolls up. + Future fetchOlder() async { + final currentEvents = state.value; + if (currentEvents == null || currentEvents.isEmpty) return; + + final oldest = currentEvents.first.createdAt; + final session = ref.read(relaySessionProvider.notifier); + + final older = await session.fetchHistory( + NostrFilter( + kinds: EventKind.channelEventKinds, + tags: { + '#h': [channelId], + }, + limit: 50, + until: oldest, + ), + ); + + if (older.isEmpty) return; + + state = state.whenData((events) { + final ids = events.map((e) => e.id).toSet(); + final deduped = older.where((e) => !ids.contains(e.id)).toList(); + final merged = [...deduped, ...events]; + merged.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + return merged; + }); + } +} + +final channelMessagesProvider = + NotifierProvider.family< + ChannelMessagesNotifier, + AsyncValue>, + String + >(ChannelMessagesNotifier.new); diff --git a/mobile/lib/features/channels/channel_typing_provider.dart b/mobile/lib/features/channels/channel_typing_provider.dart new file mode 100644 index 00000000000..73c7990ca2e --- /dev/null +++ b/mobile/lib/features/channels/channel_typing_provider.dart @@ -0,0 +1,113 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/relay/relay.dart'; + +/// A single typing indicator entry. +@immutable +class TypingEntry { + final String pubkey; + final String? threadHeadId; + final int expiresAtMs; + + const TypingEntry({ + required this.pubkey, + this.threadHeadId, + required this.expiresAtMs, + }); +} + +/// Tracks who is currently typing in a specific channel. +/// +/// Subscribes to kind:20002 (typing indicator) events via websocket. +/// Entries expire after 8 seconds (matching the desktop TTL). +class ChannelTypingNotifier extends Notifier> { + static const _ttlMs = 8000; + static const _pruneIntervalMs = 1000; + + final String channelId; + void Function()? _unsubscribe; + Timer? _pruneTimer; + + ChannelTypingNotifier(this.channelId); + + @override + List build() { + final sessionState = ref.watch(relaySessionProvider); + + ref.onDispose(() { + _unsubscribe?.call(); + _unsubscribe = null; + _pruneTimer?.cancel(); + _pruneTimer = null; + }); + + if (sessionState.status == SessionStatus.connected) { + _subscribeLive(); + } + + return []; + } + + void _subscribeLive() async { + final session = ref.read(relaySessionProvider.notifier); + _unsubscribe = await session.subscribe( + NostrFilter( + kinds: [EventKind.typingIndicator], + tags: { + '#h': [channelId], + }, + limit: 10, + ), + _handleTypingEvent, + ); + } + + void _handleTypingEvent(NostrEvent event) { + final now = DateTime.now().millisecondsSinceEpoch; + final entry = TypingEntry( + pubkey: event.pubkey, + threadHeadId: event.getTagValue('e'), + expiresAtMs: now + _ttlMs, + ); + + // Upsert: replace existing entry for same pubkey+thread, or add. + final updated = + state + .where( + (e) => + !(e.pubkey == entry.pubkey && + e.threadHeadId == entry.threadHeadId), + ) + .toList() + ..add(entry); + + state = updated; + _ensurePruneTimer(); + } + + void _ensurePruneTimer() { + _pruneTimer ??= Timer.periodic( + const Duration(milliseconds: _pruneIntervalMs), + (_) => _prune(), + ); + } + + void _prune() { + final now = DateTime.now().millisecondsSinceEpoch; + final pruned = state.where((e) => e.expiresAtMs > now).toList(); + state = pruned; + + if (pruned.isEmpty) { + _pruneTimer?.cancel(); + _pruneTimer = null; + } + } +} + +final channelTypingProvider = + NotifierProvider.family, String>( + ChannelTypingNotifier.new, + ); diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 560ebf3b4c5..d12ba2c72eb 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -1,45 +1,43 @@ -import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; -import '../../shared/relay/relay_client.dart'; +import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../profile/profile_avatar.dart'; import 'channel.dart'; +import 'channel_detail_page.dart'; import 'channels_provider.dart'; -class ChannelsPage extends HookConsumerWidget { +class ChannelsPage extends ConsumerWidget { const ChannelsPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final channelsAsync = ref.watch(channelsProvider); - - // Poll every 30s while this page is mounted, matching desktop's pattern. - useEffect(() { - final timer = Timer.periodic( - const Duration(seconds: 30), - (_) => ref.read(channelsProvider.notifier).refresh(), - ); - return timer.cancel; - }, const []); + final sessionState = ref.watch(relaySessionProvider); return Scaffold( appBar: AppBar( title: const Text('Sprout'), actions: const [ProfileAvatar()], ), - body: channelsAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => _ErrorView( - error: error, - onRetry: () => ref.read(channelsProvider.notifier).refresh(), - ), - data: (channels) => _ChannelsList(channels: channels), + body: Column( + children: [ + _ConnectionBanner(status: sessionState.status), + Expanded( + child: channelsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => _ErrorView( + error: error, + onRetry: () => ref.read(channelsProvider.notifier).refresh(), + ), + data: (channels) => _ChannelsList(channels: channels), + ), + ), + ], ), ); } @@ -148,7 +146,11 @@ class _ChannelTile extends StatelessWidget { return InkWell( borderRadius: BorderRadius.circular(Radii.md), onTap: () { - // TODO: navigate to channel + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChannelDetailPage(channel: channel), + ), + ); }, child: Padding( padding: const EdgeInsets.symmetric( @@ -279,6 +281,53 @@ class _EphemeralBadge extends StatelessWidget { } } +/// Slim banner shown when the websocket is reconnecting or disconnected. +class _ConnectionBanner extends StatelessWidget { + final SessionStatus status; + + const _ConnectionBanner({required this.status}); + + @override + Widget build(BuildContext context) { + if (status == SessionStatus.connected || + status == SessionStatus.disconnected) { + return const SizedBox.shrink(); + } + + final isConnecting = status == SessionStatus.connecting; + final message = isConnecting ? 'Connecting…' : 'Reconnecting…'; + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: Grid.xs, + vertical: Grid.quarter + 2, + ), + color: context.colors.surfaceContainerHighest, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 2, + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(width: Grid.xxs), + Text( + message, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} + class _ErrorView extends StatelessWidget { final Object error; final VoidCallback onRetry; diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 164535bd3d6..c6804e1aef0 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -4,11 +4,28 @@ import '../../shared/relay/relay.dart'; import 'channel.dart'; class ChannelsNotifier extends AsyncNotifier> { + void Function()? _unsubscribe; + @override Future> build() { - // Watch relayClientProvider here so we auto-refetch when config changes. ref.watch(relayClientProvider); - return _fetch(); + final sessionState = ref.watch(relaySessionProvider); + + ref.onDispose(() { + _unsubscribe?.call(); + _unsubscribe = null; + }); + + // Initial fetch via HTTP (reliable, paginated). + final fetchFuture = _fetch(); + + // If websocket is connected, subscribe to live channel events to keep + // the list up to date without polling. + if (sessionState.status == SessionStatus.connected) { + _subscribeLive(); + } + + return fetchFuture; } Future> _fetch() async { @@ -23,6 +40,40 @@ class ChannelsNotifier extends AsyncNotifier> { return channels; } + void _subscribeLive() async { + final session = ref.read(relaySessionProvider.notifier); + _unsubscribe = await session.subscribe( + NostrFilter(kinds: EventKind.channelEventKinds, limit: 0), + _handleLiveEvent, + ); + } + + void _handleLiveEvent(NostrEvent event) { + final channelId = event.channelId; + if (channelId == null) return; + + state = state.whenData((channels) { + final idx = channels.indexWhere((c) => c.id == channelId); + if (idx == -1) { + // Unknown channel — queue a full refresh to pick it up. + refresh(); + return channels; + } + // Update lastMessageAt for the affected channel. + final updated = List.of(channels); + final channel = updated[idx]; + final eventTime = DateTime.fromMillisecondsSinceEpoch( + event.createdAt * 1000, + isUtc: true, + ); + if (channel.lastMessageAt == null || + eventTime.isAfter(channel.lastMessageAt!)) { + updated[idx] = channel.copyWith(lastMessageAt: eventTime); + } + return updated; + }); + } + Future refresh() async { state = await AsyncValue.guard(_fetch); } diff --git a/mobile/lib/features/channels/send_message_provider.dart b/mobile/lib/features/channels/send_message_provider.dart new file mode 100644 index 00000000000..12caeac63c0 --- /dev/null +++ b/mobile/lib/features/channels/send_message_provider.dart @@ -0,0 +1,114 @@ +import 'dart:convert'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../../shared/relay/relay.dart'; +import '../profile/user_cache_provider.dart'; +import '../profile/user_profile.dart'; + +/// Sends messages via the relay HTTP API. The event is signed on the client +/// with the user's nsec, then POSTed as a full signed Nostr event — matching +/// what the desktop does via `submit_event`. +class SendMessage { + final RelayClient _client; + final String? _nsec; + final Map Function() _readUserCache; + + SendMessage({ + required RelayClient client, + required String? nsec, + required Map Function() readUserCache, + }) : _client = client, + _nsec = nsec, + _readUserCache = readUserCache; + + /// Send a text message to a channel. + Future call({ + required String channelId, + required String content, + String? parentEventId, + List? mentionPubkeys, + }) async { + final nsec = _nsec; + if (nsec == null || nsec.isEmpty) { + throw Exception('Cannot send messages: no signing key available'); + } + + // Decode bech32 nsec to hex private key. + final privkeyHex = nostr.Nip19.decodePrivkey(nsec); + if (privkeyHex.isEmpty) { + throw Exception('Invalid nsec'); + } + + // Resolve @mentions in the message content to pubkeys. + final resolvedMentions = mentionPubkeys ?? _resolveMentions(content); + + final tags = >[ + ['h', channelId], + if (parentEventId != null) ['e', parentEventId], + for (final pk in resolvedMentions) ['p', pk], + ]; + + // Create and sign the event using the nostr package. + final event = nostr.Event.from( + kind: EventKind.streamMessage, + content: content, + tags: tags, + privkey: privkeyHex, + verify: false, + ); + + // POST the full signed event JSON to the relay. + final response = await _client.postRaw( + '/api/events', + body: jsonEncode(event.toJson()), + ); + + final result = jsonDecode(response) as Map; + if (result['accepted'] != true) { + throw Exception(result['message'] ?? 'Event rejected by relay'); + } + } + + /// Parse @mentions from message content and resolve to pubkeys using + /// the user cache. Reads the cache at call time (not construction time) + /// to ensure freshly loaded profiles are available. + List _resolveMentions(String content) { + final mentionPattern = RegExp(r'@(\w+)'); + final matches = mentionPattern.allMatches(content); + final pubkeys = {}; + + // Read the current cache state at send time. + final cache = _readUserCache(); + + for (final match in matches) { + final name = match.group(1)?.toLowerCase(); + if (name == null || name.isEmpty) continue; + + for (final profile in cache.values) { + final displayName = profile.displayName?.toLowerCase(); + if (displayName == null) continue; + + // Match against full display name or first word of it. + final firstName = displayName.split(RegExp(r'\s+')).first; + if (displayName == name || firstName == name) { + pubkeys.add(profile.pubkey); + break; + } + } + } + + return pubkeys.toList(); + } +} + +final sendMessageProvider = Provider((ref) { + final client = ref.watch(relayClientProvider); + final config = ref.watch(relayConfigProvider); + return SendMessage( + client: client, + nsec: config.nsec, + readUserCache: () => ref.read(userCacheProvider), + ); +}); diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index e4f20be7a12..f639f07a981 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -109,6 +109,7 @@ class PairingNotifier extends Notifier { relayUrl: relayUrl, token: token, pubkey: decoded['pubkey'] as String?, + nsec: decoded['nsec'] as String?, ); } diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index a5c7ffc4feb..eaa0ad9d3af 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; @@ -33,14 +36,56 @@ final profileProvider = AsyncNotifierProvider( ); /// Presence status for the current user. +/// +/// Sends a heartbeat every 60s while the app is active. Watches +/// [appLifecycleProvider] to send "away" when backgrounded. class PresenceNotifier extends AsyncNotifier { + static const _heartbeatInterval = Duration(seconds: 60); + + Timer? _heartbeatTimer; + @override Future build() { ref.watch(relayClientProvider); ref.watch(profileProvider); + + final lifecycle = ref.watch(appLifecycleProvider); + + ref.onDispose(() { + _heartbeatTimer?.cancel(); + _heartbeatTimer = null; + }); + + if (lifecycle == AppLifecycleState.resumed) { + _startHeartbeat(); + return _setPresence('online'); + } else if (lifecycle == AppLifecycleState.paused || + lifecycle == AppLifecycleState.detached) { + _heartbeatTimer?.cancel(); + _heartbeatTimer = null; + return _setPresence('away'); + } + return _fetch(); } + void _startHeartbeat() { + _heartbeatTimer?.cancel(); + _heartbeatTimer = Timer.periodic(_heartbeatInterval, (_) { + _setPresence('online'); + }); + } + + Future _setPresence(String status) async { + final client = ref.read(relayClientProvider); + try { + await client.post('/api/presence', body: {'status': status}); + return status; + } catch (_) { + return state.value ?? 'offline'; + } + } + Future _fetch() async { final profile = ref.read(profileProvider).whenData((v) => v).value; if (profile == null) return 'offline'; diff --git a/mobile/lib/features/profile/user_cache_provider.dart b/mobile/lib/features/profile/user_cache_provider.dart new file mode 100644 index 00000000000..60d06598a17 --- /dev/null +++ b/mobile/lib/features/profile/user_cache_provider.dart @@ -0,0 +1,86 @@ +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/relay/relay.dart'; +import 'user_profile.dart'; + +/// In-memory cache of user profiles, fetched in batches from the relay. +class UserCacheNotifier extends Notifier> { + final Set _pending = {}; + Timer? _batchTimer; + + @override + Map build() { + ref.watch(relayClientProvider); + ref.onDispose(() { + _batchTimer?.cancel(); + _batchTimer = null; + }); + return {}; + } + + /// Request a profile for [pubkey]. Returns immediately from cache if + /// available, otherwise schedules a batch fetch. + UserProfile? get(String pubkey) { + final cached = state[pubkey]; + if (cached != null) return cached; + _scheduleFetch(pubkey); + return null; + } + + /// Preload profiles for a list of pubkeys (e.g. channel members). + void preload(List pubkeys) { + final uncached = pubkeys + .map((pk) => pk.toLowerCase()) + .where((pk) => !state.containsKey(pk) && !_pending.contains(pk)) + .toList(); + if (uncached.isEmpty) return; + _pending.addAll(uncached); + _batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending); + } + + void _scheduleFetch(String pubkey) { + if (_pending.contains(pubkey)) return; + _pending.add(pubkey); + + // Batch: wait 50ms to collect multiple lookups into one request. + _batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending); + } + + Future _flushPending() async { + _batchTimer = null; + if (_pending.isEmpty) return; + + final pubkeys = _pending.toList(); + _pending.clear(); + + try { + final client = ref.read(relayClientProvider); + final json = + await client.post('/api/users/batch', body: {'pubkeys': pubkeys}) + as Map; + + final profiles = json['profiles'] as Map? ?? {}; + final updated = Map.from(state); + + for (final entry in profiles.entries) { + final data = entry.value as Map; + updated[entry.key.toLowerCase()] = UserProfile( + pubkey: entry.key.toLowerCase(), + displayName: data['display_name'] as String?, + avatarUrl: data['avatar_url'] as String?, + ); + } + + state = updated; + } catch (_) { + // Silently fail — we'll just show pubkeys. + } + } +} + +final userCacheProvider = + NotifierProvider>( + UserCacheNotifier.new, + ); diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 9bc532437da..3602afa19ac 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -132,7 +132,7 @@ class SettingsPage extends HookConsumerWidget { ref.read(authProvider.notifier).signOut(); }, style: FilledButton.styleFrom( - backgroundColor: context.colors.error, + backgroundColor: Theme.of(ctx).colorScheme.error, ), child: const Text('Sign Out'), ), diff --git a/mobile/lib/shared/auth/auth_provider.dart b/mobile/lib/shared/auth/auth_provider.dart index a9bb9ccdeee..1adbfdb146d 100644 --- a/mobile/lib/shared/auth/auth_provider.dart +++ b/mobile/lib/shared/auth/auth_provider.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../relay/relay.dart'; @@ -16,12 +15,6 @@ class AuthState { class AuthNotifier extends AsyncNotifier { @override Future build() async { - // If a token is provided via dart-define in debug mode, treat as - // pre-authenticated. - if (kDebugMode && Env.apiToken.isNotEmpty) { - return const AuthState(status: AuthStatus.authenticated); - } - final storage = CredentialStorage(); final creds = await storage.load(); if (creds == null) { @@ -39,6 +32,7 @@ class AuthNotifier extends AsyncNotifier { baseUrl: creds.relayUrl, apiToken: creds.token, devPubkey: null, + nsec: creds.nsec, ); return AuthState(status: AuthStatus.authenticated, credentials: creds); } on RelayException { @@ -69,6 +63,7 @@ class AuthNotifier extends AsyncNotifier { baseUrl: creds.relayUrl, apiToken: creds.token, devPubkey: null, + nsec: creds.nsec, ); state = AsyncData( diff --git a/mobile/lib/shared/auth/credential_storage.dart b/mobile/lib/shared/auth/credential_storage.dart index 35a5a160ce8..4ff324f19a3 100644 --- a/mobile/lib/shared/auth/credential_storage.dart +++ b/mobile/lib/shared/auth/credential_storage.dart @@ -4,11 +4,13 @@ class StoredCredentials { final String relayUrl; final String token; final String? pubkey; + final String? nsec; const StoredCredentials({ required this.relayUrl, required this.token, this.pubkey, + this.nsec, }); } @@ -16,6 +18,7 @@ class CredentialStorage { static const _keyRelayUrl = 'sprout_relay_url'; static const _keyToken = 'sprout_token'; static const _keyPubkey = 'sprout_pubkey'; + static const _keyNsec = 'sprout_nsec'; final FlutterSecureStorage _storage; @@ -28,7 +31,13 @@ class CredentialStorage { if (relayUrl == null || token == null) return null; final pubkey = await _storage.read(key: _keyPubkey); - return StoredCredentials(relayUrl: relayUrl, token: token, pubkey: pubkey); + final nsec = await _storage.read(key: _keyNsec); + return StoredCredentials( + relayUrl: relayUrl, + token: token, + pubkey: pubkey, + nsec: nsec, + ); } Future save(StoredCredentials credentials) async { @@ -37,11 +46,15 @@ class CredentialStorage { if (credentials.pubkey != null) { await _storage.write(key: _keyPubkey, value: credentials.pubkey); } + if (credentials.nsec != null) { + await _storage.write(key: _keyNsec, value: credentials.nsec); + } } Future clear() async { await _storage.delete(key: _keyRelayUrl); await _storage.delete(key: _keyToken); await _storage.delete(key: _keyPubkey); + await _storage.delete(key: _keyNsec); } } diff --git a/mobile/lib/shared/relay/app_lifecycle_provider.dart b/mobile/lib/shared/relay/app_lifecycle_provider.dart new file mode 100644 index 00000000000..18e3eaebdf6 --- /dev/null +++ b/mobile/lib/shared/relay/app_lifecycle_provider.dart @@ -0,0 +1,55 @@ +import 'dart:async'; + +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'relay_session.dart'; + +/// Tracks the app lifecycle state and drives websocket connect/disconnect +/// behavior for mobile battery efficiency. +class AppLifecycleNotifier extends Notifier { + AppLifecycleListener? _listener; + StreamSubscription>? _connectivitySub; + + @override + AppLifecycleState build() { + _listener = AppLifecycleListener(onStateChange: _onStateChange); + + // Watch connectivity changes to trigger reconnect on network restore. + _connectivitySub = Connectivity().onConnectivityChanged.listen((results) { + final hasNetwork = results.any((r) => r != ConnectivityResult.none); + if (hasNetwork && state == AppLifecycleState.resumed) { + ref.read(relaySessionProvider.notifier).onAppResumed(); + } + }); + + ref.onDispose(() { + _listener?.dispose(); + _connectivitySub?.cancel(); + }); + + return AppLifecycleState.resumed; + } + + void _onStateChange(AppLifecycleState newState) { + state = newState; + + final session = ref.read(relaySessionProvider.notifier); + switch (newState) { + case AppLifecycleState.resumed: + session.onAppResumed(); + case AppLifecycleState.paused: + case AppLifecycleState.detached: + session.onAppPaused(); + case AppLifecycleState.inactive: + case AppLifecycleState.hidden: + break; // Brief transition states — no action. + } + } +} + +final appLifecycleProvider = + NotifierProvider( + AppLifecycleNotifier.new, + ); diff --git a/mobile/lib/shared/relay/nostr_models.dart b/mobile/lib/shared/relay/nostr_models.dart new file mode 100644 index 00000000000..ab501a6ebe0 --- /dev/null +++ b/mobile/lib/shared/relay/nostr_models.dart @@ -0,0 +1,138 @@ +import 'package:flutter/foundation.dart'; + +/// Nostr event kind constants. +/// +/// Keep in sync with `desktop/src/shared/constants/kinds.ts`. +abstract final class EventKind { + static const deletion = 5; + static const reaction = 7; + static const streamMessage = 9; + static const typingIndicator = 20002; + static const auth = 22242; + static const streamMessageV2 = 40002; + static const streamMessageEdit = 40003; + static const streamMessageDiff = 40008; + static const systemMessage = 40099; + static const forumPost = 45001; + static const forumComment = 45003; + + /// Event kinds that represent channel activity (messages, edits, reactions, + /// deletions, system events). Matches the desktop's `CHANNEL_EVENT_KINDS`. + static const channelEventKinds = [ + deletion, // 5 + reaction, // 7 + streamMessage, // 9 + 40001, // legacy pre-migration stream messages + streamMessageEdit, // 40003 + streamMessageDiff, // 40008 + systemMessage, // 40099 + ]; +} + +/// A Nostr event as defined by NIP-01. +@immutable +class NostrEvent { + final String id; + final String pubkey; + final int createdAt; + final int kind; + final List> tags; + final String content; + final String sig; + + const NostrEvent({ + required this.id, + required this.pubkey, + required this.createdAt, + required this.kind, + required this.tags, + required this.content, + required this.sig, + }); + + factory NostrEvent.fromJson(Map json) { + return NostrEvent( + id: json['id'] as String, + pubkey: json['pubkey'] as String, + createdAt: json['created_at'] as int, + kind: json['kind'] as int, + tags: (json['tags'] as List) + .map((t) => (t as List).map((e) => e as String).toList()) + .toList(), + content: json['content'] as String, + sig: json['sig'] as String, + ); + } + + Map toJson() => { + 'id': id, + 'pubkey': pubkey, + 'created_at': createdAt, + 'kind': kind, + 'tags': tags, + 'content': content, + 'sig': sig, + }; + + /// Get the first value for a given tag key. + String? getTagValue(String key) { + for (final tag in tags) { + if (tag.isNotEmpty && tag[0] == key && tag.length > 1) { + return tag[1]; + } + } + return null; + } + + /// The channel/group ID from the `h` tag (NIP-29). + String? get channelId => getTagValue('h'); + + /// The parent event ID from the `e` tag. + String? get parentEventId => getTagValue('e'); + + @override + bool operator ==(Object other) => + identical(this, other) || other is NostrEvent && id == other.id; + + @override + int get hashCode => id.hashCode; +} + +/// A NIP-01 subscription filter. +@immutable +class NostrFilter { + final List kinds; + final int limit; + final int? since; + final int? until; + + /// Tag filters, e.g. `{'#h': ['channel-id']}`. + final Map> tags; + + const NostrFilter({ + required this.kinds, + this.limit = 100, + this.since, + this.until, + this.tags = const {}, + }); + + /// Return a copy with an updated `since` value. + NostrFilter copyWithSince(int since) => NostrFilter( + kinds: kinds, + limit: limit, + since: since, + until: until, + tags: tags, + ); + + Map toJson() { + final json = {'kinds': kinds, 'limit': limit}; + if (since != null) json['since'] = since; + if (until != null) json['until'] = until; + for (final entry in tags.entries) { + json[entry.key] = entry.value; + } + return json; + } +} diff --git a/mobile/lib/shared/relay/relay.dart b/mobile/lib/shared/relay/relay.dart index 316474c29ad..d017071f1ff 100644 --- a/mobile/lib/shared/relay/relay.dart +++ b/mobile/lib/shared/relay/relay.dart @@ -1,2 +1,6 @@ +export 'app_lifecycle_provider.dart'; +export 'nostr_models.dart'; export 'relay_client.dart'; export 'relay_provider.dart'; +export 'relay_session.dart'; +export 'relay_socket.dart'; diff --git a/mobile/lib/shared/relay/relay_client.dart b/mobile/lib/shared/relay/relay_client.dart index 7fa83ae85c1..a9b86b063c3 100644 --- a/mobile/lib/shared/relay/relay_client.dart +++ b/mobile/lib/shared/relay/relay_client.dart @@ -63,6 +63,19 @@ class RelayClient { return jsonDecode(response.body); } + /// POST [path] with a pre-encoded string body. Returns the raw response body. + Future postRaw(String path, {required String body}) async { + final response = await _http.post( + _uri(path), + headers: _headers, + body: body, + ); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw RelayException(response.statusCode, response.body); + } + return response.body; + } + void dispose() => _http.close(); } diff --git a/mobile/lib/shared/relay/relay_provider.dart b/mobile/lib/shared/relay/relay_provider.dart index 7676ae3d709..fa1ff12713b 100644 --- a/mobile/lib/shared/relay/relay_provider.dart +++ b/mobile/lib/shared/relay/relay_provider.dart @@ -11,7 +11,22 @@ class RelayConfig { /// Used when the relay has `SPROUT_REQUIRE_AUTH_TOKEN=false`. final String? devPubkey; - const RelayConfig({required this.baseUrl, this.apiToken, this.devPubkey}); + /// Nostr secret key (bech32 nsec) for signing NIP-42 AUTH events. + final String? nsec; + + const RelayConfig({ + required this.baseUrl, + this.apiToken, + this.devPubkey, + this.nsec, + }); + + /// Derive the websocket URL from the HTTP base URL. + String get wsUrl { + final uri = Uri.parse(baseUrl); + final scheme = uri.scheme == 'https' ? 'wss' : 'ws'; + return uri.replace(scheme: scheme).toString(); + } } /// Compile-time environment config via --dart-define. @@ -44,11 +59,13 @@ class RelayConfigNotifier extends Notifier { required String baseUrl, required String? apiToken, required String? devPubkey, + String? nsec, }) { state = RelayConfig( baseUrl: baseUrl, apiToken: apiToken, devPubkey: devPubkey, + nsec: nsec, ); } } diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart new file mode 100644 index 00000000000..d3a5e786e70 --- /dev/null +++ b/mobile/lib/shared/relay/relay_session.dart @@ -0,0 +1,496 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../auth/auth.dart'; +import 'nostr_models.dart'; +import 'relay_provider.dart'; +import 'relay_socket.dart'; + +// --------------------------------------------------------------------------- +// Session state +// --------------------------------------------------------------------------- + +enum SessionStatus { disconnected, connecting, connected, reconnecting } + +@immutable +class SessionState { + final SessionStatus status; + final int reconnectAttempt; + + const SessionState({required this.status, this.reconnectAttempt = 0}); +} + +// --------------------------------------------------------------------------- +// Internal subscription types +// --------------------------------------------------------------------------- + +class _HistorySubscription { + final List events = []; + final Completer> completer; + final Timer timeout; + + _HistorySubscription({required this.completer, required this.timeout}); +} + +class _LiveSubscription { + final NostrFilter filter; + final void Function(NostrEvent) onEvent; + Completer? readyCompleter; + int? lastSeenCreatedAt; + + _LiveSubscription({ + required this.filter, + required this.onEvent, + this.readyCompleter, + }); +} + +class _PendingEvent { + final Completer completer; + final Timer timeout; + + _PendingEvent({required this.completer, required this.timeout}); +} + +class _BufferedEvent { + final String subId; + final NostrEvent event; + + _BufferedEvent(this.subId, this.event); +} + +// --------------------------------------------------------------------------- +// RelaySession — the core session manager +// --------------------------------------------------------------------------- + +/// Manages websocket subscriptions, event batching, reconnection with replay, +/// and pending event tracking. Equivalent to the desktop's RelayClientSession. +class RelaySessionNotifier extends Notifier { + static const _baseReconnectDelayMs = 1000; + static const _maxReconnectDelayMs = 30000; + static const _eventBatchMs = 16; + static const _reconnectReplaySkewSeconds = 5; + + RelaySocket? _socket; + final Map _historySubscriptions = {}; + final Map _liveSubscriptions = {}; + final Map _pendingEvents = {}; + final List<_BufferedEvent> _eventBuffer = []; + final Set _recentEventIds = {}; + Timer? _reconnectTimer; + Timer? _flushTimer; + Timer? _backgroundGraceTimer; + int _reconnectDelayMs = _baseReconnectDelayMs; + int _subIdCounter = 0; + bool _disposed = false; + + @override + SessionState build() { + final config = ref.watch(relayConfigProvider); + final authState = ref.watch(authProvider); + + ref.onDispose(_dispose); + + // Auto-connect when authenticated and we have credentials. + final isAuthenticated = authState.value?.status == AuthStatus.authenticated; + if (isAuthenticated && config.apiToken != null) { + // Schedule connection after build completes. + Future.microtask(() => _connect(config)); + } + + return const SessionState(status: SessionStatus.disconnected); + } + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + /// Fetch historical events matching [filter]. Sends REQ, collects events + /// until EOSE, then resolves. One-shot subscription. + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) { + final subId = _nextSubId('h'); + final completer = Completer>(); + + final timer = Timer(timeout, () { + final sub = _historySubscriptions.remove(subId); + if (sub != null && !sub.completer.isCompleted) { + // Resolve with whatever we collected so far rather than failing. + sub.completer.complete(sub.events); + } + _sendClose(subId); + }); + + _historySubscriptions[subId] = _HistorySubscription( + completer: completer, + timeout: timer, + ); + + _sendReq(subId, filter); + return completer.future; + } + + /// Subscribe to live events matching [filter]. Returns an unsubscribe + /// function. Live subscriptions survive reconnects — they are replayed with + /// `since: lastSeenCreatedAt - 5s` on reconnect. + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, + ) async { + final subId = _nextSubId('l'); + final readyCompleter = Completer(); + + _liveSubscriptions[subId] = _LiveSubscription( + filter: filter, + onEvent: onEvent, + readyCompleter: readyCompleter, + ); + + _sendReq(subId, filter); + + // Wait for EOSE or a short fallback timeout. + await readyCompleter.future.timeout( + const Duration(milliseconds: 500), + onTimeout: () {}, + ); + + return () => _unsubscribe(subId); + } + + /// Publish an event and wait for the relay's OK confirmation. + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) { + final completer = Completer(); + + final timer = Timer(timeout, () { + final pending = _pendingEvents.remove(event.id); + if (pending != null && !pending.completer.isCompleted) { + pending.completer.completeError( + TimeoutException( + 'Event ${event.id} not acknowledged within $timeout', + ), + ); + } + }); + + _pendingEvents[event.id] = _PendingEvent( + completer: completer, + timeout: timer, + ); + + _socket?.send(['EVENT', event.toJson()]); + return completer.future; + } + + /// Force a reconnect (e.g., returning from background). + Future reconnect() async { + await _socket?.disconnect(); + _reconnectDelayMs = _baseReconnectDelayMs; + final config = ref.read(relayConfigProvider); + await _connect(config); + } + + /// Called by the app lifecycle provider when the app goes to background. + void onAppPaused() { + _backgroundGraceTimer?.cancel(); + _backgroundGraceTimer = Timer(const Duration(seconds: 30), () { + _socket?.disconnect(); + state = const SessionState(status: SessionStatus.disconnected); + }); + } + + /// Called by the app lifecycle provider when the app returns to foreground. + void onAppResumed() { + _backgroundGraceTimer?.cancel(); + _backgroundGraceTimer = null; + if (state.status == SessionStatus.disconnected) { + _reconnectDelayMs = _baseReconnectDelayMs; + final config = ref.read(relayConfigProvider); + _connect(config); + } + } + + // ------------------------------------------------------------------------- + // Connection management + // ------------------------------------------------------------------------- + + Future _connect(RelayConfig config) async { + if (_disposed) return; + if (_socket?.state == SocketState.connecting || + _socket?.state == SocketState.authenticating) { + return; + } + + state = SessionState( + status: SessionStatus.connecting, + reconnectAttempt: state.reconnectAttempt, + ); + + _socket?.dispose(); + _socket = RelaySocket( + wsUrl: config.wsUrl, + nsec: config.nsec, + apiToken: config.apiToken, + onMessage: _handleMessage, + onConnected: _handleConnected, + onDisconnected: _handleDisconnected, + ); + + await _socket!.connect(); + } + + void _handleConnected() { + if (_disposed) return; + _reconnectDelayMs = _baseReconnectDelayMs; + state = const SessionState(status: SessionStatus.connected); + _replayLiveSubscriptions(); + } + + void _handleDisconnected(Object? error) { + if (_disposed) return; + _cancelAllHistory(error); + _rejectAllPending(error); + _eventBuffer.clear(); + _flushTimer?.cancel(); + _flushTimer = null; + _scheduleReconnect(); + } + + void _scheduleReconnect() { + if (_disposed) return; + if (_liveSubscriptions.isEmpty) { + state = const SessionState(status: SessionStatus.disconnected); + return; + } + + final attempt = state.reconnectAttempt + 1; + state = SessionState( + status: SessionStatus.reconnecting, + reconnectAttempt: attempt, + ); + + _reconnectTimer?.cancel(); + _reconnectTimer = Timer(Duration(milliseconds: _reconnectDelayMs), () { + _reconnectDelayMs = min(_reconnectDelayMs * 2, _maxReconnectDelayMs); + final config = ref.read(relayConfigProvider); + _connect(config); + }); + } + + /// Replay all live subscriptions after a reconnect, with a time skew to + /// catch events that occurred during the disconnect. + void _replayLiveSubscriptions() { + for (final entry in _liveSubscriptions.entries) { + final sub = entry.value; + final since = sub.lastSeenCreatedAt != null + ? sub.lastSeenCreatedAt! - _reconnectReplaySkewSeconds + : null; + final filter = since != null + ? sub.filter.copyWithSince(since) + : sub.filter; + _sendReq(entry.key, filter); + } + } + + // ------------------------------------------------------------------------- + // Message dispatch + // ------------------------------------------------------------------------- + + void _handleMessage(List data) { + if (data.isEmpty) return; + final type = data[0] as String; + + switch (type) { + case 'EVENT': + _handleEvent(data); + case 'EOSE': + _handleEose(data); + case 'OK': + _handleOk(data); + } + } + + void _handleEvent(List data) { + if (data.length < 3) return; + final subId = data[1] as String; + final eventJson = data[2] as Map; + final event = NostrEvent.fromJson(eventJson); + + // History subscriptions accumulate immediately. + final historySub = _historySubscriptions[subId]; + if (historySub != null) { + historySub.events.add(event); + return; + } + + // Live subscriptions get batched. + final liveSub = _liveSubscriptions[subId]; + if (liveSub != null) { + // Track last seen timestamp for reconnect replay. + if (liveSub.lastSeenCreatedAt == null || + event.createdAt > liveSub.lastSeenCreatedAt!) { + liveSub.lastSeenCreatedAt = event.createdAt; + } + _eventBuffer.add(_BufferedEvent(subId, event)); + _scheduleFlush(); + } + } + + void _handleEose(List data) { + if (data.length < 2) return; + final subId = data[1] as String; + + // History subscription: resolve with collected events. + final historySub = _historySubscriptions.remove(subId); + if (historySub != null) { + historySub.timeout.cancel(); + if (!historySub.completer.isCompleted) { + historySub.completer.complete(historySub.events); + } + _sendClose(subId); + return; + } + + // Live subscription: signal ready. + final liveSub = _liveSubscriptions[subId]; + if (liveSub != null && + liveSub.readyCompleter != null && + !liveSub.readyCompleter!.isCompleted) { + liveSub.readyCompleter!.complete(); + liveSub.readyCompleter = null; + } + } + + void _handleOk(List data) { + if (data.length < 3) return; + final eventId = data[1] as String; + final accepted = data[2] as bool; + + final pending = _pendingEvents.remove(eventId); + if (pending == null) return; + pending.timeout.cancel(); + + if (accepted) { + // We don't have the full event here; create a minimal placeholder. + // The caller typically already has the event they published. + if (!pending.completer.isCompleted) { + pending.completer.complete( + NostrEvent( + id: eventId, + pubkey: '', + createdAt: 0, + kind: 0, + tags: [], + content: '', + sig: '', + ), + ); + } + } else { + final message = data.length > 3 ? data[3] as String : 'Event rejected'; + if (!pending.completer.isCompleted) { + pending.completer.completeError(Exception(message)); + } + } + } + + // ------------------------------------------------------------------------- + // Event batching (16ms flush interval) + // ------------------------------------------------------------------------- + + void _scheduleFlush() { + _flushTimer ??= Timer( + const Duration(milliseconds: _eventBatchMs), + _flushEventBuffer, + ); + } + + void _flushEventBuffer() { + _flushTimer = null; + if (_eventBuffer.isEmpty) return; + + final batch = List<_BufferedEvent>.from(_eventBuffer); + _eventBuffer.clear(); + + for (final buffered in batch) { + // Deduplication: skip events we've already seen recently. + if (_recentEventIds.contains(buffered.event.id)) continue; + _recentEventIds.add(buffered.event.id); + + // Cap the dedup set to prevent unbounded memory growth. + if (_recentEventIds.length > 5000) { + _recentEventIds.clear(); + } + + final sub = _liveSubscriptions[buffered.subId]; + if (sub != null) { + sub.onEvent(buffered.event); + } + } + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + String _nextSubId(String prefix) { + _subIdCounter++; + return '$prefix-$_subIdCounter'; + } + + void _sendReq(String subId, NostrFilter filter) { + _socket?.send(['REQ', subId, filter.toJson()]); + } + + void _sendClose(String subId) { + _socket?.send(['CLOSE', subId]); + } + + void _unsubscribe(String subId) { + _liveSubscriptions.remove(subId); + _sendClose(subId); + } + + void _cancelAllHistory(Object? error) { + for (final entry in _historySubscriptions.values) { + entry.timeout.cancel(); + if (!entry.completer.isCompleted) { + entry.completer.completeError(error ?? Exception('Connection lost')); + } + } + _historySubscriptions.clear(); + } + + void _rejectAllPending(Object? error) { + for (final entry in _pendingEvents.values) { + entry.timeout.cancel(); + if (!entry.completer.isCompleted) { + entry.completer.completeError(error ?? Exception('Connection lost')); + } + } + _pendingEvents.clear(); + } + + void _dispose() { + _disposed = true; + _reconnectTimer?.cancel(); + _flushTimer?.cancel(); + _backgroundGraceTimer?.cancel(); + _cancelAllHistory(null); + _rejectAllPending(null); + _socket?.dispose(); + _socket = null; + } +} + +final relaySessionProvider = + NotifierProvider( + RelaySessionNotifier.new, + ); diff --git a/mobile/lib/shared/relay/relay_socket.dart b/mobile/lib/shared/relay/relay_socket.dart new file mode 100644 index 00000000000..ecf2b7afa44 --- /dev/null +++ b/mobile/lib/shared/relay/relay_socket.dart @@ -0,0 +1,230 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:nostr/nostr.dart' as nostr; +import 'package:web_socket_channel/web_socket_channel.dart'; + +import 'nostr_models.dart'; + +/// Low-level websocket connection with NIP-42 authentication. +/// +/// Handles the raw websocket lifecycle: connect, authenticate via NIP-42 +/// challenge/response, send/receive JSON frames, and disconnect. +/// +/// Does NOT handle reconnection — that is [RelaySessionNotifier]'s job. +enum SocketState { disconnected, connecting, authenticating, connected } + +class RelaySocket { + final String _wsUrl; + final String? _nsec; + final String? _apiToken; + final void Function(List message) _onMessage; + final void Function() _onConnected; + final void Function(Object? error) _onDisconnected; + + WebSocketChannel? _channel; + StreamSubscription? _subscription; + SocketState _state = SocketState.disconnected; + Completer? _authCompleter; + Timer? _authTimeout; + String? _pendingAuthEventId; + + SocketState get state => _state; + + RelaySocket({ + required String wsUrl, + required String? nsec, + required String? apiToken, + required void Function(List message) onMessage, + required void Function() onConnected, + required void Function(Object? error) onDisconnected, + }) : _wsUrl = wsUrl, + _nsec = nsec, + _apiToken = apiToken, + _onMessage = onMessage, + _onConnected = onConnected, + _onDisconnected = onDisconnected; + + /// Connect to the relay and complete NIP-42 authentication. + Future connect() async { + if (_state != SocketState.disconnected) return; + _state = SocketState.connecting; + + try { + _channel = WebSocketChannel.connect(Uri.parse(_wsUrl)); + await _channel!.ready; + } catch (e) { + _state = SocketState.disconnected; + _onDisconnected(e); + return; + } + + _state = SocketState.authenticating; + _authCompleter = Completer(); + + _subscription = _channel!.stream.listen( + _handleRawMessage, + onError: (Object error) { + _failAuth(error); + _resetConnection(); + _onDisconnected(error); + }, + onDone: () { + _failAuth(null); + _resetConnection(); + _onDisconnected(null); + }, + ); + + // Wait for auth to complete (or timeout). + _authTimeout = Timer(const Duration(seconds: 8), () { + if (_authCompleter != null && !_authCompleter!.isCompleted) { + _authCompleter!.completeError( + TimeoutException('NIP-42 auth timed out after 8s'), + ); + } + }); + + try { + await _authCompleter!.future; + _authTimeout?.cancel(); + _state = SocketState.connected; + _onConnected(); + } catch (e) { + _authTimeout?.cancel(); + await disconnect(); + _onDisconnected(e); + } + } + + /// Send a raw JSON array over the websocket. + void send(List payload) { + _channel?.sink.add(jsonEncode(payload)); + } + + /// Gracefully close the connection. + Future disconnect() async { + _resetConnection(); + final channel = _channel; + _channel = null; + if (channel != null) { + await channel.sink.close(); + } + } + + void dispose() { + _resetConnection(); + _channel?.sink.close(); + _channel = null; + } + + void _resetConnection() { + _state = SocketState.disconnected; + _subscription?.cancel(); + _subscription = null; + _authTimeout?.cancel(); + _authTimeout = null; + _pendingAuthEventId = null; + } + + void _failAuth(Object? error) { + if (_authCompleter != null && !_authCompleter!.isCompleted) { + _authCompleter!.completeError(error ?? Exception('Connection closed')); + } + } + + void _handleRawMessage(dynamic raw) { + final String text; + if (raw is String) { + text = raw; + } else { + return; // Binary frames are not part of the Nostr protocol. + } + + final List data; + try { + data = jsonDecode(text) as List; + } catch (_) { + return; // Malformed JSON. + } + + if (data.isEmpty) return; + final type = data[0] as String; + + switch (type) { + case 'AUTH': + _handleAuthChallenge(data); + case 'OK': + _handleOk(data); + default: + // Pass EVENT, EOSE, NOTICE, etc. upstream. + _onMessage(data); + } + } + + /// Handle the relay's AUTH challenge: sign a kind:22242 event and respond. + void _handleAuthChallenge(List data) { + if (data.length < 2) return; + final challenge = data[1] as String; + + if (_nsec == null) { + _failAuth(Exception('No nsec available for NIP-42 auth')); + return; + } + + try { + // Decode bech32 nsec to hex private key. + final privkeyHex = nostr.Nip19.decodePrivkey(_nsec); + if (privkeyHex.isEmpty) { + _failAuth(Exception('Invalid nsec')); + return; + } + + // Build the auth tags. + final tags = >[ + ['relay', _wsUrl], + ['challenge', challenge], + if (_apiToken != null) ['auth_token', _apiToken], + ]; + + // Create and sign the kind:22242 AUTH event. + final event = nostr.Event.from( + kind: EventKind.auth, + content: '', + tags: tags, + privkey: privkeyHex, + ); + + _pendingAuthEventId = event.id; + send(['AUTH', event.toJson()]); + } catch (e) { + _failAuth(e); + } + } + + /// Handle OK frames. During auth, complete the auth flow. + void _handleOk(List data) { + if (data.length < 3) return; + final eventId = data[1] as String; + final accepted = data[2] as bool; + + // Check if this OK is for our pending AUTH event. + if (_pendingAuthEventId != null && eventId == _pendingAuthEventId) { + _pendingAuthEventId = null; + if (accepted) { + if (_authCompleter != null && !_authCompleter!.isCompleted) { + _authCompleter!.complete(); + } + } else { + final message = data.length > 3 + ? data[3] as String + : 'Auth rejected by relay'; + _failAuth(Exception(message)); + } + return; + } + + // Pass non-auth OK frames upstream for pending event tracking. + _onMessage(data); + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index d3bd84cda52..b2614f634be 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -57,6 +57,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + bech32: + dependency: transitive + description: + name: bech32 + sha256: "156cbace936f7720c79a79d16a03efad343b1ef17106716e04b8b8e39f99f7f7" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + bip340: + dependency: transitive + description: + name: bip340 + sha256: "2a92f6ed68959f75d67c9a304c17928b9c9449587d4f75ee68f34152f7f69e87" + url: "https://pub.dev" + source: hosted + version: "0.2.0" boolean_selector: dependency: transitive description: @@ -137,6 +153,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec + url: "https://pub.dev" + source: hosted + version: "6.1.5" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" + url: "https://pub.dev" + source: hosted + version: "2.1.0" convert: dependency: transitive description: @@ -193,6 +225,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" fake_async: dependency: transitive description: @@ -520,6 +560,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.17.6" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" node_preamble: dependency: transitive description: @@ -528,6 +576,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + nostr: + dependency: "direct main" + description: + name: nostr + sha256: a99942e4eedd5823d16f42e6df96240488028666a329ee1047552f79db564123 + url: "https://pub.dev" + source: hosted + version: "1.5.0" objective_c: dependency: transitive description: @@ -600,6 +656,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -616,6 +680,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + url: "https://pub.dev" + source: hosted + version: "3.9.1" pool: dependency: transitive description: @@ -918,7 +990,7 @@ packages: source: hosted version: "1.0.1" web_socket_channel: - dependency: transitive + dependency: "direct main" description: name: web_socket_channel sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 @@ -949,6 +1021,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" yaml: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 2704af93baf..7bcfb52620f 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -16,6 +16,9 @@ dependencies: flutter_secure_storage: ^9.2.4 mobile_scanner: ^6.0.2 shared_preferences: ^2.5.5 + web_socket_channel: ^3.0.1 + connectivity_plus: ^6.1.4 + nostr: ^1.5.0 dev_dependencies: flutter_test: