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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions mobile/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" />
<application
android:label="Sprout"
android:name="${applicationName}"
Expand Down
2 changes: 2 additions & 0 deletions mobile/ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Sprout needs camera access to scan QR codes for device pairing.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Sprout needs photo library access so you can attach images to messages.</string>
<key>UIApplicationSceneManifest</key>
Expand Down
65 changes: 60 additions & 5 deletions mobile/lib/features/channels/channel_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -148,6 +153,9 @@ class ChannelDetailPage extends HookConsumerWidget {
),
body: Column(
children: [
_DetailConnectionBanner(
status: ref.watch(relaySessionProvider).status,
),
Expanded(
child: resolvedChannel.isForum
? ForumPostsView(
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
39 changes: 34 additions & 5 deletions mobile/lib/features/channels/channel_messages_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
final String channelId;
void Function()? _unsubscribe;
bool _reachedOldest = false;
bool _initInFlight = false;

ChannelMessagesNotifier(this.channelId);

/// Last successfully loaded messages, preserved across reconnections so the
/// UI can show stale data instead of a blank loading spinner.
List<NostrEvent>? _lastKnownMessages;

@override
AsyncValue<List<NostrEvent>> build() {
final sessionState = ref.watch(relaySessionProvider);
Expand All @@ -21,16 +26,23 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
});

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

Future<void> _init() async {
_initInFlight = true;
try {
final session = ref.read(relaySessionProvider.notifier);

Expand All @@ -57,15 +69,31 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
_handleLiveEvent,
);

history.sort((a, b) => a.createdAt.compareTo(b.createdAt));
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;
}
}

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.
Expand Down Expand Up @@ -98,7 +126,7 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
/// Fetch older messages (pagination). Call this when the user scrolls up.
/// Returns `true` if new messages were loaded.
Future<bool> fetchOlder() async {
if (_reachedOldest) return false;
if (_reachedOldest || _initInFlight) return false;

final currentEvents = state.value;
if (currentEvents == null || currentEvents.isEmpty) return false;
Expand Down Expand Up @@ -136,6 +164,7 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
state = state.whenData((events) {
final merged = [...deduped, ...events];
merged.sort((a, b) => a.createdAt.compareTo(b.createdAt));
_lastKnownMessages = merged;
return merged;
});
return true;
Expand Down
5 changes: 4 additions & 1 deletion mobile/lib/features/channels/channels_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
86 changes: 62 additions & 24 deletions mobile/lib/features/channels/members_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class MembersSheet extends HookConsumerWidget {
final membersAsync = ref.watch(channelMembersProvider(channel.id));
final allMembers = membersAsync.asData?.value ?? const <ChannelMember>[];
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.
Expand All @@ -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(
Expand All @@ -69,34 +70,50 @@ class MembersSheet extends HookConsumerWidget {
),
),
if (!channel.isDm) ...[const Divider(height: Grid.sm)],
SizedBox(
height: 280,
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 400),
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(
Expand All @@ -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 {
Expand Down
7 changes: 6 additions & 1 deletion mobile/lib/features/channels/thread_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
35 changes: 35 additions & 0 deletions mobile/lib/features/pairing/pairing_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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;
Expand Down
Loading