diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index d92969e0c..14b84913b 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -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), + ), + ), ], ), ), diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index ab3c70d53..94fd17706 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -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().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( + 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( + 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, + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index ee6a25e35..a2162aa83 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -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 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 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 addReaction(String eventId, String emoji) async { await _signedEventRelay.submit( kind: EventKind.reaction, diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index aa506a828..4474943fd 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -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 { diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index f0f8ab43f..84869a48a 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -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), ], diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index cbeeaf7de..9707ba146 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -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 { /// 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 { _socket = null; _processedEventIds.clear(); _sasConfirmReceived = false; + _userConfirmedSas = false; _pendingPayload = null; } @@ -126,6 +138,7 @@ class PairingNotifier extends Notifier { Uint8List? _sasInput; Uint8List? _conversationKey; bool _sasConfirmReceived = false; + bool _userConfirmedSas = false; Map? _pendingPayload; // buffered until user confirms SAS final Set _processedEventIds = {}; // NIP-AB §Duplicate Event Handling @@ -324,7 +337,19 @@ class PairingNotifier extends Notifier { } _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 msg) { @@ -376,8 +401,8 @@ class PairingNotifier extends Notifier { 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 { client.dispose(); } + // Send complete only after credentials are validated. + _sendComplete(true); + // Store credentials. await ref .read(authProvider.notifier) diff --git a/mobile/lib/features/profile/presence_cache_provider.dart b/mobile/lib/features/profile/presence_cache_provider.dart new file mode 100644 index 000000000..fcdd4500b --- /dev/null +++ b/mobile/lib/features/profile/presence_cache_provider.dart @@ -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> { + static const _refreshInterval = Duration(seconds: 30); + + final Set _tracked = {}; + final Set _pending = {}; + Timer? _batchTimer; + Timer? _refreshTimer; + + @override + Map 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 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 _refreshAll() async { + if (_tracked.isEmpty) return; + await _fetchPresence(_tracked.toList()); + } + + Future _flushPending() async { + _batchTimer = null; + if (_pending.isEmpty) return; + + final pubkeys = _pending.toList(); + _pending.clear(); + await _fetchPresence(pubkeys); + } + + Future _fetchPresence(List pubkeys) async { + try { + final client = ref.read(relayClientProvider); + final json = + await client.get( + '/api/presence', + queryParams: {'pubkeys': pubkeys.join(',')}, + ) + as Map; + + final updated = Map.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.new, + ); diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index d3a5e786e..654d5fc97 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -92,6 +92,10 @@ class RelaySessionNotifier extends Notifier { 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. diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 9c153c1bc..62716b390 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -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 {