mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(mobile): DM avatars, member roles, pairing fixes, and offline UX (#348)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -90,6 +90,14 @@ class _OfflineScreen extends ConsumerWidget {
|
||||
icon: const Icon(LucideIcons.refreshCw),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
const SizedBox(height: Grid.twelve),
|
||||
TextButton(
|
||||
onPressed: () => ref.read(authProvider.notifier).signOut(),
|
||||
child: Text(
|
||||
'Sign out and re-pair',
|
||||
style: TextStyle(color: context.colors.outline),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../profile/presence_cache_provider.dart';
|
||||
import '../profile/profile_provider.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import '../profile/user_profile.dart';
|
||||
@@ -82,22 +83,29 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(
|
||||
channelIcon(resolvedChannel),
|
||||
size: 18,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
Expanded(
|
||||
child: Text(
|
||||
resolvedChannel.displayLabel(currentPubkey: currentPubkey),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
title: resolvedChannel.isDm
|
||||
? _DmAppBarTitle(
|
||||
channel: resolvedChannel,
|
||||
currentPubkey: currentPubkey,
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
Icon(
|
||||
channelIcon(resolvedChannel),
|
||||
size: 18,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
Expanded(
|
||||
child: Text(
|
||||
resolvedChannel.displayLabel(
|
||||
currentPubkey: currentPubkey,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
@@ -1102,6 +1110,16 @@ class _MembersSheet extends HookConsumerWidget {
|
||||
final people = allMembers.where((member) => !member.isBot).toList();
|
||||
final userCache = ref.watch(userCacheProvider);
|
||||
|
||||
// Determine if the current user can manage members.
|
||||
final currentMember = allMembers.cast<ChannelMember?>().firstWhere(
|
||||
(m) => m!.pubkey.toLowerCase() == currentPubkey?.toLowerCase(),
|
||||
orElse: () => null,
|
||||
);
|
||||
final canManage =
|
||||
currentMember != null &&
|
||||
currentMember.isElevated &&
|
||||
!channel.isArchived;
|
||||
|
||||
// Preload profiles for all members so avatars appear.
|
||||
useEffect(() {
|
||||
if (people.isNotEmpty) {
|
||||
@@ -1134,17 +1152,6 @@ class _MembersSheet extends HookConsumerWidget {
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (!channel.isDm) ...[
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Text(
|
||||
channel.isArchived
|
||||
? 'Archived channels are read-only on mobile. Member and bot management stay on desktop.'
|
||||
: 'Member and bot management stay on desktop.',
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (!channel.isDm) ...[const Divider(height: Grid.sm)],
|
||||
SizedBox(
|
||||
height: 280,
|
||||
@@ -1162,25 +1169,15 @@ class _MembersSheet extends HookConsumerWidget {
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
for (final member in people)
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final profile =
|
||||
userCache[member.pubkey.toLowerCase()];
|
||||
final avatarUrl = profile?.avatarUrl;
|
||||
final label = member.labelFor(currentPubkey);
|
||||
final initial = label
|
||||
.substring(0, 1)
|
||||
.toUpperCase();
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: _MemberAvatar(
|
||||
avatarUrl: avatarUrl,
|
||||
initial: initial,
|
||||
),
|
||||
title: Text(label),
|
||||
subtitle: Text(member.role),
|
||||
);
|
||||
},
|
||||
_MemberTile(
|
||||
member: member,
|
||||
currentPubkey: currentPubkey,
|
||||
profile: userCache[member.pubkey.toLowerCase()],
|
||||
canManage: canManage,
|
||||
isSelf:
|
||||
member.pubkey.toLowerCase() ==
|
||||
currentPubkey?.toLowerCase(),
|
||||
channelId: channel.id,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1204,6 +1201,156 @@ class _MembersSheet extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
const _changeableRoles = ['admin', 'member', 'guest'];
|
||||
|
||||
class _MemberTile extends ConsumerWidget {
|
||||
final ChannelMember member;
|
||||
final String? currentPubkey;
|
||||
final UserProfile? profile;
|
||||
final bool canManage;
|
||||
final bool isSelf;
|
||||
final String channelId;
|
||||
|
||||
const _MemberTile({
|
||||
required this.member,
|
||||
required this.currentPubkey,
|
||||
required this.profile,
|
||||
required this.canManage,
|
||||
required this.isSelf,
|
||||
required this.channelId,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final label = member.labelFor(currentPubkey);
|
||||
final initial = label.substring(0, 1).toUpperCase();
|
||||
final showMenu = canManage && !isSelf && !member.isOwner;
|
||||
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: _MemberAvatar(avatarUrl: profile?.avatarUrl, initial: initial),
|
||||
title: Text(label),
|
||||
subtitle: Text(
|
||||
_roleLabel(member.role),
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.outline,
|
||||
),
|
||||
),
|
||||
trailing: showMenu
|
||||
? IconButton(
|
||||
icon: const Icon(LucideIcons.ellipsis, size: 18),
|
||||
onPressed: () => _showMemberActions(context, ref),
|
||||
visualDensity: VisualDensity.compact,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
String _roleLabel(String role) {
|
||||
if (role.isEmpty) return 'Member';
|
||||
return '${role[0].toUpperCase()}${role.substring(1)}';
|
||||
}
|
||||
|
||||
void _showMemberActions(BuildContext context, WidgetRef ref) {
|
||||
final label = member.labelFor(currentPubkey);
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (_) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
|
||||
child: Text(label, style: context.textTheme.titleSmall),
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
|
||||
child: Text(
|
||||
'Change role',
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.outline,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.half),
|
||||
for (final role in _changeableRoles)
|
||||
ListTile(
|
||||
title: Text(_roleLabel(role)),
|
||||
trailing: role == member.role
|
||||
? Icon(
|
||||
LucideIcons.check,
|
||||
size: 16,
|
||||
color: context.colors.primary,
|
||||
)
|
||||
: null,
|
||||
enabled: role != member.role,
|
||||
onTap: role == member.role
|
||||
? null
|
||||
: () async {
|
||||
Navigator.of(context).pop();
|
||||
await ref
|
||||
.read(channelActionsProvider)
|
||||
.changeMemberRole(
|
||||
channelId: channelId,
|
||||
pubkey: member.pubkey,
|
||||
role: role,
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
LucideIcons.userMinus,
|
||||
size: 18,
|
||||
color: context.colors.error,
|
||||
),
|
||||
title: Text(
|
||||
'Remove from channel',
|
||||
style: TextStyle(color: context.colors.error),
|
||||
),
|
||||
onTap: () async {
|
||||
Navigator.of(context).pop();
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Remove member'),
|
||||
content: Text('Remove $label from this channel?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(
|
||||
'Remove',
|
||||
style: TextStyle(color: context.colors.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true) {
|
||||
await ref
|
||||
.read(channelActionsProvider)
|
||||
.removeMember(
|
||||
channelId: channelId,
|
||||
pubkey: member.pubkey,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: Grid.xxs),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MemberAvatar extends HookWidget {
|
||||
final String? avatarUrl;
|
||||
final String initial;
|
||||
@@ -1686,3 +1833,121 @@ String _formatTime(int createdAt) {
|
||||
}
|
||||
|
||||
String _pad(int n) => n.toString().padLeft(2, '0');
|
||||
|
||||
class _DmAppBarTitle extends ConsumerWidget {
|
||||
final Channel channel;
|
||||
final String? currentPubkey;
|
||||
|
||||
const _DmAppBarTitle({required this.channel, required this.currentPubkey});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final profiles = ref.watch(userCacheProvider);
|
||||
final presenceMap = ref.watch(presenceCacheProvider);
|
||||
final normalizedCurrent = currentPubkey?.toLowerCase();
|
||||
|
||||
String? otherPubkey;
|
||||
for (final pk in channel.participantPubkeys) {
|
||||
if (pk.toLowerCase() != normalizedCurrent) {
|
||||
otherPubkey = pk.toLowerCase();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
final profile = otherPubkey != null ? profiles[otherPubkey] : null;
|
||||
|
||||
if (otherPubkey != null) {
|
||||
if (profile == null) {
|
||||
ref.read(userCacheProvider.notifier).preload([otherPubkey]);
|
||||
}
|
||||
ref.read(presenceCacheProvider.notifier).track([otherPubkey]);
|
||||
}
|
||||
|
||||
final avatarUrl = profile?.avatarUrl;
|
||||
final initial =
|
||||
profile?.initial ??
|
||||
(channel.participants.isNotEmpty
|
||||
? channel.participants.first[0].toUpperCase()
|
||||
: '?');
|
||||
final presence = otherPubkey != null
|
||||
? (presenceMap[otherPubkey] ?? 'offline')
|
||||
: 'offline';
|
||||
final presenceLabel = switch (presence) {
|
||||
'online' => 'Online',
|
||||
'away' => 'Away',
|
||||
_ => 'Offline',
|
||||
};
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 30,
|
||||
height: 30,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
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,
|
||||
),
|
||||
Positioned(
|
||||
right: -1,
|
||||
bottom: -1,
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: switch (presence) {
|
||||
'online' => context.appColors.success,
|
||||
'away' => context.appColors.warning,
|
||||
_ => context.colors.outline,
|
||||
},
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color:
|
||||
context.theme.appBarTheme.backgroundColor ??
|
||||
context.theme.scaffoldBackgroundColor,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
channel.displayLabel(currentPubkey: currentPubkey),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.titleSmall,
|
||||
),
|
||||
Text(
|
||||
presenceLabel,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ class ChannelMember {
|
||||
);
|
||||
|
||||
bool get isBot => role == 'bot';
|
||||
bool get isOwner => role == 'owner';
|
||||
bool get isElevated => role == 'owner' || role == 'admin';
|
||||
|
||||
String labelFor(String? currentPubkey) {
|
||||
if (currentPubkey != null &&
|
||||
@@ -298,6 +300,38 @@ class ChannelActions {
|
||||
'${hex.substring(20, 32)}';
|
||||
}
|
||||
|
||||
Future<void> changeMemberRole({
|
||||
required String channelId,
|
||||
required String pubkey,
|
||||
required String role,
|
||||
}) async {
|
||||
await _signedEventRelay.submit(
|
||||
kind: 9000,
|
||||
content: '',
|
||||
tags: [
|
||||
['h', channelId],
|
||||
['p', pubkey.toLowerCase()],
|
||||
['role', role],
|
||||
],
|
||||
);
|
||||
_ref.invalidate(channelMembersProvider(channelId));
|
||||
}
|
||||
|
||||
Future<void> removeMember({
|
||||
required String channelId,
|
||||
required String pubkey,
|
||||
}) async {
|
||||
await _signedEventRelay.submit(
|
||||
kind: 9001,
|
||||
content: '',
|
||||
tags: [
|
||||
['h', channelId],
|
||||
['p', pubkey.toLowerCase()],
|
||||
],
|
||||
);
|
||||
_ref.invalidate(channelMembersProvider(channelId));
|
||||
}
|
||||
|
||||
Future<void> addReaction(String eventId, String emoji) async {
|
||||
await _signedEventRelay.submit(
|
||||
kind: EventKind.reaction,
|
||||
|
||||
@@ -11,6 +11,8 @@ import '../../shared/theme/theme.dart';
|
||||
import '../profile/profile_avatar.dart';
|
||||
import '../profile/profile_provider.dart';
|
||||
import '../settings/settings_page.dart';
|
||||
import '../profile/presence_cache_provider.dart';
|
||||
import '../profile/user_cache_provider.dart';
|
||||
import 'channel.dart';
|
||||
import 'channel_detail_page.dart';
|
||||
import 'channel_management_provider.dart';
|
||||
@@ -394,7 +396,7 @@ class _SectionHeader extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelTile extends StatelessWidget {
|
||||
class _ChannelTile extends ConsumerWidget {
|
||||
final Channel channel;
|
||||
final String? currentPubkey;
|
||||
final VoidCallback onTap;
|
||||
@@ -406,7 +408,7 @@ class _ChannelTile extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final hasActivity = channel.lastMessageAt != null;
|
||||
|
||||
return InkWell(
|
||||
@@ -421,13 +423,16 @@ class _ChannelTile extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_iconFor(channel),
|
||||
size: 18,
|
||||
color: hasActivity
|
||||
? context.colors.onSurface
|
||||
: context.colors.outline,
|
||||
),
|
||||
if (channel.isDm)
|
||||
_DmAvatar(channel: channel, currentPubkey: currentPubkey)
|
||||
else
|
||||
Icon(
|
||||
channelIcon(channel),
|
||||
size: 18,
|
||||
color: hasActivity
|
||||
? context.colors.onSurface
|
||||
: context.colors.outline,
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -443,15 +448,6 @@ class _ChannelTile extends StatelessWidget {
|
||||
: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (channel.isDm && channel.name.trim().isNotEmpty)
|
||||
Text(
|
||||
channel.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -485,8 +481,98 @@ class _ChannelTile extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
IconData _iconFor(Channel channel) => channelIcon(channel);
|
||||
class _DmAvatar extends ConsumerWidget {
|
||||
final Channel channel;
|
||||
final String? currentPubkey;
|
||||
|
||||
const _DmAvatar({required this.channel, required this.currentPubkey});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final profiles = ref.watch(userCacheProvider);
|
||||
final presenceMap = ref.watch(presenceCacheProvider);
|
||||
final normalizedCurrent = currentPubkey?.toLowerCase();
|
||||
|
||||
// Find the other participant's pubkey.
|
||||
String? otherPubkey;
|
||||
for (final pk in channel.participantPubkeys) {
|
||||
if (pk.toLowerCase() != normalizedCurrent) {
|
||||
otherPubkey = pk.toLowerCase();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
final profile = otherPubkey != null ? profiles[otherPubkey] : null;
|
||||
|
||||
// Trigger fetches if not cached yet.
|
||||
if (otherPubkey != null) {
|
||||
if (profile == null) {
|
||||
ref.read(userCacheProvider.notifier).preload([otherPubkey]);
|
||||
}
|
||||
ref.read(presenceCacheProvider.notifier).track([otherPubkey]);
|
||||
}
|
||||
|
||||
final avatarUrl = profile?.avatarUrl;
|
||||
final initial =
|
||||
profile?.initial ??
|
||||
(channel.participants.isNotEmpty
|
||||
? channel.participants.first[0].toUpperCase()
|
||||
: '?');
|
||||
final presence = otherPubkey != null
|
||||
? (presenceMap[otherPubkey] ?? 'offline')
|
||||
: 'offline';
|
||||
|
||||
return SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundColor: context.colors.primaryContainer,
|
||||
backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null,
|
||||
child: avatarUrl == null
|
||||
? Text(
|
||||
initial,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
fontSize: 9,
|
||||
color: context.colors.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
Positioned(
|
||||
right: -1,
|
||||
bottom: -1,
|
||||
child: Container(
|
||||
width: 9,
|
||||
height: 9,
|
||||
decoration: BoxDecoration(
|
||||
color: _presenceColor(context, presence),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: context.theme.scaffoldBackgroundColor,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _presenceColor(BuildContext context, String presence) {
|
||||
return switch (presence) {
|
||||
'online' => context.appColors.success,
|
||||
'away' => context.appColors.warning,
|
||||
_ => context.colors.outline,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickActionsSheet extends StatelessWidget {
|
||||
|
||||
@@ -26,6 +26,7 @@ class PairingPage extends HookConsumerWidget {
|
||||
child: pairingState.status == PairingStatus.confirmingSas
|
||||
? _SasVerificationView(
|
||||
sasCode: pairingState.sasCode ?? '------',
|
||||
confirmed: pairingState.userConfirmedSas,
|
||||
onConfirm: () =>
|
||||
ref.read(pairingProvider.notifier).confirmSas(),
|
||||
onDeny: () => ref.read(pairingProvider.notifier).denySas(),
|
||||
@@ -190,11 +191,13 @@ class PairingPage extends HookConsumerWidget {
|
||||
/// SAS verification screen shown during NIP-AB pairing.
|
||||
class _SasVerificationView extends StatelessWidget {
|
||||
final String sasCode;
|
||||
final bool confirmed;
|
||||
final VoidCallback onConfirm;
|
||||
final VoidCallback onDeny;
|
||||
|
||||
const _SasVerificationView({
|
||||
required this.sasCode,
|
||||
required this.confirmed,
|
||||
required this.onConfirm,
|
||||
required this.onDeny,
|
||||
});
|
||||
@@ -213,7 +216,9 @@ class _SasVerificationView extends StatelessWidget {
|
||||
const SizedBox(height: Grid.xs),
|
||||
|
||||
Text(
|
||||
'Does your desktop app show this code?',
|
||||
confirmed
|
||||
? 'Waiting for desktop to confirm...'
|
||||
: 'Does your desktop app show this code?',
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
@@ -257,26 +262,48 @@ class _SasVerificationView extends StatelessWidget {
|
||||
const SizedBox(height: Grid.lg),
|
||||
|
||||
// Confirm / Deny buttons
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: onDeny,
|
||||
icon: const Icon(LucideIcons.x),
|
||||
label: const Text('Cancel'),
|
||||
if (confirmed)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: context.colors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.sm),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: onConfirm,
|
||||
icon: const Icon(LucideIcons.check),
|
||||
label: const Text('Codes Match'),
|
||||
const SizedBox(width: Grid.twelve),
|
||||
Text(
|
||||
'Confirmed — waiting for desktop',
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: onDeny,
|
||||
icon: const Icon(LucideIcons.x),
|
||||
label: const Text('Cancel'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.sm),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: onConfirm,
|
||||
icon: const Icon(LucideIcons.check),
|
||||
label: const Text('Codes Match'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const Spacer(flex: 3),
|
||||
],
|
||||
|
||||
@@ -35,21 +35,25 @@ class PairingState {
|
||||
final PairingStatus status;
|
||||
final String? errorMessage;
|
||||
final String? sasCode;
|
||||
final bool userConfirmedSas;
|
||||
|
||||
const PairingState({
|
||||
this.status = PairingStatus.idle,
|
||||
this.errorMessage,
|
||||
this.sasCode,
|
||||
this.userConfirmedSas = false,
|
||||
});
|
||||
|
||||
PairingState copyWith({
|
||||
PairingStatus? status,
|
||||
String? errorMessage,
|
||||
String? sasCode,
|
||||
bool? userConfirmedSas,
|
||||
}) => PairingState(
|
||||
status: status ?? this.status,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
sasCode: sasCode ?? this.sasCode,
|
||||
userConfirmedSas: userConfirmedSas ?? this.userConfirmedSas,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,16 +82,23 @@ class PairingNotifier extends Notifier<PairingState> {
|
||||
/// Confirm that the SAS code matches. Called by the UI after user approval.
|
||||
void confirmSas() {
|
||||
if (state.status != PairingStatus.confirmingSas) return;
|
||||
state = state.copyWith(status: PairingStatus.transferring);
|
||||
|
||||
// The source sends sas-confirm + payload back-to-back. The payload may
|
||||
// have arrived while we were still in confirmingSas (before the user
|
||||
// tapped "Codes Match"). Process the buffered payload now.
|
||||
final pending = _pendingPayload;
|
||||
if (pending != null) {
|
||||
_pendingPayload = null;
|
||||
_handlePayload(pending);
|
||||
// If the desktop's sas-confirm has already arrived and been verified,
|
||||
// transition immediately and process any buffered payload.
|
||||
if (_sasConfirmReceived) {
|
||||
state = state.copyWith(status: PairingStatus.transferring);
|
||||
final pending = _pendingPayload;
|
||||
if (pending != null) {
|
||||
_pendingPayload = null;
|
||||
_handlePayload(pending);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Desktop hasn't confirmed yet — record intent and wait. The transition
|
||||
// will happen in _handleSasConfirm() once the transcript hash is verified.
|
||||
_userConfirmedSas = true;
|
||||
state = state.copyWith(userConfirmedSas: true);
|
||||
}
|
||||
|
||||
/// Deny the SAS code. Send abort and terminate.
|
||||
@@ -112,6 +123,7 @@ class PairingNotifier extends Notifier<PairingState> {
|
||||
_socket = null;
|
||||
_processedEventIds.clear();
|
||||
_sasConfirmReceived = false;
|
||||
_userConfirmedSas = false;
|
||||
_pendingPayload = null;
|
||||
}
|
||||
|
||||
@@ -126,6 +138,7 @@ class PairingNotifier extends Notifier<PairingState> {
|
||||
Uint8List? _sasInput;
|
||||
Uint8List? _conversationKey;
|
||||
bool _sasConfirmReceived = false;
|
||||
bool _userConfirmedSas = false;
|
||||
Map<String, dynamic>? _pendingPayload; // buffered until user confirms SAS
|
||||
final Set<String> _processedEventIds = {}; // NIP-AB §Duplicate Event Handling
|
||||
|
||||
@@ -324,7 +337,19 @@ class PairingNotifier extends Notifier<PairingState> {
|
||||
}
|
||||
|
||||
_sasConfirmReceived = true;
|
||||
// Stay in confirmingSas — user must still explicitly confirm via confirmSas().
|
||||
|
||||
// If the user already tapped "Codes Match", complete the transition now
|
||||
// that the transcript hash is verified.
|
||||
if (_userConfirmedSas) {
|
||||
_userConfirmedSas = false;
|
||||
state = state.copyWith(status: PairingStatus.transferring);
|
||||
final pending = _pendingPayload;
|
||||
if (pending != null) {
|
||||
_pendingPayload = null;
|
||||
_handlePayload(pending);
|
||||
}
|
||||
}
|
||||
// Otherwise stay in confirmingSas — user must still confirm via confirmSas().
|
||||
}
|
||||
|
||||
void _handlePayload(Map<String, dynamic> msg) {
|
||||
@@ -376,8 +401,8 @@ class PairingNotifier extends Notifier<PairingState> {
|
||||
throw const FormatException('Missing relayUrl or token in payload');
|
||||
}
|
||||
|
||||
// Send complete event.
|
||||
_sendComplete(true);
|
||||
// Validate relay URL to prevent SSRF via private network addresses.
|
||||
_validateRelayUrl(relayUrl);
|
||||
|
||||
// Validate credentials against the relay.
|
||||
final client = RelayClient(
|
||||
@@ -391,6 +416,9 @@ class PairingNotifier extends Notifier<PairingState> {
|
||||
client.dispose();
|
||||
}
|
||||
|
||||
// Send complete only after credentials are validated.
|
||||
_sendComplete(true);
|
||||
|
||||
// Store credentials.
|
||||
await ref
|
||||
.read(authProvider.notifier)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/relay/relay.dart';
|
||||
|
||||
/// In-memory cache of other users' presence, fetched in batches.
|
||||
/// Periodically refreshes to keep presence status up to date.
|
||||
class PresenceCacheNotifier extends Notifier<Map<String, String>> {
|
||||
static const _refreshInterval = Duration(seconds: 30);
|
||||
|
||||
final Set<String> _tracked = {};
|
||||
final Set<String> _pending = {};
|
||||
Timer? _batchTimer;
|
||||
Timer? _refreshTimer;
|
||||
|
||||
@override
|
||||
Map<String, String> build() {
|
||||
ref.watch(relayClientProvider);
|
||||
ref.onDispose(() {
|
||||
_batchTimer?.cancel();
|
||||
_batchTimer = null;
|
||||
_refreshTimer?.cancel();
|
||||
_refreshTimer = null;
|
||||
});
|
||||
return {};
|
||||
}
|
||||
|
||||
/// Track presence for [pubkeys]. Fetches immediately if not cached,
|
||||
/// and includes them in periodic refreshes.
|
||||
void track(List<String> pubkeys) {
|
||||
final normalized = pubkeys.map((pk) => pk.toLowerCase()).toList();
|
||||
final uncached = normalized
|
||||
.where((pk) => !state.containsKey(pk) && !_pending.contains(pk))
|
||||
.toList();
|
||||
|
||||
_tracked.addAll(normalized);
|
||||
_ensureRefreshTimer();
|
||||
|
||||
if (uncached.isEmpty) return;
|
||||
_pending.addAll(uncached);
|
||||
_batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending);
|
||||
}
|
||||
|
||||
void _ensureRefreshTimer() {
|
||||
_refreshTimer ??= Timer.periodic(_refreshInterval, (_) => _refreshAll());
|
||||
}
|
||||
|
||||
Future<void> _refreshAll() async {
|
||||
if (_tracked.isEmpty) return;
|
||||
await _fetchPresence(_tracked.toList());
|
||||
}
|
||||
|
||||
Future<void> _flushPending() async {
|
||||
_batchTimer = null;
|
||||
if (_pending.isEmpty) return;
|
||||
|
||||
final pubkeys = _pending.toList();
|
||||
_pending.clear();
|
||||
await _fetchPresence(pubkeys);
|
||||
}
|
||||
|
||||
Future<void> _fetchPresence(List<String> pubkeys) async {
|
||||
try {
|
||||
final client = ref.read(relayClientProvider);
|
||||
final json =
|
||||
await client.get(
|
||||
'/api/presence',
|
||||
queryParams: {'pubkeys': pubkeys.join(',')},
|
||||
)
|
||||
as Map<String, dynamic>;
|
||||
|
||||
final updated = Map<String, String>.from(state);
|
||||
for (final pk in pubkeys) {
|
||||
updated[pk] = (json[pk] as String?) ?? 'offline';
|
||||
}
|
||||
state = updated;
|
||||
} catch (_) {
|
||||
// Silently fail — default to offline.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final presenceCacheProvider =
|
||||
NotifierProvider<PresenceCacheNotifier, Map<String, String>>(
|
||||
PresenceCacheNotifier.new,
|
||||
);
|
||||
@@ -92,6 +92,10 @@ class RelaySessionNotifier extends Notifier<SessionState> {
|
||||
final config = ref.watch(relayConfigProvider);
|
||||
final authState = ref.watch(authProvider);
|
||||
|
||||
// Reset disposed flag — build() may re-run on the same Notifier instance
|
||||
// after a provider dependency changes (e.g. auth completing).
|
||||
_disposed = false;
|
||||
|
||||
ref.onDispose(_dispose);
|
||||
|
||||
// Auto-connect when authenticated and we have credentials.
|
||||
|
||||
@@ -201,7 +201,9 @@ void main() {
|
||||
expect(find.text('Message…'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('members sheet stays read-only on mobile', (tester) async {
|
||||
testWidgets('members sheet shows roles and manage controls for owners', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: const [],
|
||||
@@ -226,12 +228,9 @@ void main() {
|
||||
await tester.tap(find.byTooltip('View members'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('Member and bot management stay on desktop.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Alice'), findsOneWidget);
|
||||
expect(find.byKey(const Key('members-search-field')), findsNothing);
|
||||
expect(find.text('Member'), findsOneWidget);
|
||||
expect(find.text('Owner'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('hides composer for archived channels', (tester) async {
|
||||
|
||||
Reference in New Issue
Block a user