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:
Wes
2026-04-16 20:17:55 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 9cce7b8bbb
commit b106fba815
9 changed files with 638 additions and 100 deletions
+8
View File
@@ -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,
+105 -19
View File
@@ -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 {
+46 -19
View File
@@ -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 {