From 7e68bdf34cce9e9a6eb0b100025e187010d24445 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 24 Apr 2026 09:52:12 -0700 Subject: [PATCH 1/3] =?UTF-8?q?fix(mobile):=206=20bug=20fixes=20=E2=80=94?= =?UTF-8?q?=20QR=20permissions,=20resume=20caching,=20bots=20in=20members,?= =?UTF-8?q?=20typing=20self-filter,=20friendly=20errors,=20keyboard=20inse?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. QR scanner crash: add NSCameraUsageDescription to Info.plist, CAMERA permission to AndroidManifest, and errorBuilder for denied state. 2. Empty screen on resume: preserve last-known messages in provider so the UI shows cached content while reconnecting instead of a spinner. Add reconnecting banner to channel detail page. 3. Bots missing from members: show bots in a separate section with People/Bots headers and counts, matching desktop behavior. 4. Self-typing indicator: filter out current user's pubkey from typing entries in both channel and thread views. 5. Null error on VPN disconnect: catch SocketException and map raw exceptions to human-friendly messages in the pairing flow. 6. Search sheet behind keyboard: add bottom padding using MediaQuery.viewInsetsOf to the browse channels ListView. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../android/app/src/main/AndroidManifest.xml | 1 + mobile/ios/Runner/Info.plist | 2 + .../channels/channel_detail_page.dart | 65 ++++++++++++-- .../channels/channel_messages_provider.dart | 20 ++++- .../lib/features/channels/channels_page.dart | 5 +- .../lib/features/channels/members_sheet.dart | 84 ++++++++++++++----- .../features/channels/thread_detail_page.dart | 7 +- mobile/lib/features/pairing/pairing_page.dart | 35 ++++++++ .../features/pairing/pairing_provider.dart | 33 +++++++- 9 files changed, 219 insertions(+), 33 deletions(-) diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 206cfb5e..f386b412 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSCameraUsageDescription + Sprout needs camera access to scan QR codes for device pairing. NSPhotoLibraryUsageDescription Sprout needs photo library access so you can attach images to messages. UIApplicationSceneManifest diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 3cf70956..c6cde15f 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -59,15 +59,20 @@ class ChannelDetailPage extends HookConsumerWidget { final detailsAsync = ref.watch(channelDetailsProvider(channel.id)); final channelsAsync = ref.watch(channelsProvider); final messagesState = ref.watch(channelMessagesProvider(channel.id)); - // Only show channel-level typing (exclude thread-scoped entries). - final typingEntries = ref - .watch(channelTypingProvider(channel.id)) - .where((e) => e.threadHeadId == null) - .toList(); final currentPubkey = ref .watch(profileProvider) .whenData((value) => value?.pubkey) .value; + // Only show channel-level typing (exclude thread-scoped entries and self). + final typingEntries = ref + .watch(channelTypingProvider(channel.id)) + .where((e) => e.threadHeadId == null) + .where( + (e) => + currentPubkey == null || + e.pubkey.toLowerCase() != currentPubkey.toLowerCase(), + ) + .toList(); final baseChannel = channelsAsync .whenData( @@ -148,6 +153,9 @@ class ChannelDetailPage extends HookConsumerWidget { ), body: Column( children: [ + _DetailConnectionBanner( + status: ref.watch(relaySessionProvider).status, + ), Expanded( child: resolvedChannel.isForum ? ForumPostsView( @@ -860,6 +868,53 @@ class _ReadOnlyNotice extends StatelessWidget { } } +// --------------------------------------------------------------------------- +// Connection banner (shown inside channel detail during reconnect) +// --------------------------------------------------------------------------- + +class _DetailConnectionBanner extends StatelessWidget { + final SessionStatus status; + + const _DetailConnectionBanner({required this.status}); + + @override + Widget build(BuildContext context) { + if (status == SessionStatus.connected || + status == SessionStatus.disconnected) { + return const SizedBox.shrink(); + } + + 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( + 'Reconnecting…', + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} + // --------------------------------------------------------------------------- // Typing indicator // --------------------------------------------------------------------------- diff --git a/mobile/lib/features/channels/channel_messages_provider.dart b/mobile/lib/features/channels/channel_messages_provider.dart index 851673eb..60069241 100644 --- a/mobile/lib/features/channels/channel_messages_provider.dart +++ b/mobile/lib/features/channels/channel_messages_provider.dart @@ -12,6 +12,10 @@ class ChannelMessagesNotifier extends Notifier>> { ChannelMessagesNotifier(this.channelId); + /// Last successfully loaded messages, preserved across reconnections so the + /// UI can show stale data instead of a blank loading spinner. + List? _lastKnownMessages; + @override AsyncValue> build() { final sessionState = ref.watch(relaySessionProvider); @@ -21,12 +25,18 @@ class ChannelMessagesNotifier extends Notifier>> { }); if (sessionState.status != SessionStatus.connected) { - return const AsyncData([]); + // Return cached messages if available so the UI remains usable while + // disconnected/reconnecting, instead of showing an empty screen. + return AsyncData(_lastKnownMessages ?? const []); } // Reset pagination state on rebuild (e.g. after reconnect). _reachedOldest = false; _init(); + // Show previous messages while fetching fresh ones, instead of a spinner. + if (_lastKnownMessages case final cached? when cached.isNotEmpty) { + return AsyncData(cached); + } return const AsyncLoading(); } @@ -58,6 +68,7 @@ class ChannelMessagesNotifier extends Notifier>> { ); history.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + _lastKnownMessages = history; state = AsyncData(history); } catch (e, st) { state = AsyncError(e, st); @@ -65,7 +76,11 @@ class ChannelMessagesNotifier extends Notifier>> { } void _handleLiveEvent(NostrEvent event) { - state = state.whenData((events) => _mergeEvent(events, event)); + state = state.whenData((events) { + final merged = _mergeEvent(events, event); + _lastKnownMessages = merged; + return merged; + }); // When a membership system event arrives, refresh the channel member list // so the @mention autocomplete picks up new members without a restart. @@ -136,6 +151,7 @@ class ChannelMessagesNotifier extends Notifier>> { state = state.whenData((events) { final merged = [...deduped, ...events]; merged.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + _lastKnownMessages = merged; return merged; }); return true; diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 4474943f..9525ab8a 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -855,7 +855,10 @@ class _BrowseChannelsSheet extends HookConsumerWidget { ) : ListView( controller: scrollController, - padding: const EdgeInsets.only(top: Grid.xxs), + padding: EdgeInsets.only( + top: Grid.xxs, + bottom: MediaQuery.viewInsetsOf(context).bottom, + ), children: [ if (notJoined.isNotEmpty) ...[ _MiniHeader( diff --git a/mobile/lib/features/channels/members_sheet.dart b/mobile/lib/features/channels/members_sheet.dart index bfc7c55c..73502a7c 100644 --- a/mobile/lib/features/channels/members_sheet.dart +++ b/mobile/lib/features/channels/members_sheet.dart @@ -24,6 +24,7 @@ class MembersSheet extends HookConsumerWidget { final membersAsync = ref.watch(channelMembersProvider(channel.id)); final allMembers = membersAsync.asData?.value ?? const []; final people = allMembers.where((member) => !member.isBot).toList(); + final bots = allMembers.where((member) => member.isBot).toList(); final userCache = ref.watch(userCacheProvider); // Determine if the current user can manage members. @@ -38,13 +39,13 @@ class MembersSheet extends HookConsumerWidget { // Preload profiles for all members so avatars appear. useEffect(() { - if (people.isNotEmpty) { + if (allMembers.isNotEmpty) { ref .read(userCacheProvider.notifier) - .preload(people.map((m) => m.pubkey).toList()); + .preload(allMembers.map((m) => m.pubkey).toList()); } return null; - }, [people.length]); + }, [allMembers.length]); return Padding( padding: EdgeInsets.fromLTRB( @@ -70,33 +71,49 @@ class MembersSheet extends HookConsumerWidget { ), if (!channel.isDm) ...[const Divider(height: Grid.sm)], SizedBox( - height: 280, + height: bots.isEmpty ? 280 : 360, child: membersAsync.when( - data: (_) => people.isEmpty - ? Center( + data: (_) => ListView( + shrinkWrap: true, + children: [ + if (people.isNotEmpty) ...[ + _SectionLabel(label: 'People — ${people.length}'), + for (final member in people) + _MemberTile( + member: member, + currentPubkey: currentPubkey, + profile: userCache[member.pubkey.toLowerCase()], + canManage: canManage, + isSelf: + member.pubkey.toLowerCase() == + currentPubkey?.toLowerCase(), + channelId: channel.id, + ), + ], + if (bots.isNotEmpty) ...[ + const SizedBox(height: Grid.xxs), + _SectionLabel(label: 'Bots — ${bots.length}'), + for (final bot in bots) + _MemberTile( + member: bot, + currentPubkey: currentPubkey, + profile: userCache[bot.pubkey.toLowerCase()], + canManage: canManage, + isSelf: false, + channelId: channel.id, + ), + ], + if (people.isEmpty && bots.isEmpty) + Center( child: Text( - 'No people found.', + 'No members found.', style: context.textTheme.bodySmall?.copyWith( color: context.colors.outline, ), ), - ) - : ListView( - shrinkWrap: true, - children: [ - for (final member in people) - _MemberTile( - member: member, - currentPubkey: currentPubkey, - profile: userCache[member.pubkey.toLowerCase()], - canManage: canManage, - isSelf: - member.pubkey.toLowerCase() == - currentPubkey?.toLowerCase(), - channelId: channel.id, - ), - ], ), + ], + ), loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => Center( @@ -117,6 +134,27 @@ class MembersSheet extends HookConsumerWidget { } } +class _SectionLabel extends StatelessWidget { + final String label; + + const _SectionLabel({required this.label}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: Grid.half, bottom: Grid.half), + child: Text( + label.toUpperCase(), + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.outline, + fontWeight: FontWeight.w600, + letterSpacing: 0.8, + ), + ), + ); + } +} + const _changeableRoles = ['admin', 'member', 'guest']; class _MemberTile extends ConsumerWidget { diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 5d57220c..fd5550c3 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -58,10 +58,15 @@ class ThreadDetailPage extends HookConsumerWidget { final replies = childrenByParent[threadHead.id] ?? const []; - // Thread-scoped typing indicators. + // Thread-scoped typing indicators (exclude self). final allTyping = ref.watch(channelTypingProvider(channelId)); final threadTyping = allTyping .where((e) => e.threadHeadId == threadHead.id) + .where( + (e) => + currentPubkey == null || + e.pubkey.toLowerCase() != currentPubkey?.toLowerCase(), + ) .toList(); // Resolve thread head from live data (reactions/edits may have changed). diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index 84869a48..8531c21c 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -319,6 +319,9 @@ class _ScannerPage extends HookWidget { @override Widget build(BuildContext context) { final handled = useState(false); + final controller = useMemoized(() => MobileScannerController()); + + useEffect(() => controller.dispose, const []); return Scaffold( appBar: AppBar( @@ -329,6 +332,38 @@ class _ScannerPage extends HookWidget { ), ), body: MobileScanner( + controller: controller, + errorBuilder: (context, error) { + final message = switch (error.errorCode) { + MobileScannerErrorCode.permissionDenied => + 'Camera permission is required to scan QR codes.\n\nPlease grant camera access in your device settings.', + _ => + 'Could not start camera: ${error.errorDetails?.message ?? 'unknown error'}', + }; + return Center( + child: Padding( + padding: const EdgeInsets.all(Grid.sm), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.cameraOff, + size: 48, + color: context.colors.onSurfaceVariant, + ), + const SizedBox(height: Grid.xs), + Text( + message, + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + }, onDetect: (capture) { if (handled.value) return; final barcodes = capture.barcodes; diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index 9707ba14..c3a75b14 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'dart:math' as math; import 'package:flutter/foundation.dart'; @@ -228,15 +229,45 @@ class PairingNotifier extends Notifier { status: PairingStatus.error, errorMessage: 'Invalid pairing code: ${e.message}', ); + } on SocketException catch (_) { + _cleanup(); + state = const PairingState( + status: PairingStatus.error, + errorMessage: + 'Could not reach the pairing relay. Check your internet ' + 'connection and VPN, then try again.', + ); } catch (e) { _cleanup(); state = PairingState( status: PairingStatus.error, - errorMessage: 'Connection failed: $e', + errorMessage: _friendlyErrorMessage(e), ); } } + static String _friendlyErrorMessage(Object error) { + final message = error.toString(); + if (message.contains('SocketException') || + message.contains('Connection refused') || + message.contains('Network is unreachable') || + message.contains('No route to host')) { + return 'Could not reach the pairing relay. Check your internet ' + 'connection and VPN, then try again.'; + } + if (message.contains('HandshakeException') || + message.contains('CERTIFICATE_VERIFY_FAILED')) { + return 'Secure connection failed. Check your network settings ' + 'and try again.'; + } + if (message.contains('TimeoutException') || message.contains('timed out')) { + return 'Connection timed out. Check your internet connection and ' + 'try again.'; + } + return 'Connection failed. Please check your internet connection ' + 'and try again.'; + } + void _handleRelayMessage(List data) { if (data.isEmpty) return; final type = data[0] as String; From 70e76886604e9bcb4482452a2e4f744f941857dc Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 24 Apr 2026 09:55:21 -0700 Subject: [PATCH 2/3] fix(mobile): use ConstrainedBox for members sheet instead of hardcoded height Replace the fragile `SizedBox(height: bots.isEmpty ? 280 : 360)` with a `ConstrainedBox(maxHeight: 400)` so the sheet adapts to content size rather than using arbitrary fixed heights. Co-Authored-By: Claude Opus 4.6 (1M context) --- mobile/lib/features/channels/members_sheet.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/lib/features/channels/members_sheet.dart b/mobile/lib/features/channels/members_sheet.dart index 73502a7c..8ec29a0f 100644 --- a/mobile/lib/features/channels/members_sheet.dart +++ b/mobile/lib/features/channels/members_sheet.dart @@ -70,8 +70,8 @@ class MembersSheet extends HookConsumerWidget { ), ), if (!channel.isDm) ...[const Divider(height: Grid.sm)], - SizedBox( - height: bots.isEmpty ? 280 : 360, + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 400), child: membersAsync.when( data: (_) => ListView( shrinkWrap: true, From 11b711871752b93e8e2c76847c7c392d6e3f701f Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 24 Apr 2026 10:02:29 -0700 Subject: [PATCH 3/3] =?UTF-8?q?fix(mobile):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20merge=20on=20reconnect,=20remove=20dead=20catch,=20add=20deb?= =?UTF-8?q?ug=20logging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BLOCK: _init() now merges fresh history with existing state instead of replacing it, preventing a race where fetchOlder() results are discarded. Added _initInFlight guard to block pagination while init is in flight. CHANGE: Removed dead `on SocketException` catch (PairingSocket swallows it internally). Added 'Failed to connect' and null-check patterns to _friendlyErrorMessage. Added debugPrint before friendly error mapping so dev builds surface the root cause. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../channels/channel_messages_provider.dart | 21 +++++++++++++++---- .../features/pairing/pairing_provider.dart | 14 ++++--------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/mobile/lib/features/channels/channel_messages_provider.dart b/mobile/lib/features/channels/channel_messages_provider.dart index 60069241..0419403e 100644 --- a/mobile/lib/features/channels/channel_messages_provider.dart +++ b/mobile/lib/features/channels/channel_messages_provider.dart @@ -9,6 +9,7 @@ class ChannelMessagesNotifier extends Notifier>> { final String channelId; void Function()? _unsubscribe; bool _reachedOldest = false; + bool _initInFlight = false; ChannelMessagesNotifier(this.channelId); @@ -41,6 +42,7 @@ class ChannelMessagesNotifier extends Notifier>> { } Future _init() async { + _initInFlight = true; try { final session = ref.read(relaySessionProvider.notifier); @@ -67,11 +69,22 @@ class ChannelMessagesNotifier extends Notifier>> { _handleLiveEvent, ); - history.sort((a, b) => a.createdAt.compareTo(b.createdAt)); - _lastKnownMessages = history; - state = AsyncData(history); + // Merge fresh history with any events already in state (e.g. from + // fetchOlder() or live events that arrived while _init was in flight) + // to avoid discarding data the user has already scrolled through. + final existing = state.value ?? const []; + final existingIds = existing.map((e) => e.id).toSet(); + final newEvents = history + .where((e) => !existingIds.contains(e.id)) + .toList(); + final merged = [...existing, ...newEvents]; + merged.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + _lastKnownMessages = merged; + state = AsyncData(merged); } catch (e, st) { state = AsyncError(e, st); + } finally { + _initInFlight = false; } } @@ -113,7 +126,7 @@ class ChannelMessagesNotifier extends Notifier>> { /// Fetch older messages (pagination). Call this when the user scrolls up. /// Returns `true` if new messages were loaded. Future fetchOlder() async { - if (_reachedOldest) return false; + if (_reachedOldest || _initInFlight) return false; final currentEvents = state.value; if (currentEvents == null || currentEvents.isEmpty) return false; diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index c3a75b14..c910c825 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'dart:math' as math; import 'package:flutter/foundation.dart'; @@ -229,15 +228,8 @@ class PairingNotifier extends Notifier { status: PairingStatus.error, errorMessage: 'Invalid pairing code: ${e.message}', ); - } on SocketException catch (_) { - _cleanup(); - state = const PairingState( - status: PairingStatus.error, - errorMessage: - 'Could not reach the pairing relay. Check your internet ' - 'connection and VPN, then try again.', - ); } catch (e) { + debugPrint('Pairing connection error: $e'); _cleanup(); state = PairingState( status: PairingStatus.error, @@ -251,7 +243,9 @@ class PairingNotifier extends Notifier { if (message.contains('SocketException') || message.contains('Connection refused') || message.contains('Network is unreachable') || - message.contains('No route to host')) { + message.contains('No route to host') || + message.contains('Failed to connect') || + message.contains('Null check operator used on a null value')) { return 'Could not reach the pairing relay. Check your internet ' 'connection and VPN, then try again.'; }