mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(mobile): 6 bug fixes — QR, resume, bots, typing, errors, keyboard (#397)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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}"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
@@ -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(
|
||||
@@ -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(
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -229,14 +229,39 @@ class PairingNotifier extends Notifier<PairingState> {
|
||||
errorMessage: 'Invalid pairing code: ${e.message}',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Pairing connection error: $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') ||
|
||||
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.';
|
||||
}
|
||||
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<dynamic> data) {
|
||||
if (data.isEmpty) return;
|
||||
final type = data[0] as String;
|
||||
|
||||
Reference in New Issue
Block a user