From b30f1f61299f6f559777f797be27f193a6a4f0b3 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Fri, 14 Aug 2026 04:16:04 +0100 Subject: [PATCH] Polish mobile profiles, DMs, and sheets (#5401) ## Summary - add poster-first, tap-to-toggle animated avatars on profile surfaces while preserving transparent/static behavior elsewhere - align mobile DM headers, membership actions, and invisible agent recipient addressing with established desktop semantics - polish titled sheets and status editing, preserve native iOS sheet corners, and batch relay reads to improve review-build responsiveness ## Snapshots
Profile avatar Agent DM header and composer
Mobile profile settings with animated avatar
surface Agent direct message with masked presence and normal
composer
Members sheet Status editor
Members bottom sheet with centered title and padded
content Status editor bottom sheet with duration and quick
statuses
Switch Community
Switch Community bottom sheet with centered title and
aligned Edit action
## Validation - `just mobile-check` - `just mobile-test` (1,283 tests) - installed and reviewed isolated debug builds on iPhone and Pixel --------- Signed-off-by: kenny lopez Signed-off-by: Princess Donut Signed-off-by: Kenny Lopez Signed-off-by: Wes Co-authored-by: Princess Donut Co-authored-by: Wes Co-authored-by: Carl --- .../activity/activity_page/inbox_row.dart | 21 +- .../features/activity/activity_provider.dart | 142 +++-- .../channels/channel_actions_sheet.dart | 1 + .../channels/channel_detail_page.dart | 14 +- .../channels/channel_detail_page/app_bar.dart | 102 ++-- .../channel_detail_page/message_bubble.dart | 18 +- .../channels/channel_typing_indicator.dart | 13 +- .../lib/features/channels/channels_page.dart | 4 +- .../channels/channels_page/channel_tile.dart | 19 +- .../channels/channels_page/community.dart | 230 ++++---- .../channels_page/quick_actions_launcher.dart | 2 + .../channels/channels_page/sheets.dart | 16 - .../features/channels/channels_provider.dart | 203 ++++--- .../channels/manage_channel_sheet.dart | 2 - .../lib/features/channels/members_sheet.dart | 155 +++--- .../channels/message_mention_pubkeys.dart | 26 + .../channels/send_message_provider.dart | 46 +- .../features/channels/thread_detail_page.dart | 4 + mobile/lib/features/home/home_page.dart | 12 +- .../lib/features/profile/profile_avatar.dart | 9 +- .../features/profile/set_status_sheet.dart | 501 +++++++++++++----- .../profile/settings_profile_header.dart | 63 ++- .../features/profile/user_profile_sheet.dart | 33 +- mobile/lib/features/profile/user_status.dart | 25 + .../profile/user_status_cache_provider.dart | 81 ++- .../profile/user_status_provider.dart | 75 ++- .../settings_page/appearance_section.dart | 10 +- mobile/lib/shared/animated_avatar.dart | 58 ++ mobile/lib/shared/relay/media_image.dart | 3 + .../shared/relay/relay_http_query_client.dart | 97 ++++ mobile/lib/shared/relay/relay_session.dart | 47 +- .../reminders/remind_me_later_sheet.dart | 11 +- mobile/lib/shared/widgets/avatar_image.dart | 13 +- .../lib/shared/widgets/buzz_sheet_header.dart | 121 +++++ .../widgets/buzz_titled_sheet_layout.dart | 67 +++ .../widgets/concentric_sheet_surface.dart | 39 +- .../shared/widgets/modal_presentation.dart | 107 ++-- .../widgets/progressive_animated_avatar.dart | 77 +++ .../activity/activity_provider_test.dart | 74 ++- .../channels/channel_detail_page_test.dart | 300 +++++++++++ .../features/channels/channels_page_test.dart | 11 +- .../channels/channels_provider_test.dart | 107 ++++ .../message_mention_pubkeys_test.dart | 82 +++ .../channels/send_message_provider_test.dart | 161 ++++++ .../features/profile/profile_avatar_test.dart | 52 +- .../profile/set_status_sheet_test.dart | 188 ++++++- .../profile/settings_profile_header_test.dart | 123 ++++- .../profile/user_status_provider_test.dart | 235 ++++++++ mobile/test/shared/animated_avatar_test.dart | 36 ++ .../test/shared/relay/relay_session_test.dart | 138 +++++ .../shared/widgets/avatar_image_test.dart | 36 +- .../widgets/modal_presentation_test.dart | 146 ++++- 52 files changed, 3432 insertions(+), 724 deletions(-) create mode 100644 mobile/lib/features/channels/message_mention_pubkeys.dart create mode 100644 mobile/lib/shared/animated_avatar.dart create mode 100644 mobile/lib/shared/relay/relay_http_query_client.dart create mode 100644 mobile/lib/shared/widgets/buzz_sheet_header.dart create mode 100644 mobile/lib/shared/widgets/buzz_titled_sheet_layout.dart create mode 100644 mobile/lib/shared/widgets/progressive_animated_avatar.dart create mode 100644 mobile/test/features/channels/message_mention_pubkeys_test.dart create mode 100644 mobile/test/features/profile/user_status_provider_test.dart create mode 100644 mobile/test/shared/animated_avatar_test.dart diff --git a/mobile/lib/features/activity/activity_page/inbox_row.dart b/mobile/lib/features/activity/activity_page/inbox_row.dart index fe7869299d..6d7f8f2985 100644 --- a/mobile/lib/features/activity/activity_page/inbox_row.dart +++ b/mobile/lib/features/activity/activity_page/inbox_row.dart @@ -72,23 +72,28 @@ class _InboxRow extends HookConsumerWidget { final revealAmount = useState(0.0); final isDragging = useState(false); final labelHapticFired = useRef(false); - final userCache = ref.watch(userCacheProvider); - final profile = userCache[item.item.pubkey.toLowerCase()]; + final senderPubkey = item.item.pubkey.toLowerCase(); + final mentionPubkeys = mentionedPubkeysFromTags(item.item.tags); + final relevantPubkeys = {senderPubkey, ...mentionPubkeys}; + final profiles = { + for (final pubkey in relevantPubkeys) + pubkey: ref.watch(userCacheProvider.select((cache) => cache[pubkey])), + }; + final profile = profiles[senderPubkey]; final senderLabel = profile?.displayName ?? shortPubkey(item.item.pubkey); final profileMentionNames = { - for (final pubkey in mentionedPubkeysFromTags(item.item.tags)) - if (userCache[pubkey]?.displayName?.trim().isNotEmpty == true) - pubkey: userCache[pubkey]!.displayName!.trim(), + for (final pubkey in mentionPubkeys) + if (profiles[pubkey]?.displayName?.trim().isNotEmpty == true) + pubkey: profiles[pubkey]!.displayName!.trim(), }; - final mentionPubkeys = mentionedPubkeysFromTags(item.item.tags); final knownAgentPubkeys = channel == null ? ref.watch(knownAgentPubkeysProvider) : ref.watch(agentMentionPubkeysProvider(channel!.id)); final agentMentionPubkeys = agentPubkeysWithProfileOwners( knownAgentPubkeys: knownAgentPubkeys, profileOwnedAgentPubkeys: [ - for (final profile in userCache.values) - if (profile.ownerPubkey != null) profile.pubkey, + for (final pubkey in mentionPubkeys) + if (profiles[pubkey]?.ownerPubkey != null) pubkey, ], ); final mentionNames = mentionNamesWithDirectoryLabels( diff --git a/mobile/lib/features/activity/activity_provider.dart b/mobile/lib/features/activity/activity_provider.dart index 3581cbd551..a1badf2045 100644 --- a/mobile/lib/features/activity/activity_provider.dart +++ b/mobile/lib/features/activity/activity_provider.dart @@ -214,48 +214,55 @@ class ActivityNotifier extends AsyncNotifier { if (channel.isDm && channel.isMember) channel.id, ]; - final results = await Future.wait([ + final filters = [ // Mentions of me on user-visible channel content. - session.fetchHistory( - NostrFilter( - kinds: const [9, 40002, 1, 45001, 45003], - tags: { - '#p': [myPk], - }, - limit: 50, - ), + NostrFilter( + kinds: const [9, 40002, 1, 45001, 45003], + tags: { + '#p': [myPk], + }, + limit: 50, ), // Workflow approvals addressed to me. - session.fetchHistory( - NostrFilter( - kinds: const [46010, 46011, 46012], - tags: { - '#p': [myPk], - }, - limit: 20, - ), + NostrFilter( + kinds: const [46010, 46011, 46012], + tags: { + '#p': [myPk], + }, + limit: 20, ), // Agent job lifecycle events addressed to me. - session.fetchHistory( - NostrFilter( - kinds: const [43001, 43002, 43003, 43004, 43005, 43006], - tags: { - '#p': [myPk], - }, - limit: 20, - ), + NostrFilter( + kinds: const [43001, 43002, 43003, 43004, 43005, 43006], + tags: { + '#p': [myPk], + }, + limit: 20, ), // Recent DM traffic (filtered to other senders below). - if (dmChannelIds.isEmpty) - Future.value(const []) - else - session.fetchHistory( - NostrFilter(kinds: const [9], tags: {'#h': dmChannelIds}, limit: 30), - ), - ]); + if (dmChannelIds.isNotEmpty) + NostrFilter(kinds: const [9], tags: {'#h': dmChannelIds}, limit: 30), + ]; + + // The HTTP bridge keeps each NIP-01 filter's independent limit while + // executing the batch with bounded server-side concurrency. One request + // here replaces the four simultaneous websocket history subscriptions that + // otherwise compete with channel and preference startup sync. + final events = await _queryWithWebSocketFallback(session, filters); bool isFromOther(NostrEvent e) => e.pubkey.toLowerCase() != myPk.toLowerCase(); + bool isAddressedToMe(NostrEvent event) => event.tags.any( + (tag) => + tag.length > 1 && + tag[0] == 'p' && + tag[1].toLowerCase() == myPk.toLowerCase(), + ); + + const mentionKinds = {9, 40002, 1, 45001, 45003}; + const needsActionKinds = {46010, 46011, 46012}; + const agentActivityKinds = {43001, 43002, 43003, 43004, 43005, 43006}; + final dmChannelIdSet = dmChannelIds.toSet(); // Dedupe across sources by event id, keeping the higher-priority // category (needs_action > mention > agent_activity > activity). @@ -271,10 +278,38 @@ class ActivityNotifier extends AsyncNotifier { } } - add(results[1], 'needs_action'); - add(results[0].where(isFromOther), 'mention'); - add(results[2], 'agent_activity'); - add(results[3].where(isFromOther), 'activity'); + add( + events.where( + (event) => + needsActionKinds.contains(event.kind) && isAddressedToMe(event), + ), + 'needs_action', + ); + add( + events.where( + (event) => + mentionKinds.contains(event.kind) && + isAddressedToMe(event) && + isFromOther(event), + ), + 'mention', + ); + add( + events.where( + (event) => + agentActivityKinds.contains(event.kind) && isAddressedToMe(event), + ), + 'agent_activity', + ); + add( + events.where( + (event) => + event.kind == 9 && + dmChannelIdSet.contains(event.channelId) && + isFromOther(event), + ), + 'activity', + ); final items = byId.values.toList() ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); @@ -299,6 +334,41 @@ class ActivityNotifier extends AsyncNotifier { ); } + Future> _queryWithWebSocketFallback( + RelaySessionNotifier session, + List filters, + ) async { + try { + return await session.queryRelay(filters); + } catch (error) { + debugPrint( + '[ActivityNotifier] batched history query failed; ' + 'using bounded websocket fallback: $error', + ); + } + + const fallbackConcurrency = 4; + final events = []; + for (var start = 0; start < filters.length; start += fallbackConcurrency) { + final end = start + fallbackConcurrency < filters.length + ? start + fallbackConcurrency + : filters.length; + final results = await Future.wait( + filters.sublist(start, end).map((filter) async { + try { + return await session.fetchHistory(filter); + } catch (_) { + return const []; + } + }), + ); + for (final result in results) { + events.addAll(result); + } + } + return events; + } + FeedItem _feedItem(NostrEvent event, {required String category}) { return FeedItem( id: event.id, diff --git a/mobile/lib/features/channels/channel_actions_sheet.dart b/mobile/lib/features/channels/channel_actions_sheet.dart index 4e821ecc07..99dc2ebf47 100644 --- a/mobile/lib/features/channels/channel_actions_sheet.dart +++ b/mobile/lib/features/channels/channel_actions_sheet.dart @@ -200,6 +200,7 @@ class ChannelActionsSheet extends ConsumerWidget { onTap: () async { final shouldClose = await showBuzzModalBottomSheet( context: context, + title: 'Manage channel', isScrollControlled: true, showDragHandle: true, constraints: BoxConstraints( diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index c0b64f3e40..7cf3532ac4 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -9,6 +9,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; +import '../../shared/animated_avatar.dart'; import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; @@ -17,6 +18,7 @@ import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/keyboard_dismiss_on_drag.dart'; +import '../../shared/widgets/masked_avatar_badge.dart'; import '../../shared/widgets/message_author_meta.dart'; import '../../shared/widgets/modal_presentation.dart'; import '../../shared/widgets/skeleton.dart'; @@ -338,11 +340,12 @@ class ChannelDetailPage extends HookConsumerWidget { ], ), actions: [ - _MembersButton( - channelId: resolvedChannel.id, - channel: resolvedChannel, - currentPubkey: currentPubkey, - ), + if (_showsMembersAction(resolvedChannel)) + _MembersButton( + channelId: resolvedChannel.id, + channel: resolvedChannel, + currentPubkey: currentPubkey, + ), IconButton( color: context.colors.primary, onPressed: () async { @@ -524,6 +527,7 @@ class ChannelDetailPage extends HookConsumerWidget { channelId: channel.id, content: content, mentionPubkeys: mentionPubkeys, + channel: resolvedChannel, mediaTags: mediaTags, ), ), diff --git a/mobile/lib/features/channels/channel_detail_page/app_bar.dart b/mobile/lib/features/channels/channel_detail_page/app_bar.dart index fe861bf0b9..b3e1271e98 100644 --- a/mobile/lib/features/channels/channel_detail_page/app_bar.dart +++ b/mobile/lib/features/channels/channel_detail_page/app_bar.dart @@ -1,5 +1,16 @@ part of '../channel_detail_page.dart'; +const _dmHeaderAvatarSize = 32.0; +const _dmPresenceDotRatio = 8 / 14; + +bool _showsMembersAction(Channel channel) { + if (!channel.isDm) return true; + final participants = channel.participantPubkeys + .map((pubkey) => pubkey.toLowerCase()) + .toSet(); + return participants.length != 2; +} + double _scaledTextHeight(BuildContext context, TextStyle style) { final scaledFontSize = MediaQuery.textScalerOf( context, @@ -8,15 +19,15 @@ double _scaledTextHeight(BuildContext context, TextStyle style) { } double _dmAppBarTitleContentHeight(BuildContext context) { - const titleStyle = channelTitleTextStyle; - final presenceStyle = context.textTheme.bodySmall; - if (presenceStyle == null) { - return 30; + final titleStyle = context.textTheme.titleSmall; + final presenceStyle = context.textTheme.bodyMedium; + if (titleStyle == null || presenceStyle == null) { + return _dmHeaderAvatarSize; } final textHeight = _scaledTextHeight(context, titleStyle) + _scaledTextHeight(context, presenceStyle); - return textHeight > 30 ? textHeight : 30; + return textHeight > _dmHeaderAvatarSize ? textHeight : _dmHeaderAvatarSize; } class _MembersButton extends ConsumerWidget { @@ -41,6 +52,7 @@ class _MembersButton extends ConsumerWidget { onPressed: () { showBuzzModalBottomSheet( context: context, + title: 'Members', isScrollControlled: true, showDragHandle: true, builder: (_) => @@ -80,8 +92,6 @@ class _DmAppBarTitle extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final profiles = ref.watch(userCacheProvider); - final presenceMap = ref.watch(presenceCacheProvider); final normalizedCurrent = currentPubkey?.toLowerCase(); String? otherPubkey; @@ -92,7 +102,18 @@ class _DmAppBarTitle extends ConsumerWidget { } } - final profile = otherPubkey != null ? profiles[otherPubkey] : null; + final profile = ref.watch( + userCacheProvider.select( + (profiles) => otherPubkey == null ? null : profiles[otherPubkey], + ), + ); + final presence = ref.watch( + presenceCacheProvider.select( + (presenceMap) => otherPubkey == null + ? 'offline' + : (presenceMap[otherPubkey] ?? 'offline'), + ), + ); if (otherPubkey != null) { if (profile == null) { @@ -102,14 +123,12 @@ class _DmAppBarTitle extends ConsumerWidget { } final avatarUrl = profile?.avatarUrl; + final animatedAvatar = parseAnimatedAvatarUrl(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', @@ -118,16 +137,17 @@ class _DmAppBarTitle extends ConsumerWidget { return Row( children: [ - SizedBox( - width: 30, - height: 30, - child: Stack( - clipBehavior: Clip.none, - children: [ - AvatarImage( - imageUrl: avatarUrl, - radius: 14, - backgroundColor: context.colors.primaryContainer, + MaskedAvatarBadge( + key: const ValueKey('dm-header-avatar'), + size: _dmHeaderAvatarSize, + geometry: AvatarBadgeMaskGeometry.presenceDot, + avatar: ClipOval( + child: ColoredBox( + color: animatedAvatar == null + ? context.colors.primaryContainer + : Colors.transparent, + child: AvatarImageContent( + imageUrl: animatedAvatar?.posterUrl ?? avatarUrl, fallback: Text( initial, style: context.textTheme.labelSmall?.copyWith( @@ -136,27 +156,23 @@ class _DmAppBarTitle extends ConsumerWidget { ), ), ), - 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.colors.surface, - width: 1.5, - ), - ), + ), + ), + badge: Center( + child: FractionallySizedBox( + widthFactor: _dmPresenceDotRatio, + heightFactor: _dmPresenceDotRatio, + child: DecoratedBox( + decoration: BoxDecoration( + color: switch (presence) { + 'online' => context.appColors.success, + 'away' => context.appColors.warning, + _ => context.colors.outline, + }, + shape: BoxShape.circle, ), ), - ], + ), ), ), const SizedBox(width: Grid.xxs), @@ -176,7 +192,8 @@ class _DmAppBarTitle extends ConsumerWidget { ), maxLines: 1, overflow: TextOverflow.ellipsis, - style: channelTitleTextStyle, + key: const ValueKey('dm-header-name'), + style: context.textTheme.titleSmall, ), ), if (channel.isEphemeral) ...[ @@ -187,7 +204,8 @@ class _DmAppBarTitle extends ConsumerWidget { ), Text( presenceLabel, - style: context.textTheme.bodySmall?.copyWith( + key: const ValueKey('dm-header-presence'), + style: context.textTheme.bodyMedium?.copyWith( color: context.colors.onSurfaceVariant, ), ), diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index ac14f1d0a1..87673b3cbe 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -34,22 +34,30 @@ class _MessageBubble extends ConsumerWidget { (profile?.ownerPubkey != null && profile?.ownerPubkey == currentPubkey?.toLowerCase()); - // Build mention names map from event p-tags. - final userCache = ref.watch(userCacheProvider); + // Watch only profiles referenced by this message. A batched profile fetch + // should not rebuild every visible message just because an unrelated user + // was added to the shared cache. + final normalizedMentionPubkeys = { + for (final pubkey in message.mentionPubkeys) pubkey.toLowerCase(), + }; + final mentionProfiles = { + for (final pubkey in normalizedMentionPubkeys) + pubkey: ref.watch(userCacheProvider.select((cache) => cache[pubkey])), + }; final knownAgentPubkeys = agentPubkeysWithProfileOwners( knownAgentPubkeys: ref.watch( agentMentionPubkeysProvider(currentChannelId), ), profileOwnedAgentPubkeys: [ - for (final profile in userCache.values) - if (profile.ownerPubkey != null) profile.pubkey, + for (final entry in mentionProfiles.entries) + if (entry.value?.ownerPubkey != null) entry.key, ], ); final mentionNames = {}; final agentMentionPubkeys = {}; for (final mpk in message.mentionPubkeys) { final normalizedPubkey = mpk.toLowerCase(); - final p = userCache[normalizedPubkey]; + final p = mentionProfiles[normalizedPubkey]; if (p?.displayName != null) { mentionNames[normalizedPubkey] = p!.displayName!; } diff --git a/mobile/lib/features/channels/channel_typing_indicator.dart b/mobile/lib/features/channels/channel_typing_indicator.dart index 0543f13b06..698144ce72 100644 --- a/mobile/lib/features/channels/channel_typing_indicator.dart +++ b/mobile/lib/features/channels/channel_typing_indicator.dart @@ -16,10 +16,19 @@ class ChannelTypingIndicator extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final userCache = ref.watch(userCacheProvider); + final normalizedPubkeys = { + for (final entry in entries) entry.pubkey.toLowerCase(), + }; + final profiles = { + for (final pubkey in normalizedPubkeys) + pubkey: ref.watch(userCacheProvider.select((cache) => cache[pubkey])), + }; + final userCache = { + for (final entry in profiles.entries) entry.key: ?entry.value, + }; final names = entries.map((entry) { final profile = - userCache[entry.pubkey.toLowerCase()] ?? + profiles[entry.pubkey.toLowerCase()] ?? ref.read(userCacheProvider.notifier).get(entry.pubkey.toLowerCase()); return profile?.label ?? shortPubkey(entry.pubkey); }).toList(); diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 5ecc21a73a..c77ef278ec 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -19,6 +19,7 @@ import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/anchored_popover_menu.dart'; import '../../shared/widgets/bee_refresh_indicator.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; +import '../../shared/widgets/buzz_titled_sheet_layout.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/modal_presentation.dart'; @@ -303,7 +304,8 @@ class ChannelsPage extends HookConsumerWidget { ref.invalidate(communityIconProvider); showBuzzModalBottomSheet( context: context, - showDragHandle: true, + showCloseButton: false, + showDragHandle: false, builder: (_) => const _CommunitySwitcherSheet(), ); } diff --git a/mobile/lib/features/channels/channels_page/channel_tile.dart b/mobile/lib/features/channels/channels_page/channel_tile.dart index f7344086e5..04d6b38d73 100644 --- a/mobile/lib/features/channels/channels_page/channel_tile.dart +++ b/mobile/lib/features/channels/channels_page/channel_tile.dart @@ -137,8 +137,6 @@ class _DmAvatar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final profiles = ref.watch(userCacheProvider); - final presenceMap = ref.watch(presenceCacheProvider); final normalizedCurrent = currentPubkey?.toLowerCase(); final otherPubkeys = [ for (final pk in channel.participantPubkeys) @@ -171,7 +169,18 @@ class _DmAvatar extends ConsumerWidget { } final otherPubkey = visiblePubkeys.isNotEmpty ? visiblePubkeys.first : null; - final profile = otherPubkey != null ? profiles[otherPubkey] : null; + final profile = ref.watch( + userCacheProvider.select( + (profiles) => otherPubkey == null ? null : profiles[otherPubkey], + ), + ); + final presence = ref.watch( + presenceCacheProvider.select( + (presenceMap) => otherPubkey == null + ? 'offline' + : (presenceMap[otherPubkey] ?? 'offline'), + ), + ); // Trigger fetches if not cached yet. if (otherPubkey != null) { @@ -187,10 +196,6 @@ class _DmAvatar extends ConsumerWidget { (channel.participants.isNotEmpty ? channel.participants.first[0].toUpperCase() : '?'); - final presence = otherPubkey != null - ? (presenceMap[otherPubkey] ?? 'offline') - : 'offline'; - return SizedBox( width: _kDmAvatarSize, height: _kDmAvatarSize, diff --git a/mobile/lib/features/channels/channels_page/community.dart b/mobile/lib/features/channels/channels_page/community.dart index 19f0efe5da..bd64ccc726 100644 --- a/mobile/lib/features/channels/channels_page/community.dart +++ b/mobile/lib/features/channels/channels_page/community.dart @@ -10,149 +10,109 @@ class _CommunitySwitcherSheet extends HookConsumerWidget { final isEditing = useState(false); return SafeArea( - child: Column( + child: BuzzTitledSheetLayout( key: const Key('community-switcher-sheet'), - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - Grid.xxs, - Grid.gutter, - Grid.xxs, + title: 'Switch Community', + titleKey: const Key('community-switcher-title'), + showDragHandle: true, + trailing: SizedBox( + key: const Key('community-switcher-edit'), + width: 56, + height: 44, + child: TextButton( + onPressed: () => isEditing.value = !isEditing.value, + style: TextButton.styleFrom( + minimumSize: const Size(56, 44), + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: 32), - child: Row( - children: [ - Expanded( - child: Text( - 'Switch Community', - key: const Key('community-switcher-title'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ), - Padding( - padding: const EdgeInsets.only(right: Grid.half), - child: ConstrainedBox( - key: const Key('community-switcher-edit'), - constraints: const BoxConstraints( - minWidth: 48, - minHeight: 32, - ), - child: TextButton( - onPressed: () => isEditing.value = !isEditing.value, - style: TextButton.styleFrom( - minimumSize: Size.zero, - padding: const EdgeInsets.symmetric( - horizontal: Grid.half, - ), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: Text(isEditing.value ? 'Done' : 'Edit'), - ), - ), - ), - ], + child: Text(isEditing.value ? 'Done' : 'Edit'), + ), + ), + child: communitiesAsync.when( + loading: () => const SizedBox( + height: 120, + child: Center( + child: BuzzLoadingIndicator( + size: 40, + semanticLabel: 'Loading communities', ), ), ), - Flexible( - child: communitiesAsync.when( - loading: () => const SizedBox( - height: 120, - child: Center( - child: BuzzLoadingIndicator( - size: 40, - semanticLabel: 'Loading communities', + error: (e, _) => Padding( + padding: const EdgeInsets.all(Grid.xs), + child: Text('Error loading communities: $e'), + ), + data: (communities) { + final activeId = activeAsync.value?.id; + return SingleChildScrollView( + padding: const EdgeInsets.only(bottom: Grid.xs), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.gutter), + child: Material( + key: const Key('community-switcher-options'), + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.card), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + for ( + var index = 0; + index < communities.length; + index++ + ) ...[ + if (index > 0) const _CommunitySwitcherDivider(), + _CommunitySwitcherTile( + community: communities[index], + isActive: communities[index].id == activeId, + isEditing: isEditing.value, + onTap: isEditing.value + ? null + : () async { + final community = communities[index]; + if (community.id != activeId) { + await ref + .read(communityListProvider.notifier) + .switchCommunity(community.id); + } + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + onRemove: () => _confirmRemoveCommunity( + context, + ref, + communities[index], + closeSheetAfterRemoval: + communities[index].id == activeId, + ), + ), + ], + if (communities.isNotEmpty) + const _CommunitySwitcherDivider(), + _AddCommunityTile( + onTap: () { + final nav = Navigator.of( + context, + rootNavigator: true, + ); + ref.read(pairingProvider.notifier).reset(); + Navigator.of(context).pop(); + nav.push( + MaterialPageRoute( + builder: (_) => + const PairingPage(addingCommunity: true), + ), + ); + }, + ), + ], ), ), ), - error: (e, _) => Padding( - padding: const EdgeInsets.all(Grid.xs), - child: Text('Error loading communities: $e'), - ), - data: (communities) { - final activeId = activeAsync.value?.id; - return SingleChildScrollView( - padding: const EdgeInsets.only(bottom: Grid.xs), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.gutter, - ), - child: Material( - key: const Key('community-switcher-options'), - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.card), - clipBehavior: Clip.antiAlias, - child: Column( - children: [ - for ( - var index = 0; - index < communities.length; - index++ - ) ...[ - if (index > 0) const _CommunitySwitcherDivider(), - _CommunitySwitcherTile( - community: communities[index], - isActive: communities[index].id == activeId, - isEditing: isEditing.value, - onTap: isEditing.value - ? null - : () async { - final community = communities[index]; - if (community.id != activeId) { - await ref - .read( - communityListProvider.notifier, - ) - .switchCommunity(community.id); - } - if (context.mounted) { - Navigator.of(context).pop(); - } - }, - onRemove: () => _confirmRemoveCommunity( - context, - ref, - communities[index], - closeSheetAfterRemoval: - communities[index].id == activeId, - ), - ), - ], - if (communities.isNotEmpty) - const _CommunitySwitcherDivider(), - _AddCommunityTile( - onTap: () { - final nav = Navigator.of( - context, - rootNavigator: true, - ); - ref.read(pairingProvider.notifier).reset(); - Navigator.of(context).pop(); - nav.push( - MaterialPageRoute( - builder: (_) => - const PairingPage(addingCommunity: true), - ), - ); - }, - ), - ], - ), - ), - ), - ); - }, - ), - ), - ], + ); + }, + ), ), ); } diff --git a/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart b/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart index adf5cfd176..8517fa70b9 100644 --- a/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart +++ b/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart @@ -87,6 +87,7 @@ class ChannelQuickActionsLauncher extends HookConsumerWidget { case _QuickAction.createChannel: final created = await showBuzzModalBottomSheet( context: context, + title: 'Create a new channel', constraints: _quickActionSheetConstraints(context), isScrollControlled: true, showDragHandle: true, @@ -98,6 +99,7 @@ class ChannelQuickActionsLauncher extends HookConsumerWidget { case _QuickAction.newDm: final opened = await showBuzzModalBottomSheet( context: context, + title: 'New message', constraints: _quickActionSheetConstraints(context), isScrollControlled: true, showDragHandle: true, diff --git a/mobile/lib/features/channels/channels_page/sheets.dart b/mobile/lib/features/channels/channels_page/sheets.dart index 2738be0aa9..a17bcef476 100644 --- a/mobile/lib/features/channels/channels_page/sheets.dart +++ b/mobile/lib/features/channels/channels_page/sheets.dart @@ -91,14 +91,6 @@ class _CreateChannelSheet extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Create a new $kindLabel', - style: context.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - letterSpacing: -0.3, - ), - ), - const SizedBox(height: Grid.sm), _CreateChannelFieldLabel(label: 'Name'), const SizedBox(height: Grid.xxs), _CreateChannelFieldShell( @@ -537,14 +529,6 @@ class _NewDirectMessageSheet extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'New message', - style: context.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w600, - letterSpacing: -0.3, - ), - ), - const SizedBox(height: Grid.xs), GestureDetector( behavior: HitTestBehavior.translucent, onTap: isSubmitting.value ? null : queryFocusNode.requestFocus, diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 094f37b3f7..2f8dddf2d9 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -279,53 +279,34 @@ class ChannelsNotifier extends AsyncNotifier> { // see every channel as having no messages. Skipped on backstop refreshes since // live subscriptions keep lastMessageAt current after the initial load. if (fetchLastMessage) { - final lastMessageResults = await Future.wait( - channels.map((channel) async { - if (!channel.isMember || channel.isArchived) return null; - try { - if (channel.isDm) { - final events = await session.fetchHistory( - NostrFilter( - kinds: EventKind.channelMessageEventKinds, - tags: { - '#h': [channel.id], - }, - limit: 1, - ), - ); - if (events.isEmpty) return null; - return MapEntry(channel.id, events.first.createdAt); - } - final events = await session.fetchHistory( - NostrFilter( - kinds: EventKind.channelMessageEventKinds, - tags: { - '#h': [channel.id], - }, - limit: 20, - ), - ); - for (final event in events) { - if (shouldNotifyForEvent( - event, - myPk, - mutedChannelIds: _mutedChannelIds(), - channelId: channel.id, - )) { - return MapEntry(channel.id, event.createdAt); - } - } - return null; - } catch (_) { - return null; - } - }), - ); - + final activeChannels = [ + for (final channel in channels) + if (channel.isMember && !channel.isArchived) channel, + ]; + final channelById = { + for (final channel in activeChannels) channel.id: channel, + }; + final events = await _fetchLastMessageEvents(session, activeChannels); final lastMessageMap = {}; - for (final entry - in lastMessageResults.whereType>()) { - lastMessageMap[entry.key] = entry.value; + final mutedChannelIds = _mutedChannelIds(); + for (final event in events) { + final channelId = event.channelId; + if (channelId == null) continue; + final channel = channelById[channelId]; + if (channel == null) continue; + if (!channel.isDm && + !shouldNotifyForEvent( + event, + myPk, + mutedChannelIds: mutedChannelIds, + channelId: channelId, + )) { + continue; + } + final current = lastMessageMap[channelId]; + if (current == null || event.createdAt > current) { + lastMessageMap[channelId] = event.createdAt; + } } for (var i = 0; i < channels.length; i++) { @@ -410,6 +391,70 @@ class ChannelsNotifier extends AsyncNotifier> { _memberSnapshotsByChannelId = Map.unmodifiable(snapshots); } + /// Fetches each channel's independent latest-message window in one HTTP + /// bridge request. The relay preserves NIP-01 per-filter limits while + /// executing the filters with bounded concurrency, avoiding an unbounded + /// burst of websocket REQs on communities with many channels. + Future> _fetchLastMessageEvents( + RelaySessionNotifier session, + List channels, + ) async { + if (channels.isEmpty) return const []; + + final filters = [ + for (final channel in channels) + NostrFilter( + kinds: EventKind.channelMessageEventKinds, + tags: { + '#h': [channel.id], + }, + limit: channel.isDm ? 1 : 20, + ), + ]; + + return _fetchChannelHistoryBatch( + session, + filters, + operation: 'latest-message query', + ); + } + + Future> _fetchChannelHistoryBatch( + RelaySessionNotifier session, + List filters, { + required String operation, + }) async { + if (filters.isEmpty) return const []; + + try { + return await session.queryRelay(filters); + } catch (error) { + debugPrint( + '[ChannelsNotifier] batched $operation failed; ' + 'using bounded websocket fallback: $error', + ); + } + + const fallbackConcurrency = 4; + final events = []; + for (var start = 0; start < filters.length; start += fallbackConcurrency) { + final end = min(start + fallbackConcurrency, filters.length); + final results = await Future.wait( + filters.sublist(start, end).map((filter) async { + try { + return await session.fetchHistory(filter); + } catch (_) { + return const []; + } + }), + ); + for (final result in results) { + events.addAll(result); + } + } + return events; + } + Future> _fetchHiddenDmIds( RelaySessionNotifier session, String myPk, @@ -615,47 +660,34 @@ class ChannelsNotifier extends AsyncNotifier> { debugPrint('[ChannelsNotifier] unread catch-up skipped: $error'); return; } - final futures = >[]; - - for (final channel in channels) { - if (!channel.isMember || channel.isArchived) continue; - final readAt = readState.effectiveTimestamp(channel.id); - futures.add( - _catchUpUnreadEventsForChannel( - session, - channel, - myPk, - readAt, - mutedChannelIds, - ), - ); - } - - const batchSize = 5; - for (var i = 0; i < futures.length; i += batchSize) { - await Future.wait(futures.sublist(i, min(i + batchSize, futures.length))); - } - - state = state.whenData((channels) => List.of(channels)); - } - - Future _catchUpUnreadEventsForChannel( - RelaySessionNotifier session, - Channel channel, - String myPk, - int? readAt, - Set mutedChannelIds, - ) async { - try { - final events = await session.fetchHistory( + final activeChannels = [ + for (final channel in channels) + if (channel.isMember && !channel.isArchived) channel, + ]; + final channelById = { + for (final channel in activeChannels) channel.id: channel, + }; + final readAtByChannel = { + for (final channel in activeChannels) + channel.id: readState.effectiveTimestamp(channel.id), + }; + final filters = [ + for (final channel in activeChannels) NostrFilter( kinds: EventKind.channelMessageEventKinds, tags: { '#h': [channel.id], }, - since: readAt == null ? 0 : readAt + 1, + since: (readAtByChannel[channel.id] ?? -1) + 1, limit: _unreadCatchUpLimit, ), + ]; + + try { + final events = await _fetchChannelHistoryBatch( + session, + filters, + operation: 'unread catch-up', ); for (final event in events) { @@ -665,6 +697,11 @@ class ChannelsNotifier extends AsyncNotifier> { } for (final event in events) { + final channelId = event.channelId; + if (channelId == null) continue; + final channel = channelById[channelId]; + if (channel == null) continue; + final readAt = readAtByChannel[channelId]; if (event.pubkey.toLowerCase() == myPk.toLowerCase()) continue; if (readAt != null && event.createdAt <= readAt) continue; if (!shouldNotifyForEvent( @@ -681,10 +718,10 @@ class ChannelsNotifier extends AsyncNotifier> { _recordUnreadEvent(channel, event, myPk); } } catch (error) { - debugPrint( - '[ChannelsNotifier] unread catch-up failed for ${channel.id}: $error', - ); + debugPrint('[ChannelsNotifier] unread catch-up failed: $error'); } + + state = state.whenData((channels) => List.of(channels)); } void _handleLiveEvent(NostrEvent event) { diff --git a/mobile/lib/features/channels/manage_channel_sheet.dart b/mobile/lib/features/channels/manage_channel_sheet.dart index 15ad07b0cd..3f0d612745 100644 --- a/mobile/lib/features/channels/manage_channel_sheet.dart +++ b/mobile/lib/features/channels/manage_channel_sheet.dart @@ -109,8 +109,6 @@ class ManageChannelSheet extends HookConsumerWidget { child: ListView( shrinkWrap: true, children: [ - Text('Manage channel', style: context.textTheme.titleMedium), - const SizedBox(height: Grid.xxs), Text( 'Basic management for ${channel.name}.', style: context.textTheme.bodySmall?.copyWith( diff --git a/mobile/lib/features/channels/members_sheet.dart b/mobile/lib/features/channels/members_sheet.dart index 24c80daa5f..54215ccd75 100644 --- a/mobile/lib/features/channels/members_sheet.dart +++ b/mobile/lib/features/channels/members_sheet.dart @@ -35,6 +35,7 @@ class MembersSheet extends HookConsumerWidget { final userCache = ref.watch(userCacheProvider); final typingBotPubkeys = ref.watch(workingBotPubkeysProvider(channel.id)); final statusCache = ref.watch(userStatusCacheProvider); + final bottomClearance = Grid.md + MediaQuery.viewPaddingOf(context).bottom; // Determine if the current user can manage members. final currentMember = allMembers.cast().firstWhere( @@ -82,93 +83,78 @@ class MembersSheet extends HookConsumerWidget { }, [allMembers.length]); return Padding( - padding: EdgeInsets.fromLTRB( - Grid.gutter, - 0, - Grid.gutter, - MediaQuery.viewInsetsOf(context).bottom, - ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Members', style: context.textTheme.titleMedium), - const SizedBox(height: Grid.xxs), - if (!channel.isDm) ...[const Divider(height: 1)], - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 400), - child: membersAsync.when( - data: (_) => ListView( - shrinkWrap: true, - padding: const EdgeInsets.only(top: Grid.xxs), - 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, - userStatus: statusCache[member.pubkey.toLowerCase()], - ), - ], - 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, - isWorking: typingBotPubkeys.contains( - bot.pubkey.toLowerCase(), - ), - onViewActivity: () => openActivity(bot), - onActivityTap: - typingBotPubkeys.contains( - bot.pubkey.toLowerCase(), - ) - ? () => openActivity(bot) - : null, - ), - ], - if (people.isEmpty && bots.isEmpty) - Center( - child: Text( - 'No members found.', - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ), - ], - ), - loading: () => const Center( - child: BuzzLoadingIndicator( - size: 44, - semanticLabel: 'Loading members', + key: const ValueKey('members-sheet-content-padding'), + padding: EdgeInsets.fromLTRB(Grid.gutter, 0, Grid.gutter, 0), + child: ConstrainedBox( + key: const ValueKey('members-sheet-viewport'), + constraints: BoxConstraints(maxHeight: 400 + bottomClearance), + child: membersAsync.when( + data: (_) => ListView( + key: const ValueKey('members-sheet-list'), + shrinkWrap: true, + padding: EdgeInsets.only(top: Grid.xxs, bottom: bottomClearance), + 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, + userStatus: statusCache[member.pubkey.toLowerCase()], ), - ), - error: (error, _) => Center( + ], + if (bots.isNotEmpty) ...[ + const SizedBox(height: Grid.xxs), + _SectionLabel(label: 'Agents · ${bots.length}'), + for (final bot in bots) + _MemberTile( + member: bot, + currentPubkey: currentPubkey, + profile: userCache[bot.pubkey.toLowerCase()], + canManage: canManage, + isSelf: false, + channelId: channel.id, + isWorking: typingBotPubkeys.contains( + bot.pubkey.toLowerCase(), + ), + onViewActivity: () => openActivity(bot), + onActivityTap: + typingBotPubkeys.contains(bot.pubkey.toLowerCase()) + ? () => openActivity(bot) + : null, + ), + ], + if (people.isEmpty && bots.isEmpty) + Center( child: Text( - error.toString(), + 'No members found.', style: context.textTheme.bodySmall?.copyWith( - color: context.colors.error, + color: context.colors.onSurfaceVariant, ), ), ), + ], + ), + loading: () => const Center( + child: BuzzLoadingIndicator( + size: 44, + semanticLabel: 'Loading members', + ), + ), + error: (error, _) => Center( + child: Text( + error.toString(), + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, ), ), - ], + ), ), ), ); @@ -185,11 +171,10 @@ class _SectionLabel extends StatelessWidget { return Padding( padding: const EdgeInsets.only(top: Grid.half, bottom: Grid.half), child: Text( - label.toUpperCase(), - style: context.textTheme.labelSmall?.copyWith( + label, + style: context.textTheme.labelMedium?.copyWith( color: context.colors.onSurfaceVariant, fontWeight: FontWeight.w600, - letterSpacing: 0.8, ), ), ); @@ -305,17 +290,13 @@ class _MemberTile extends ConsumerWidget { final canChangeRole = showManagementActions && !member.isBot; showBuzzModalBottomSheet( context: context, + title: label, showDragHandle: true, builder: (sheetContext) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.gutter), - child: Text(label, style: context.textTheme.titleSmall), - ), - const SizedBox(height: Grid.xxs), if (onViewActivity != null) ListTile( leading: Icon( diff --git a/mobile/lib/features/channels/message_mention_pubkeys.dart b/mobile/lib/features/channels/message_mention_pubkeys.dart new file mode 100644 index 0000000000..59f28b903d --- /dev/null +++ b/mobile/lib/features/channels/message_mention_pubkeys.dart @@ -0,0 +1,26 @@ +import 'channel.dart'; + +/// Semantic recipients for an outgoing mobile message. +/// +/// Explicit mentions are always preserved. In a DM, every current recipient +/// is also addressed with a `p` tag without inserting visible `@mentions` into +/// the composer. Non-DM channels remain explicit-only. +List messageMentionPubkeys({ + required Channel channel, + required String? senderPubkey, + required Iterable explicitMentions, + required Iterable dmRecipientPubkeys, +}) { + final sender = senderPubkey?.toLowerCase(); + final candidates = [ + ...explicitMentions, + if (channel.isDm) ...dmRecipientPubkeys, + ]; + + final seen = {?sender}; + return [ + for (final candidate in candidates) + if (candidate.trim().isNotEmpty && seen.add(candidate.toLowerCase())) + candidate.toLowerCase(), + ]; +} diff --git a/mobile/lib/features/channels/send_message_provider.dart b/mobile/lib/features/channels/send_message_provider.dart index 730546807f..736ce0821e 100644 --- a/mobile/lib/features/channels/send_message_provider.dart +++ b/mobile/lib/features/channels/send_message_provider.dart @@ -4,7 +4,9 @@ import '../../shared/relay/relay.dart'; import '../channels/channel_management_provider.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; +import 'channel.dart'; import 'channel_messages_provider.dart'; +import 'message_mention_pubkeys.dart'; /// Sends messages by signing an event with the user's nsec and publishing it /// over the relay's NIP-42-authenticated WebSocket session. @@ -48,14 +50,26 @@ class SendMessage { String? parentEventId, String? rootEventId, List? mentionPubkeys, + Channel? channel, List> mediaTags = const [], }) async { _ensureDeliveryValid(); // Use explicitly passed pubkeys, or resolve @mentions against // channel members to avoid matching the wrong user. - final resolvedMentions = + final explicitMentions = mentionPubkeys ?? await _resolveMentions(content, channelId); final authorPubkey = _signedEventRelay.pubkey; + final dmRecipientPubkeys = channel?.isDm == true + ? await _fetchDmRecipientPubkeys(channelId, channel!, authorPubkey) + : null; + final resolvedMentions = dmRecipientPubkeys != null + ? messageMentionPubkeys( + channel: channel!, + senderPubkey: authorPubkey, + explicitMentions: explicitMentions, + dmRecipientPubkeys: dmRecipientPubkeys, + ) + : explicitMentions; // Normalize mentions: lowercase, deduplicate, exclude self (matching // the desktop's normalizeMentionPubkeys). @@ -94,6 +108,36 @@ class SendMessage { } } + /// Resolve every identity that is actually a current member of this DM. + /// + /// Membership is authoritative for delivery. The channel metadata's `p` + /// tags can lag membership changes, so they are only used when the membership + /// snapshot is unavailable. + Future> _fetchDmRecipientPubkeys( + String channelId, + Channel channel, + String? authorPubkey, + ) async { + List? members; + try { + members = await _fetchMembers(channelId); + } catch (_) { + // Fall back to metadata below so an unavailable membership query does + // not block ordinary DM sends. + } + + final author = authorPubkey?.toLowerCase(); + final participants = members != null && members.isNotEmpty + ? members.map((member) => member.pubkey) + : channel.participantPubkeys; + return { + for (final participant in participants) + if (participant.trim().isNotEmpty && + participant.toLowerCase() != author) + participant.toLowerCase(), + }; + } + void _ensureDeliveryValid() { if (_isDeliveryValid?.call() == false) { throw StateError( diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 9de8dc861f..fcd89b767a 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -375,6 +375,9 @@ class ThreadDetailPage extends HookConsumerWidget { }, [itemScrollController, replies.length]); final channelsAsync = ref.watch(channelsProvider); + final channel = channelsAsync.value + ?.where((candidate) => candidate.id == channelId) + .firstOrNull; final channelNamesMap = {}; channelsAsync.whenData((channels) { for (final ch in channels) { @@ -583,6 +586,7 @@ class ThreadDetailPage extends HookConsumerWidget { channelId: channelId, content: content, mentionPubkeys: mentionPubkeys, + channel: channel, parentEventId: threadHead.id, rootEventId: effectiveRootId, mediaTags: mediaTags, diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index c691310df5..1026a51a01 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -64,6 +64,7 @@ class HomePage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final tabIndex = useState(0); + final visitedTabs = useRef({0}); final tabContentTransitionDirection = useRef(1.0); final tabContentTransitionController = useAnimationController( duration: _tabContentTransitionDuration, @@ -96,8 +97,14 @@ class HomePage extends HookConsumerWidget { } }, ), - ActivityPage(tabReselection: activityReselection), - SearchPage(tabReselection: searchReselection), + if (visitedTabs.value.contains(1)) + ActivityPage(tabReselection: activityReselection) + else + const SizedBox.shrink(), + if (visitedTabs.value.contains(2)) + SearchPage(tabReselection: searchReselection) + else + const SizedBox.shrink(), ]; final settingsTransitionGradient = tabIndex.value == 0 @@ -195,6 +202,7 @@ class HomePage extends HookConsumerWidget { ? 1 : -1; unawaited(HapticFeedback.selectionClick()); + visitedTabs.value.add(i); tabIndex.value = i; if (reducedMotion) { tabContentTransitionController.value = 1; diff --git a/mobile/lib/features/profile/profile_avatar.dart b/mobile/lib/features/profile/profile_avatar.dart index 087cc909aa..3e98210ffd 100644 --- a/mobile/lib/features/profile/profile_avatar.dart +++ b/mobile/lib/features/profile/profile_avatar.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../shared/animated_avatar.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/masked_avatar_badge.dart'; @@ -63,6 +64,7 @@ class ProfileAvatar extends ConsumerWidget { UserProfile? profile, String presence, ) { + final animatedAvatar = parseAnimatedAvatarUrl(profile?.avatarUrl); return GestureDetector( onTap: onTap, child: MaskedAvatarBadge( @@ -70,9 +72,12 @@ class ProfileAvatar extends ConsumerWidget { geometry: AvatarBadgeMaskGeometry.presenceDot, avatar: ClipOval( child: ColoredBox( - color: context.colors.primaryContainer, + key: const ValueKey('profile-avatar-background'), + color: animatedAvatar == null + ? context.colors.primaryContainer + : Colors.transparent, child: AvatarImageContent( - imageUrl: profile?.avatarUrl, + imageUrl: animatedAvatar?.posterUrl ?? profile?.avatarUrl, fallback: Text( profile?.initial ?? '?', style: context.textTheme.labelMedium?.copyWith( diff --git a/mobile/lib/features/profile/set_status_sheet.dart b/mobile/lib/features/profile/set_status_sheet.dart index 7419548b62..b3d2b4b0af 100644 --- a/mobile/lib/features/profile/set_status_sheet.dart +++ b/mobile/lib/features/profile/set_status_sheet.dart @@ -1,40 +1,62 @@ -import 'dart:math' as math; - +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:intl/intl.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/custom_emoji/custom_emoji.dart'; import '../../shared/custom_emoji/custom_emoji_provider.dart'; import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/app_list.dart'; +import '../../shared/widgets/app_list_card.dart'; +import '../../shared/widgets/buzz_titled_sheet_layout.dart'; import '../../shared/widgets/modal_presentation.dart'; import '../channels/emoji_picker.dart'; import 'user_status.dart'; import 'user_status_provider.dart'; -/// The emoji well and the text field are sized to be the two things you reach -/// for, so they carry no borders — the sheet has no other controls to compete -/// with them. -const _emojiWellSize = 56.0; -const _emojiGlyphSize = 32.0; -const _saveButtonHeight = 52.0; +const _emojiWellSize = 48.0; +const _emojiGlyphSize = 28.0; + +const _statusPresets = [ + (text: 'In a meeting', emoji: '\u{1F5E3}\u{FE0F}'), + (text: 'Commuting', emoji: '\u{1F68C}'), + (text: 'Out sick', emoji: '\u{1F912}'), + (text: 'Vacationing', emoji: '\u{1F3D6}\u{FE0F}'), + (text: 'Working remotely', emoji: '\u{1F3E0}'), +]; + +enum _StatusDuration { + oneHour('1 hour', Duration(hours: 1)), + eightHours('8 hours', Duration(hours: 8)), + oneDay('1 day', Duration(days: 1)), + oneWeek('1 week', Duration(days: 7)), + custom('Custom', null); + + const _StatusDuration(this.label, this.duration); + + final String label; + final Duration? duration; +} void showSetStatusSheet(BuildContext context, {UserStatus? currentStatus}) { showBuzzModalBottomSheet( context: context, isScrollControlled: true, - showDragHandle: true, + showCloseButton: false, + showDragHandle: false, builder: (_) => _SetStatusSheet(currentStatus: currentStatus), ); } class _SetStatusSheet extends HookConsumerWidget { - final UserStatus? currentStatus; - const _SetStatusSheet({this.currentStatus}); + final UserStatus? currentStatus; + @override Widget build(BuildContext context, WidgetRef ref) { final textController = useTextEditingController( @@ -42,6 +64,15 @@ class _SetStatusSheet extends HookConsumerWidget { ); final emoji = useState(currentStatus?.emoji ?? ''); final text = useState(currentStatus?.text ?? ''); + final currentExpiration = currentStatus?.expirationDateTime; + final duration = useState( + currentExpiration == null + ? _StatusDuration.oneDay + : _StatusDuration.custom, + ); + final customUntil = useState( + currentExpiration ?? DateTime.now().add(_StatusDuration.oneDay.duration!), + ); final isSaving = useState(false); useEffect(() { @@ -53,13 +84,18 @@ class _SetStatusSheet extends HookConsumerWidget { final hasContent = text.value.trim().isNotEmpty || emoji.value.isNotEmpty; final hasExistingStatus = currentStatus != null && !currentStatus!.isEmpty; + DateTime expiresAt() => switch (duration.value) { + _StatusDuration.custom => customUntil.value, + final preset => DateTime.now().add(preset.duration!), + }; + Future handleSave() async { - if (isSaving.value) return; + if (isSaving.value || !hasContent) return; isSaving.value = true; try { await ref .read(userStatusProvider.notifier) - .setStatus(text.value, emoji.value); + .setStatus(text.value, emoji.value, expiresAt: expiresAt()); if (context.mounted) Navigator.of(context).pop(); } finally { isSaving.value = false; @@ -77,135 +113,244 @@ class _SetStatusSheet extends HookConsumerWidget { } } - return Padding( - padding: EdgeInsets.fromLTRB( - Grid.gutter, - 0, - Grid.gutter, - // The sheet ends in the Save button, so it owns its own breathing room - // above whichever is taller: the keyboard, or the home indicator. - Grid.gutter + - math.max( - MediaQuery.viewInsetsOf(context).bottom, - MediaQuery.viewPaddingOf(context).bottom, - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Set a status', style: context.textTheme.titleMedium), - const SizedBox(height: Grid.half), - Text( - 'Let others know what you\u2019re up to.', - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - const SizedBox(height: Grid.twelve), + Future pickCustomUntil() async { + final picked = await _showNativeDateTimePicker( + context, + initial: customUntil.value, + ); + if (picked == null) return; + duration.value = _StatusDuration.custom; + customUntil.value = picked; + } - // The emoji well doubles as the picker's entry point, which is why - // there is no separate row of emoji suggestions below. - Row( + Future chooseDuration() async { + FocusScope.of(context).unfocus(); + final selected = await _showStatusDurationSheet( + context, + selected: duration.value, + ); + if (selected == null || !context.mounted) return; + if (selected == _StatusDuration.custom) { + await pickCustomUntil(); + } else { + duration.value = selected; + } + } + + final headerAction = SizedBox.square( + dimension: 44, + child: IconButton( + key: const ValueKey('save-status-button'), + tooltip: 'Save status', + onPressed: hasContent && !isSaving.value ? handleSave : null, + style: IconButton.styleFrom( + padding: EdgeInsets.zero, + backgroundColor: context.colors.surfaceContainerHighest, + foregroundColor: context.colors.onSurface, + disabledForegroundColor: context.colors.onSurfaceVariant.withValues( + alpha: 0.38, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.dialog), + ), + ), + icon: const Icon(LucideIcons.check, size: 22), + ), + ); + + return SafeArea( + top: false, + child: BuzzTitledSheetLayout( + title: 'Set a status', + showDragHandle: true, + leading: headerAction, + child: SingleChildScrollView( + padding: EdgeInsets.only( + // Keep the sheet at its resting height while the keyboard + // overlays its lower portion. Adding viewInsets here makes the + // modal itself grow upward by the full keyboard height. + bottom: Grid.gutter + MediaQuery.viewPaddingOf(context).bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Stack( - clipBehavior: Clip.none, - children: [ - Semantics( - button: true, - label: 'Choose a status emoji', - child: InkWell( - borderRadius: BorderRadius.circular(Radii.lg), - onTap: () => showEmojiPicker( - context: context, - onSelect: (value) => emoji.value = value, - ), - child: SizedBox.square( - dimension: _emojiWellSize, - child: Center( - child: _StatusEmojiPreview(emoji: emoji.value), - ), - ), - ), + Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xs, + ), + child: _StatusInput( + controller: textController, + emoji: emoji.value, + enabled: !isSaving.value, + onChooseEmoji: () => showEmojiPicker( + context: context, + onSelect: (value) => emoji.value = value, ), - if (emoji.value.isNotEmpty) - Positioned( - top: -Grid.quarter, - right: -Grid.quarter, - child: SizedBox.square( - dimension: Grid.sm, - child: IconButton( - onPressed: isSaving.value - ? null - : () => emoji.value = '', - tooltip: 'Remove status emoji', - visualDensity: VisualDensity.compact, - style: IconButton.styleFrom( - backgroundColor: context.colors.surface, - minimumSize: const Size.square(Grid.sm), - maximumSize: const Size.square(Grid.sm), - padding: EdgeInsets.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - icon: Icon( - LucideIcons.x, - size: 14, - color: context.colors.onSurface, - ), - ), - ), + onRemoveEmoji: () => emoji.value = '', + onSubmitted: handleSave, + ), + ), + AppListCard( + label: 'Duration', + children: [ + AppListRow( + icon: LucideIcons.clock3, + title: 'Duration', + value: duration.value.label, + trailing: const Icon(LucideIcons.chevronDown, size: 18), + onTap: chooseDuration, + ), + if (duration.value == _StatusDuration.custom) + AppListRow( + icon: LucideIcons.calendarClock, + title: 'Until', + value: _formatUntil(customUntil.value), + onTap: pickCustomUntil, ), ], ), - const SizedBox(width: Grid.half), - Expanded( - child: TextField( - controller: textController, - autofocus: true, - style: context.textTheme.titleMedium, - decoration: InputDecoration( - hintText: 'What\u2019s your status?', - hintStyle: context.textTheme.titleMedium?.copyWith( - color: context.colors.onSurfaceVariant, + AppListCard( + label: 'Quick statuses', + children: [ + for (final preset in _statusPresets) + AppListRowRaw( + leading: SizedBox( + width: 22, + child: Center( + child: Text( + preset.emoji, + style: const TextStyle(fontSize: 20), + ), + ), + ), + title: Text( + preset.text, + style: context.textTheme.bodyLarge, + ), + onTap: () { + textController.text = preset.text; + emoji.value = preset.emoji; + }, ), - // The theme outlines inputs; this one is the sheet's whole - // content, so every border state is cleared explicitly. - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - isDense: false, - contentPadding: EdgeInsets.zero, - ), - textInputAction: TextInputAction.done, - onSubmitted: (_) { - if (hasContent) handleSave(); - }, - ), + ], ), + if (hasExistingStatus) + AppListCard( + children: [ + AppListRow( + icon: LucideIcons.trash2, + title: 'Clear status', + titleColor: context.colors.error, + onTap: isSaving.value ? null : handleClear, + ), + ], + ), ], ), - const SizedBox(height: Grid.gutter), + ), + ), + ); + } +} - SizedBox( - width: double.infinity, - height: _saveButtonHeight, - child: FilledButton( - onPressed: hasContent && !isSaving.value ? handleSave : null, - child: const Text('Save'), +class _StatusInput extends ConsumerWidget { + const _StatusInput({ + required this.controller, + required this.emoji, + required this.enabled, + required this.onChooseEmoji, + required this.onRemoveEmoji, + required this.onSubmitted, + }); + + final TextEditingController controller; + final String emoji; + final bool enabled; + final VoidCallback onChooseEmoji; + final VoidCallback onRemoveEmoji; + final VoidCallback onSubmitted; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return DecoratedBox( + key: const ValueKey('status-input-outline'), + decoration: BoxDecoration( + border: Border.all( + color: context.colors.outlineVariant.withValues(alpha: 0.8), + ), + borderRadius: BorderRadius.circular(Radii.card), + ), + child: Row( + children: [ + SizedBox.square( + dimension: _emojiWellSize, + child: Stack( + clipBehavior: Clip.none, + children: [ + Semantics( + button: true, + label: 'Choose a status emoji', + child: InkWell( + borderRadius: BorderRadius.circular(Radii.card), + onTap: enabled ? onChooseEmoji : null, + child: Center(child: _StatusEmojiPreview(emoji: emoji)), + ), + ), + if (emoji.isNotEmpty) + Positioned( + top: -Grid.half, + right: -Grid.half, + child: SizedBox.square( + dimension: Grid.sm, + child: IconButton( + onPressed: enabled ? onRemoveEmoji : null, + tooltip: 'Remove status emoji', + visualDensity: VisualDensity.compact, + style: IconButton.styleFrom( + backgroundColor: context.colors.surface, + minimumSize: const Size.square(Grid.sm), + maximumSize: const Size.square(Grid.sm), + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: Icon( + LucideIcons.x, + size: 14, + color: context.colors.onSurface, + ), + ), + ), + ), + ], ), ), - // No Cancel \u2014 the sheet dismisses by swiping down. - if (hasExistingStatus) - Padding( - padding: const EdgeInsets.only(top: Grid.half), - child: SizedBox( - width: double.infinity, - child: TextButton( - onPressed: isSaving.value ? null : handleClear, - child: const Text('Clear status'), + Expanded( + child: TextField( + controller: controller, + enabled: enabled, + autofocus: false, + style: context.textTheme.bodyLarge, + decoration: InputDecoration( + hintText: 'What\u2019s your status?', + hintStyle: context.textTheme.bodyLarge?.copyWith( + color: context.colors.onSurfaceVariant, + ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.only( + left: Grid.xxs, + right: Grid.xs, ), ), + textInputAction: TextInputAction.done, + onSubmitted: (_) => onSubmitted(), ), + ), ], ), ); @@ -213,10 +358,10 @@ class _SetStatusSheet extends HookConsumerWidget { } class _StatusEmojiPreview extends ConsumerWidget { - final String emoji; - const _StatusEmojiPreview({required this.emoji}); + final String emoji; + @override Widget build(BuildContext context, WidgetRef ref) { if (emoji.isEmpty) { @@ -242,3 +387,103 @@ class _StatusEmojiPreview extends ConsumerWidget { return Text(emoji, style: const TextStyle(fontSize: _emojiGlyphSize)); } } + +Future<_StatusDuration?> _showStatusDurationSheet( + BuildContext context, { + required _StatusDuration selected, +}) { + return showBuzzModalBottomSheet<_StatusDuration>( + context: context, + title: 'Duration', + isScrollControlled: true, + showDragHandle: true, + builder: (sheetContext) => SingleChildScrollView( + child: SafeArea( + top: false, + child: AppListCard( + children: [ + for (final option in _StatusDuration.values) + AppListRow( + title: option.label, + trailing: option == selected + ? const Icon(LucideIcons.check, size: 18) + : null, + onTap: () => Navigator.of(sheetContext).pop(option), + ), + ], + ), + ), + ), + ); +} + +Future _showNativeDateTimePicker( + BuildContext context, { + required DateTime initial, +}) async { + final now = DateTime.now(); + final minimum = now.add(const Duration(minutes: 5)); + final safeInitial = initial.isAfter(minimum) ? initial : minimum; + + if (defaultTargetPlatform == TargetPlatform.iOS) { + var selected = safeInitial; + return showCupertinoModalPopup( + context: context, + builder: (pickerContext) => Material( + color: context.colors.surface, + child: SafeArea( + top: false, + child: SizedBox( + height: 300, + child: Column( + children: [ + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () => Navigator.of(pickerContext).pop(selected), + child: const Text('Done'), + ), + ), + Expanded( + child: CupertinoDatePicker( + mode: CupertinoDatePickerMode.dateAndTime, + minimumDate: minimum, + initialDateTime: safeInitial, + use24hFormat: MediaQuery.alwaysUse24HourFormatOf(context), + onDateTimeChanged: (value) => selected = value, + ), + ), + ], + ), + ), + ), + ), + ); + } + + final lastDate = now.add(const Duration(days: 365)); + final androidInitial = safeInitial.isAfter(lastDate) ? lastDate : safeInitial; + final date = await showDatePicker( + context: context, + initialDate: androidInitial, + firstDate: now, + lastDate: lastDate, + ); + if (date == null || !context.mounted) return null; + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(androidInitial), + ); + if (time == null) return null; + final selected = DateTime( + date.year, + date.month, + date.day, + time.hour, + time.minute, + ); + return selected.isBefore(minimum) ? minimum : selected; +} + +String _formatUntil(DateTime value) => + DateFormat('EEE, MMM d \u00B7 h:mm a').format(value); diff --git a/mobile/lib/features/profile/settings_profile_header.dart b/mobile/lib/features/profile/settings_profile_header.dart index d33e0ce2f7..59f424cb7a 100644 --- a/mobile/lib/features/profile/settings_profile_header.dart +++ b/mobile/lib/features/profile/settings_profile_header.dart @@ -1,15 +1,18 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/animated_avatar.dart'; import '../../shared/custom_emoji/custom_emoji_provider.dart'; import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/anchored_popover_menu.dart'; import '../../shared/widgets/masked_avatar_badge.dart'; +import '../../shared/widgets/progressive_animated_avatar.dart'; import 'profile_provider.dart'; import 'set_status_sheet.dart'; import 'user_status_provider.dart'; @@ -19,7 +22,7 @@ import 'user_status_provider.dart'; /// puts an edit-photo pencil in that badge; here it carries the status glyph and /// opens the status sheet instead. The notch shape — including the fillets where /// it meets the avatar's edge — comes from [AvatarBadgeMaskGeometry.badge]. -class SettingsProfileHeader extends ConsumerWidget { +class SettingsProfileHeader extends HookConsumerWidget { const SettingsProfileHeader({super.key}); static const _avatarSize = 128.0; @@ -30,6 +33,13 @@ class SettingsProfileHeader extends ConsumerWidget { final status = ref.watch(userStatusProvider).asData?.value; final hasStatus = status != null && !status.isEmpty; final presence = ref.watch(presenceProvider).value ?? 'offline'; + final animatedAvatar = parseAnimatedAvatarUrl(profile?.avatarUrl); + final stoppedAnimationUrl = useState(null); + final avatarUrl = animatedAvatar == null + ? profile?.avatarUrl + : stoppedAnimationUrl.value == animatedAvatar.animationUrl + ? animatedAvatar.posterUrl + : null; void openStatusSheet() => showSetStatusSheet(context, currentStatus: status); @@ -40,16 +50,31 @@ class SettingsProfileHeader extends ConsumerWidget { children: [ MaskedAvatarBadge( size: _avatarSize, - avatar: ColoredBox( - color: context.colors.primaryContainer, - child: AvatarImageContent( - imageUrl: profile?.avatarUrl, - fallback: Text( - profile?.initial ?? '?', - style: context.textTheme.displaySmall?.copyWith( - color: context.colors.onPrimaryContainer, - ), - ), + avatar: GestureDetector( + key: const ValueKey('settings-profile-avatar'), + onTap: animatedAvatar == null + ? null + : () => stoppedAnimationUrl.value = + stoppedAnimationUrl.value == animatedAvatar.animationUrl + ? null + : animatedAvatar.animationUrl, + child: ColoredBox( + key: const ValueKey('settings-profile-avatar-background'), + color: animatedAvatar == null + ? context.colors.primaryContainer + : Colors.transparent, + child: + animatedAvatar != null && + stoppedAnimationUrl.value != animatedAvatar.animationUrl + ? ProgressiveAnimatedAvatar( + key: ValueKey(animatedAvatar.animationUrl), + descriptor: animatedAvatar, + fallback: _AvatarFallback(initial: profile?.initial), + ) + : AvatarImageContent( + imageUrl: avatarUrl, + fallback: _AvatarFallback(initial: profile?.initial), + ), ), ), badge: _StatusBadge( @@ -98,6 +123,22 @@ class SettingsProfileHeader extends ConsumerWidget { } } +class _AvatarFallback extends StatelessWidget { + const _AvatarFallback({required this.initial}); + + final String? initial; + + @override + Widget build(BuildContext context) { + return Text( + initial ?? '?', + style: context.textTheme.displaySmall?.copyWith( + color: context.colors.onPrimaryContainer, + ), + ); + } +} + class _PresencePill extends StatelessWidget { const _PresencePill({required this.presence, required this.onSelected}); diff --git a/mobile/lib/features/profile/user_profile_sheet.dart b/mobile/lib/features/profile/user_profile_sheet.dart index d89c64a947..e2dbb86725 100644 --- a/mobile/lib/features/profile/user_profile_sheet.dart +++ b/mobile/lib/features/profile/user_profile_sheet.dart @@ -6,12 +6,14 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/animated_avatar.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/utils/string_utils.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_action_tile.dart'; import '../../shared/widgets/modal_presentation.dart'; +import '../../shared/widgets/progressive_animated_avatar.dart'; import '../channels/channel.dart'; import '../channels/channel_detail_page.dart'; import '../channels/channel_management_provider.dart'; @@ -372,7 +374,7 @@ class _ProfilePresenceChip extends StatelessWidget { } } -class _ProfileAvatar extends StatelessWidget { +class _ProfileAvatar extends HookWidget { final String? avatarUrl; final String initial; @@ -380,12 +382,33 @@ class _ProfileAvatar extends StatelessWidget { @override Widget build(BuildContext context) { + final animatedAvatar = parseAnimatedAvatarUrl(avatarUrl); + final stoppedAnimationUrl = useState(null); + final isPlaying = + animatedAvatar != null && + stoppedAnimationUrl.value != animatedAvatar.animationUrl; + return AspectRatio( aspectRatio: 1, - child: ClipOval( - child: AvatarImageContent( - imageUrl: avatarUrl, - fallback: _AvatarFallback(initial: initial), + child: GestureDetector( + key: const ValueKey('selected-profile-avatar'), + onTap: animatedAvatar == null + ? null + : () => stoppedAnimationUrl.value = + stoppedAnimationUrl.value == animatedAvatar.animationUrl + ? null + : animatedAvatar.animationUrl, + child: ClipOval( + child: isPlaying + ? ProgressiveAnimatedAvatar( + key: ValueKey(animatedAvatar.animationUrl), + descriptor: animatedAvatar, + fallback: _AvatarFallback(initial: initial), + ) + : AvatarImageContent( + imageUrl: animatedAvatar?.posterUrl ?? avatarUrl, + fallback: _AvatarFallback(initial: initial), + ), ), ), ); diff --git a/mobile/lib/features/profile/user_status.dart b/mobile/lib/features/profile/user_status.dart index 8b28641d4f..b487c54afa 100644 --- a/mobile/lib/features/profile/user_status.dart +++ b/mobile/lib/features/profile/user_status.dart @@ -2,29 +2,54 @@ import 'package:flutter/foundation.dart'; import '../../shared/relay/nostr_models.dart'; +const _minDateTimeUnixSeconds = -8640000000000; +const _maxDateTimeUnixSeconds = 8640000000000; + /// A user's NIP-38 status (kind:30315, d=general). @immutable class UserStatus { final String text; final String emoji; final int updatedAt; + final int? expiresAt; const UserStatus({ required this.text, required this.emoji, required this.updatedAt, + this.expiresAt, }); factory UserStatus.fromEvent(NostrEvent event) { final emojiTag = event.tags .where((t) => t.length >= 2 && t[0] == 'emoji') .firstOrNull; + final expirationTag = event.tags + .where((t) => t.length >= 2 && t[0] == 'expiration') + .firstOrNull; return UserStatus( text: event.content, emoji: emojiTag?[1] ?? '', updatedAt: event.createdAt, + expiresAt: int.tryParse(expirationTag?[1] ?? ''), ); } + /// Converts [expiresAt] when it falls within Dart's supported date range. + /// + /// NIP-40 expiration tags are untrusted integers and may exceed that range. + DateTime? get expirationDateTime { + final unixSeconds = expiresAt; + if (unixSeconds == null || + unixSeconds < _minDateTimeUnixSeconds || + unixSeconds > _maxDateTimeUnixSeconds) { + return null; + } + return DateTime.fromMillisecondsSinceEpoch(unixSeconds * 1000); + } + bool get isEmpty => text.isEmpty && emoji.isEmpty; + + bool isExpiredAt(int unixSeconds) => + expiresAt != null && expiresAt! <= unixSeconds; } diff --git a/mobile/lib/features/profile/user_status_cache_provider.dart b/mobile/lib/features/profile/user_status_cache_provider.dart index e8873b08e8..0d5b8ee5d6 100644 --- a/mobile/lib/features/profile/user_status_cache_provider.dart +++ b/mobile/lib/features/profile/user_status_cache_provider.dart @@ -17,6 +17,7 @@ class UserStatusCacheNotifier extends Notifier> { final Set _pending = {}; Timer? _batchTimer; Timer? _refreshTimer; + Timer? _expirationTimer; void Function()? _statusUnsub; int _subscriptionVersion = 0; @@ -30,6 +31,8 @@ class UserStatusCacheNotifier extends Notifier> { _batchTimer = null; _refreshTimer?.cancel(); _refreshTimer = null; + _expirationTimer?.cancel(); + _expirationTimer = null; _statusUnsub?.call(); _statusUnsub = null; }); @@ -101,13 +104,20 @@ class UserStatusCacheNotifier extends Notifier> { final parsed = UserStatus.fromEvent(event); final existing = state[pubkey]; - // Staleness guard: discard if we already have a newer-or-equal event. - if (existing != null && existing.updatedAt >= parsed.updatedAt) return; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + // An equal event still needs its expiration re-evaluated. Otherwise the + // periodic refresh keeps an expired status alive forever. + if (existing != null && existing.updatedAt > parsed.updatedAt) return; + if (existing != null && + existing.updatedAt == parsed.updatedAt && + !parsed.isExpiredAt(now)) { + return; + } - final status = parsed.isEmpty ? null : parsed; + final status = parsed.isEmpty || parsed.isExpiredAt(now) ? null : parsed; final updated = Map.from(state); updated[pubkey] = status; - state = updated; + _replaceState(updated); } /// Directly update a pubkey's cached status. Used by [UserStatusNotifier] @@ -116,7 +126,7 @@ class UserStatusCacheNotifier extends Notifier> { final pk = pubkey.toLowerCase(); final updated = Map.from(state); updated[pk] = status; - state = updated; + _replaceState(updated); } Future _refreshAll() async { @@ -161,17 +171,72 @@ class UserStatusCacheNotifier extends Notifier> { final pk = event.pubkey.toLowerCase(); final parsed = UserStatus.fromEvent(event); final existing = updated[pk]; - if (existing != null && existing.updatedAt >= parsed.updatedAt) { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + if (existing != null && existing.updatedAt > parsed.updatedAt) { continue; } - updated[pk] = parsed.isEmpty ? null : parsed; + if (existing != null && + existing.updatedAt == parsed.updatedAt && + !parsed.isExpiredAt(now)) { + continue; + } + updated[pk] = parsed.isEmpty || parsed.isExpiredAt(now) ? null : parsed; } - state = updated; + _replaceState(updated); } catch (_) { // Silently fail — backstop will retry. } } + + void _replaceState(Map updated) { + state = updated; + _scheduleNextExpiration(); + } + + void _scheduleNextExpiration() { + _expirationTimer?.cancel(); + _expirationTimer = null; + + DateTime? nextDeadline; + for (final status in state.values) { + final deadline = status?.expirationDateTime; + if (deadline == null) continue; + if (nextDeadline == null || deadline.isBefore(nextDeadline)) { + nextDeadline = deadline; + } + } + if (nextDeadline == null) return; + + final remaining = nextDeadline.difference(DateTime.now()); + _expirationTimer = Timer( + remaining.isNegative ? Duration.zero : remaining, + _expireDueStatuses, + ); + } + + void _expireDueStatuses() { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + var changed = false; + final updated = Map.from(state); + for (final entry in state.entries) { + final status = entry.value; + if (status != null && status.isExpiredAt(now)) { + updated[entry.key] = null; + changed = true; + } + } + + if (changed) { + _replaceState(updated); + } else { + // Timer callbacks can land just before the Unix-second boundary. + _expirationTimer = Timer( + const Duration(milliseconds: 50), + _expireDueStatuses, + ); + } + } } final userStatusCacheProvider = diff --git a/mobile/lib/features/profile/user_status_provider.dart b/mobile/lib/features/profile/user_status_provider.dart index bdeecfdcea..1ed3c9218d 100644 --- a/mobile/lib/features/profile/user_status_provider.dart +++ b/mobile/lib/features/profile/user_status_provider.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; @@ -11,11 +13,20 @@ import 'user_status_cache_provider.dart'; /// for publishing. Publishes via WebSocket (triggers fan-out). No heartbeat /// needed — user status events are parameterised replaceable, not ephemeral. class UserStatusNotifier extends AsyncNotifier { + Timer? _expirationTimer; + @override - Future build() { + Future build() async { ref.watch(relayClientProvider); ref.watch(relaySessionProvider); - return _fetch(); + ref.onDispose(() { + _expirationTimer?.cancel(); + _expirationTimer = null; + }); + + final status = await _fetch(); + _scheduleExpiration(status); + return status; } Future _fetch() async { @@ -55,13 +66,18 @@ class UserStatusNotifier extends AsyncNotifier { (a, b) => a.createdAt >= b.createdAt ? a : b, ); final status = UserStatus.fromEvent(latest); - return status.isEmpty ? null : status; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return status.isEmpty || status.isExpiredAt(now) ? null : status; } catch (_) { return null; } } - Future setStatus(String text, String emoji) async { + Future setStatus( + String text, + String emoji, { + DateTime? expiresAt, + }) async { final trimmed = text.trim(); final config = ref.read(relayConfigProvider); final nsec = config.nsec; @@ -73,6 +89,9 @@ class UserStatusNotifier extends AsyncNotifier { if (emoji.isNotEmpty) { tags.add(['emoji', emoji]); } + if (expiresAt != null) { + tags.add(['expiration', '${expiresAt.millisecondsSinceEpoch ~/ 1000}']); + } final privkeyHex = nostr.Nip19.decode(payload: nsec).data; final event = nostr.Event.from( @@ -92,9 +111,13 @@ class UserStatusNotifier extends AsyncNotifier { text: trimmed, emoji: emoji, updatedAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + expiresAt: expiresAt == null + ? null + : expiresAt.millisecondsSinceEpoch ~/ 1000, ) : null; state = AsyncValue.data(newStatus); + _scheduleExpiration(newStatus); // Also update the shared cache so other UI reads stay consistent. final keyPair = nostr.Keys(privkeyHex); @@ -103,6 +126,50 @@ class UserStatusNotifier extends AsyncNotifier { } Future clearStatus() => setStatus('', ''); + + void _scheduleExpiration(UserStatus? status) { + _expirationTimer?.cancel(); + _expirationTimer = null; + final deadline = status?.expirationDateTime; + if (deadline == null) return; + + final remaining = deadline.difference(DateTime.now()); + _expirationTimer = Timer( + remaining.isNegative ? Duration.zero : remaining, + _expireCurrentStatus, + ); + } + + void _expireCurrentStatus() { + final current = state.asData?.value; + if (current == null) return; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + if (!current.isExpiredAt(now)) { + // Timer callbacks can land just before the Unix-second boundary. + _expirationTimer = Timer( + const Duration(milliseconds: 50), + _expireCurrentStatus, + ); + return; + } + + state = const AsyncValue.data(null); + final pubkey = _currentPubkey(); + if (pubkey != null) { + ref.read(userStatusCacheProvider.notifier).updateStatus(pubkey, null); + } + } + + String? _currentPubkey() { + final nsec = ref.read(relayConfigProvider).nsec; + if (nsec == null || nsec.isEmpty) return null; + try { + final privkeyHex = nostr.Nip19.decode(payload: nsec).data; + return nostr.Keys(privkeyHex).public.toLowerCase(); + } catch (_) { + return null; + } + } } final userStatusProvider = diff --git a/mobile/lib/features/settings/settings_page/appearance_section.dart b/mobile/lib/features/settings/settings_page/appearance_section.dart index 9179003180..a5c3a5142d 100644 --- a/mobile/lib/features/settings/settings_page/appearance_section.dart +++ b/mobile/lib/features/settings/settings_page/appearance_section.dart @@ -61,6 +61,7 @@ class _AppearanceSection extends ConsumerWidget { void _showAppearanceModeSheet(BuildContext context) { showBuzzModalBottomSheet( context: context, + title: 'Appearance', showDragHandle: true, builder: (_) => const _AppearanceModeSheet(), ); @@ -80,15 +81,6 @@ class _AppearanceModeSheet extends ConsumerWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - 0, - Grid.gutter, - Grid.xxs, - ), - child: Text('Appearance', style: context.textTheme.titleMedium), - ), for (final option in _modeOptions) AppListRow( icon: option.icon, diff --git a/mobile/lib/shared/animated_avatar.dart b/mobile/lib/shared/animated_avatar.dart new file mode 100644 index 0000000000..41efcecb1e --- /dev/null +++ b/mobile/lib/shared/animated_avatar.dart @@ -0,0 +1,58 @@ +// Animated avatar URL scheme shared with the desktop client. +// +// The static poster is the normal selected frame. The animated PNG URL lives +// in the fragment so clients that do not understand the scheme render only +// the poster: +// +// posterUrl#buzz-anim=encodedAnimationUrl +const _animatedAvatarSeparator = '#buzz-anim='; + +/// The static poster and animated image URLs encoded in an avatar URL. +class AnimatedAvatarDescriptor { + const AnimatedAvatarDescriptor({ + required this.posterUrl, + required this.animationUrl, + }); + + final String posterUrl; + final String animationUrl; +} + +/// Parses the Buzz animated-avatar fragment scheme from [url]. +/// +/// Returns `null` when the poster or animation URL is missing, malformed, or +/// does not use HTTP(S). +AnimatedAvatarDescriptor? parseAnimatedAvatarUrl(String? url) { + if (url == null || url.isEmpty) return null; + + final separatorIndex = url.indexOf(_animatedAvatarSeparator); + if (separatorIndex <= 0) return null; + + final posterUrl = url.substring(0, separatorIndex); + final encodedAnimationUrl = url.substring( + separatorIndex + _animatedAvatarSeparator.length, + ); + if (encodedAnimationUrl.isEmpty) return null; + + final String animationUrl; + try { + animationUrl = Uri.decodeComponent(encodedAnimationUrl); + } on ArgumentError { + return null; + } on FormatException { + return null; + } + + if (!_isHttpUrl(posterUrl) || !_isHttpUrl(animationUrl)) return null; + return AnimatedAvatarDescriptor( + posterUrl: posterUrl, + animationUrl: animationUrl, + ); +} + +bool _isHttpUrl(String value) { + final uri = Uri.tryParse(value); + return uri != null && + (uri.scheme == 'http' || uri.scheme == 'https') && + uri.host.isNotEmpty; +} diff --git a/mobile/lib/shared/relay/media_image.dart b/mobile/lib/shared/relay/media_image.dart index e8838b65af..14187df081 100644 --- a/mobile/lib/shared/relay/media_image.dart +++ b/mobile/lib/shared/relay/media_image.dart @@ -177,6 +177,7 @@ class MediaImage extends ConsumerWidget { final double? height; final String? semanticLabel; final ImageErrorWidgetBuilder? errorBuilder; + final ImageFrameBuilder? frameBuilder; final FilterQuality filterQuality; final double? decodeWidth; final bool boundDecodeToLayout; @@ -189,6 +190,7 @@ class MediaImage extends ConsumerWidget { this.height, this.semanticLabel, this.errorBuilder, + this.frameBuilder, this.filterQuality = FilterQuality.medium, this.decodeWidth, this.boundDecodeToLayout = true, @@ -231,6 +233,7 @@ class MediaImage extends ConsumerWidget { height: height, semanticLabel: semanticLabel, errorBuilder: errorBuilder, + frameBuilder: frameBuilder, filterQuality: filterQuality, gaplessPlayback: true, ); diff --git a/mobile/lib/shared/relay/relay_http_query_client.dart b/mobile/lib/shared/relay/relay_http_query_client.dart new file mode 100644 index 0000000000..615b28f64c --- /dev/null +++ b/mobile/lib/shared/relay/relay_http_query_client.dart @@ -0,0 +1,97 @@ +import 'dart:async'; + +import 'package:http/http.dart' as http; + +/// Owns the reusable HTTP transport used for relay `/query` requests. +class RelayHttpQueryClient { + RelayHttpQueryClient({ + http.Client? client, + http.Client Function()? clientFactory, + }) : _injectedClient = client, + _clientFactory = clientFactory ?? (() => http.Client()); + + final http.Client? _injectedClient; + final http.Client Function() _clientFactory; + _ClientGeneration? _currentGeneration; + final Set<_ClientGeneration> _generations = {}; + + Future post( + Uri url, { + required Map headers, + required List body, + required Duration timeout, + }) async { + final generation = _injectedClient == null + ? (_currentGeneration ??= _createGeneration()) + : null; + generation?.acquire(); + try { + return await (_injectedClient ?? generation!.client) + .post(url, headers: headers, body: body) + .timeout(timeout); + } on TimeoutException { + if (identical(_currentGeneration, generation)) { + _currentGeneration = null; + } + generation?.retire(); + rethrow; + } finally { + generation?.release(); + } + } + + void close() { + for (final generation in _generations.toList()) { + generation.close(); + } + _currentGeneration = null; + _injectedClient?.close(); + } + + _ClientGeneration _createGeneration() { + late final _ClientGeneration generation; + generation = _ClientGeneration( + _clientFactory(), + onClosed: () => _generations.remove(generation), + ); + _generations.add(generation); + return generation; + } +} + +class _ClientGeneration { + _ClientGeneration(this.client, {required this.onClosed}); + + final http.Client client; + final void Function() onClosed; + int _activeRequests = 0; + bool _retired = false; + bool _closed = false; + + void acquire() { + assert(!_closed); + _activeRequests++; + } + + void release() { + assert(_activeRequests > 0); + _activeRequests--; + _closeIfIdle(); + } + + void retire() { + _retired = true; + _closeIfIdle(); + } + + void close() { + if (_closed) return; + _closed = true; + client.close(); + onClosed(); + } + + void _closeIfIdle() { + if (_retired && _activeRequests == 0) close(); + } +} diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index 1c5c305b4c..d6c094d82e 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -14,6 +14,7 @@ import '../auth/auth.dart'; import 'nostr_models.dart'; import 'relay_client.dart'; import 'relay_closed_policy.dart'; +import 'relay_http_query_client.dart'; import 'relay_provider.dart'; import 'relay_rate_limit_gate.dart'; import 'relay_socket.dart'; @@ -88,19 +89,23 @@ typedef RelaySocketFactory = class RelaySessionNotifier extends Notifier { RelaySessionNotifier({ http.Client? httpClient, + http.Client Function()? httpClientFactory, RelaySocketFactory socketFactory = RelaySocket.new, DateTime Function()? now, RelayRateLimitGate? rateLimitGate, RelayTimerFactory retryTimerFactory = Timer.new, Future Function(Duration) replayDelay = Future.delayed, - }) : _httpClient = httpClient, + }) : _httpQueryClient = RelayHttpQueryClient( + client: httpClient, + clientFactory: httpClientFactory, + ), _socketFactory = socketFactory, _now = now ?? DateTime.now, _rateLimitGate = rateLimitGate ?? RelayRateLimitGate(), _retryTimerFactory = retryTimerFactory, _replayDelay = replayDelay; - final http.Client? _httpClient; + final RelayHttpQueryClient _httpQueryClient; final RelaySocketFactory _socketFactory; final DateTime Function() _now; final RelayRateLimitGate _rateLimitGate; @@ -168,26 +173,22 @@ class RelaySessionNotifier extends Notifier { final bodyBytes = utf8.encode( jsonEncode(filters.map((filter) => filter.toJson()).toList()), ); - final client = _httpClient ?? http.Client(); - final shouldCloseClient = _httpClient == null; - final response = await client - .post( - Uri.parse(url), - headers: { - 'Authorization': buildNip98AuthHeader( - method: 'POST', - url: url, - bodyBytes: bodyBytes, - nsec: config.nsec, - ), - 'Content-Type': 'application/json', - }, - body: bodyBytes, - ) - .timeout(timeout) - .whenComplete(() { - if (shouldCloseClient) client.close(); - }); + // Reuse the session transport on success. A timeout rotates immediately + // for new queries, then closes the retired client after its peers finish. + final response = await _httpQueryClient.post( + Uri.parse(url), + headers: { + 'Authorization': buildNip98AuthHeader( + method: 'POST', + url: url, + bodyBytes: bodyBytes, + nsec: config.nsec, + ), + 'Content-Type': 'application/json', + }, + body: bodyBytes, + timeout: timeout, + ); if (response.statusCode < 200 || response.statusCode >= 300) { _activateRateLimitGateFromHttpError(response.body); throw RelayException(response.statusCode, response.body); @@ -936,7 +937,7 @@ class RelaySessionNotifier extends Notifier { _recentDeliveryKeys.clear(); _socket?.dispose(); _socket = null; - _httpClient?.close(); + _httpQueryClient.close(); } } diff --git a/mobile/lib/shared/reminders/remind_me_later_sheet.dart b/mobile/lib/shared/reminders/remind_me_later_sheet.dart index a2fb7c59e9..7785f05448 100644 --- a/mobile/lib/shared/reminders/remind_me_later_sheet.dart +++ b/mobile/lib/shared/reminders/remind_me_later_sheet.dart @@ -40,6 +40,7 @@ void showRemindMeLaterSheet({ showBuzzModalBottomSheet( context: context, + title: 'Remind me about this message', showDragHandle: true, builder: (sheetContext) => SafeArea( child: IconTheme.merge( @@ -56,16 +57,6 @@ void showRemindMeLaterSheet({ mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.only( - left: Grid.half, - bottom: Grid.xxs, - ), - child: Text( - 'Remind me about this message', - style: Theme.of(sheetContext).textTheme.titleSmall, - ), - ), for (final preset in reminderTimePresets) ListTile( leading: const Icon(LucideIcons.clock), diff --git a/mobile/lib/shared/widgets/avatar_image.dart b/mobile/lib/shared/widgets/avatar_image.dart index e12a03ad61..7b40739c4c 100644 --- a/mobile/lib/shared/widgets/avatar_image.dart +++ b/mobile/lib/shared/widgets/avatar_image.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import '../animated_avatar.dart'; import '../relay/relay.dart'; /// A circular avatar that supports both remote URLs and inline image data. @@ -27,13 +28,21 @@ class AvatarImage extends StatelessWidget { @override Widget build(BuildContext context) { + final animatedAvatar = parseAnimatedAvatarUrl(imageUrl); return CircleAvatar( radius: radius, - backgroundColor: backgroundColor, + // Animated avatar posters carry their own backdrop disc; preserve their + // transparent surroundings on static/list surfaces, matching desktop. + backgroundColor: animatedAvatar == null + ? backgroundColor + : Colors.transparent, child: ClipOval( child: SizedBox.square( dimension: radius * 2, - child: AvatarImageContent(imageUrl: imageUrl, fallback: fallback), + child: AvatarImageContent( + imageUrl: animatedAvatar?.posterUrl ?? imageUrl, + fallback: fallback, + ), ), ), ); diff --git a/mobile/lib/shared/widgets/buzz_sheet_header.dart b/mobile/lib/shared/widgets/buzz_sheet_header.dart new file mode 100644 index 0000000000..3168ad1d53 --- /dev/null +++ b/mobile/lib/shared/widgets/buzz_sheet_header.dart @@ -0,0 +1,121 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../theme/theme.dart'; + +/// A titled sheet header with balanced actions and an exactly centered title. +class BuzzSheetHeader extends StatelessWidget { + const BuzzSheetHeader({ + super.key, + this.title, + this.titleKey, + this.leading, + this.trailing, + this.showDragHandle = false, + }); + + final String? title; + final Key? titleKey; + final Widget? leading; + final Widget? trailing; + final bool showDragHandle; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only( + top: Grid.xxs, + left: Grid.gutter, + right: Grid.gutter, + bottom: Grid.xs, + ), + child: SizedBox( + height: 56, + child: Stack( + alignment: Alignment.topCenter, + children: [ + if (showDragHandle) const _SheetDragHandle(), + if (title case final title?) + Positioned( + left: 64, + right: 64, + bottom: 0, + height: 44, + child: Center( + child: Text( + title, + key: titleKey ?? const ValueKey('buzz-sheet-title'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + ), + Align( + alignment: Alignment.bottomRight, + child: trailing ?? const _SheetCloseButton(), + ), + if (leading case final leading?) + Align(alignment: Alignment.bottomLeft, child: leading), + ], + ), + ), + ); + } +} + +class _SheetCloseButton extends StatelessWidget { + const _SheetCloseButton(); + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: 44, + child: IconButton( + tooltip: 'Close sheet', + onPressed: () { + unawaited(HapticFeedback.lightImpact()); + Navigator.of(context).pop(); + }, + style: IconButton.styleFrom( + padding: EdgeInsets.zero, + backgroundColor: context.colors.surfaceContainerHighest, + foregroundColor: context.colors.onSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.dialog), + ), + ), + icon: const Icon(LucideIcons.x, size: 22), + ), + ); + } +} + +class _SheetDragHandle extends StatelessWidget { + const _SheetDragHandle(); + + @override + Widget build(BuildContext context) { + return Semantics( + label: MaterialLocalizations.of(context).modalBarrierDismissLabel, + container: true, + button: true, + onTap: () => Navigator.of(context).pop(), + child: Container( + key: const ValueKey('buzz-sheet-drag-handle'), + width: 32, + height: 4, + decoration: BoxDecoration( + color: context.colors.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(Radii.full), + ), + ), + ); + } +} diff --git a/mobile/lib/shared/widgets/buzz_titled_sheet_layout.dart b/mobile/lib/shared/widgets/buzz_titled_sheet_layout.dart new file mode 100644 index 0000000000..fad8a4271a --- /dev/null +++ b/mobile/lib/shared/widgets/buzz_titled_sheet_layout.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +import '../theme/theme.dart'; +import 'buzz_sheet_header.dart'; +import 'concentric_sheet_surface.dart'; + +/// Shared solid sheet surface with a centered navigation row. +class BuzzTitledSheetLayout extends StatelessWidget { + const BuzzTitledSheetLayout({ + super.key, + required this.title, + required this.child, + this.leading, + this.trailing, + this.titleKey, + this.showDragHandle = false, + this.surfaceColor, + }); + + final String title; + final Widget child; + final Widget? leading; + final Widget? trailing; + final Key? titleKey; + final bool showDragHandle; + final Color? surfaceColor; + + @override + Widget build(BuildContext context) { + final color = surfaceColor ?? context.colors.surface; + final paintsSurface = !ConcentricSheetSurface.providesSurfaceOf(context); + + final sheet = SizedBox( + width: double.infinity, + child: ColoredBox( + key: const ValueKey('buzz-sheet-surface'), + color: paintsSurface ? color : Colors.transparent, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + BuzzSheetHeader( + title: title, + titleKey: titleKey, + leading: leading, + trailing: trailing, + showDragHandle: showDragHandle, + ), + Flexible(child: child), + ], + ), + ), + ); + + // The native iOS surface owns its exact iOS 26 container-concentric mask. + // A second fixed Flutter radius would visibly square off those corners. + if (!paintsSurface) return sheet; + + return ClipRRect( + key: const ValueKey('buzz-sheet-surface-clip'), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(Radii.dialog), + ), + clipBehavior: Clip.antiAlias, + child: sheet, + ); + } +} diff --git a/mobile/lib/shared/widgets/concentric_sheet_surface.dart b/mobile/lib/shared/widgets/concentric_sheet_surface.dart index 12e7e002fc..1cf7a67220 100644 --- a/mobile/lib/shared/widgets/concentric_sheet_surface.dart +++ b/mobile/lib/shared/widgets/concentric_sheet_surface.dart @@ -20,7 +20,14 @@ class ConcentricSheetSurface extends HookWidget { final bool enabled; final Color? color; + static bool providesSurfaceOf(BuildContext context) => + context + .dependOnInheritedWidgetOfExactType<_ConcentricSheetSurfaceScope>() + ?.providesSurface ?? + false; + static const _surfaceChannel = MethodChannel('buzz/concentric_sheet_surface'); + static const _nativeContentClipRadius = Radii.dialog * 2; Future _checkNativeSurfaceSupport() async { try { @@ -48,7 +55,7 @@ class ConcentricSheetSurface extends HookWidget { final nativeSurfaceSupported = useFuture(supportFuture).data ?? false; if (!shouldCheckNativeSurface) { - return child; + return _ConcentricSheetSurfaceScope(providesSurface: false, child: child); } final surfaceColor = color ?? context.colors.surface; @@ -83,9 +90,37 @@ class ConcentricSheetSurface extends HookWidget { clipBehavior: Clip.antiAlias, ), ), - child, + ClipRSuperellipse( + key: const ValueKey('concentric-sheet-content-clip'), + // UIKit's container-concentric corner resolves substantially + // larger than its minimum radius on an edge-inset iOS sheet. Keep + // the Flutter content inside that continuous outline; a 24pt + // circular clip still lets scrolling rows show through the native + // corner cutouts. + borderRadius: BorderRadius.circular( + nativeSurfaceSupported ? _nativeContentClipRadius : Radii.dialog, + ), + clipBehavior: Clip.antiAlias, + child: _ConcentricSheetSurfaceScope( + providesSurface: true, + child: child, + ), + ), ], ), ); } } + +class _ConcentricSheetSurfaceScope extends InheritedWidget { + const _ConcentricSheetSurfaceScope({ + required this.providesSurface, + required super.child, + }); + + final bool providesSurface; + + @override + bool updateShouldNotify(_ConcentricSheetSurfaceScope oldWidget) => + oldWidget.providesSurface != providesSurface; +} diff --git a/mobile/lib/shared/widgets/modal_presentation.dart b/mobile/lib/shared/widgets/modal_presentation.dart index 3793774a2d..735be1770b 100644 --- a/mobile/lib/shared/widgets/modal_presentation.dart +++ b/mobile/lib/shared/widgets/modal_presentation.dart @@ -1,11 +1,9 @@ -import 'dart:async'; - import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../theme/theme.dart'; +import 'buzz_sheet_header.dart'; +import 'buzz_titled_sheet_layout.dart'; import 'concentric_sheet_surface.dart'; /// Shared motion for occasional modal UI. @@ -23,10 +21,12 @@ const buzzModalAnimationStyle = AnimationStyle( /// /// Sheets include the shared close control by default. On iOS, the surface /// uses native concentric corners when available and paints a requested drag -/// handle inside that inset surface; other platforms retain Flutter's handle. +/// handle inside the shared header so its spacing is consistent on every +/// platform. Future showBuzzModalBottomSheet({ required BuildContext context, required WidgetBuilder builder, + String? title, Color? backgroundColor, String? barrierLabel, double? elevation, @@ -63,12 +63,16 @@ Future showBuzzModalBottomSheet({ enabled: isIos, color: surfaceColor, child: _SheetContent( + title: title, showCloseButton: showCloseButton, - showDragHandle: isIos && showDragHandle == true, + showDragHandle: showDragHandle == true, + surfaceColor: surfaceColor, child: builder(sheetContext), ), ), - backgroundColor: isIos ? Colors.transparent : backgroundColor, + backgroundColor: isIos || title != null + ? Colors.transparent + : backgroundColor, barrierLabel: barrierLabel, elevation: elevation, shape: shape, @@ -80,9 +84,9 @@ Future showBuzzModalBottomSheet({ useRootNavigator: useRootNavigator, isDismissible: isDismissible, enableDrag: enableDrag, - // The iOS route is transparent so its stock handle sits outside the inset - // concentric surface. Paint it inside the surface above instead. - showDragHandle: isIos ? false : showDragHandle, + // The shared header owns the handle so Android does not reserve a second + // handle band above the title row and iOS keeps it inside its native inset. + showDragHandle: false, useSafeArea: useSafeArea, routeSettings: routeSettings, transitionAnimationController: transitionAnimationController, @@ -97,78 +101,55 @@ Future showBuzzModalBottomSheet({ class _SheetContent extends StatelessWidget { const _SheetContent({ required this.child, + required this.title, required this.showCloseButton, required this.showDragHandle, + required this.surfaceColor, }); final Widget child; + final String? title; final bool showCloseButton; final bool showDragHandle; + final Color surfaceColor; @override Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (showCloseButton) - Padding( - padding: const EdgeInsets.only( - top: Grid.xxs, - left: Grid.gutter, - right: Grid.gutter, - bottom: Grid.xs, + if (title == null || !showCloseButton) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (showCloseButton) + BuzzSheetHeader(title: title, showDragHandle: showDragHandle) + else if (showDragHandle) + const Padding( + padding: EdgeInsets.only(top: Grid.xxs, bottom: Grid.xs), + child: _StandaloneSheetDragHandle(), ), - child: SizedBox( - height: 56, - child: Stack( - alignment: Alignment.topCenter, - children: [ - if (showDragHandle) const _SheetDragHandle(), - Align( - alignment: Alignment.bottomRight, - child: SizedBox.square( - dimension: 44, - child: IconButton( - tooltip: 'Close sheet', - onPressed: () { - unawaited(HapticFeedback.lightImpact()); - Navigator.of(context).pop(); - }, - style: IconButton.styleFrom( - padding: EdgeInsets.zero, - backgroundColor: - context.colors.surfaceContainerHighest, - foregroundColor: context.colors.onSurface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(Radii.dialog), - ), - ), - icon: const Icon(LucideIcons.x, size: 22), - ), - ), - ), - ], - ), - ), - ) - else if (showDragHandle) - const Padding( - padding: EdgeInsets.only(top: Grid.xxs, bottom: Grid.xs), - child: _SheetDragHandle(), - ), - Flexible(child: child), - ], + Flexible(child: child), + ], + ); + } + + return BuzzTitledSheetLayout( + title: title!, + showDragHandle: showDragHandle, + surfaceColor: surfaceColor, + child: child, ); } } -class _SheetDragHandle extends StatelessWidget { - const _SheetDragHandle(); +class _StandaloneSheetDragHandle extends StatelessWidget { + const _StandaloneSheetDragHandle(); @override Widget build(BuildContext context) { return Semantics( - label: 'Drag handle', + label: MaterialLocalizations.of(context).modalBarrierDismissLabel, + container: true, + button: true, + onTap: () => Navigator.of(context).pop(), child: Container( key: const ValueKey('buzz-sheet-drag-handle'), width: 32, diff --git a/mobile/lib/shared/widgets/progressive_animated_avatar.dart b/mobile/lib/shared/widgets/progressive_animated_avatar.dart new file mode 100644 index 0000000000..94a4785fdf --- /dev/null +++ b/mobile/lib/shared/widgets/progressive_animated_avatar.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import '../animated_avatar.dart'; +import '../relay/media_image.dart'; +import 'avatar_image.dart'; + +/// Paints an animated avatar's persisted poster until its first frame is ready. +/// +/// Callers own playback policy, which keeps animation opt-in at individual +/// profile surfaces. +class ProgressiveAnimatedAvatar extends HookWidget { + const ProgressiveAnimatedAvatar({ + super.key, + required this.descriptor, + required this.fallback, + this.fit = BoxFit.cover, + }); + + final AnimatedAvatarDescriptor descriptor; + final Widget fallback; + final BoxFit fit; + + @override + Widget build(BuildContext context) { + final readyAnimationUrl = useState(null); + final scheduledAnimationUrl = useRef(null); + final animationKey = useMemoized(GlobalKey.new, [descriptor.animationUrl]); + final isReady = readyAnimationUrl.value == descriptor.animationUrl; + final animation = KeyedSubtree( + key: const ValueKey('progressive-animated-avatar-animation'), + child: MediaImage( + key: animationKey, + url: descriptor.animationUrl, + fit: fit, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if ((wasSynchronouslyLoaded || frame != null) && + !isReady && + scheduledAnimationUrl.value != descriptor.animationUrl) { + scheduledAnimationUrl.value = descriptor.animationUrl; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) { + readyAnimationUrl.value = descriptor.animationUrl; + } + }); + } + return child; + }, + ), + ); + + if (isReady) { + return KeyedSubtree( + key: const ValueKey('progressive-animated-avatar-animation-ready'), + child: animation, + ); + } + + return Stack( + fit: StackFit.expand, + children: [ + AvatarImageContent( + key: const ValueKey('progressive-animated-avatar-poster'), + imageUrl: descriptor.posterUrl, + fallback: fallback, + fit: fit, + ), + Offstage( + key: const ValueKey('progressive-animated-avatar-animation-loading'), + offstage: true, + child: animation, + ), + ], + ); + } +} diff --git a/mobile/test/features/activity/activity_provider_test.dart b/mobile/test/features/activity/activity_provider_test.dart index 351e08b4e1..820a6d1560 100644 --- a/mobile/test/features/activity/activity_provider_test.dart +++ b/mobile/test/features/activity/activity_provider_test.dart @@ -10,11 +10,13 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; /// Records subscriptions and DM history queries for Activity projection tests. class _RecordingSessionNotifier extends RelaySessionNotifier { final List> dmQueries = []; + final List queryFilterCounts = []; final List _history = []; final List<({NostrFilter filter, void Function(NostrEvent) onEvent})> _subscriptions = []; Completer? mentionFetchGate; bool failNextMentionFetch = false; + bool failNextQueryRelay = false; int mentionFetchCount = 0; int activeMentionFetches = 0; int maxActiveMentionFetches = 0; @@ -51,6 +53,45 @@ class _RecordingSessionNotifier extends RelaySessionNotifier { return _history.where((event) => _matches(filter, event)).toList(); } + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + queryFilterCounts.add(filters.length); + if (failNextQueryRelay) { + failNextQueryRelay = false; + throw StateError('transient HTTP query failure'); + } + for (final filter in filters) { + final h = filter.tags['#h']; + if (h != null) dmQueries.add(h); + } + final isMentionFetch = filters.any( + (filter) => filter.tags.containsKey('#p') && filter.kinds.contains(40002), + ); + if (isMentionFetch) { + mentionFetchCount += 1; + activeMentionFetches += 1; + if (activeMentionFetches > maxActiveMentionFetches) { + maxActiveMentionFetches = activeMentionFetches; + } + try { + final gate = mentionFetchGate; + if (gate != null) await gate.future; + if (failNextMentionFetch) { + failNextMentionFetch = false; + throw StateError('transient mention history failure'); + } + } finally { + activeMentionFetches -= 1; + } + } + return _history + .where((event) => filters.any((filter) => _matches(filter, event))) + .toList(); + } + @override Future subscribe( NostrFilter filter, @@ -159,6 +200,7 @@ void main() { // Cold start: channels still loading, so the first fetch has no DM ids. await container.read(activityProvider.future); expect(session.dmQueries, isEmpty); + expect(session.queryFilterCounts, [3]); // Channel list resolves with a DM → Activity must rebuild and query it. channels.resolve([_dmChannel('dm1')]); @@ -167,6 +209,7 @@ void main() { expect(session.dmQueries, hasLength(1)); expect(session.dmQueries.single, ['dm1']); + expect(session.queryFilterCounts, [3, 4]); }); test('does not query DMs when the resolved channel list has none', () async { @@ -190,6 +233,30 @@ void main() { expect(session.dmQueries, isEmpty); }); + test('falls back to websocket history when the HTTP batch fails', () async { + final session = _RecordingSessionNotifier() + ..seed(_mentionEvent('fallback-mention', 1_700_000_001)) + ..failNextQueryRelay = true; + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith(_FixedRelayConfigNotifier.new), + myPubkeyProvider.overrideWithValue('me_pk'), + relaySessionProvider.overrideWith(() => session), + channelsProvider.overrideWith( + () => _FixedChannelsNotifier(const []), + ), + ], + ); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + final feed = await container.read(activityProvider.future); + + expect(session.queryFilterCounts, [3]); + expect(session.mentionFetchCount, 1); + expect(feed.mentions.map((item) => item.id), ['fallback-mention']); + }); + test( 'refreshes the inbox projection when addressed activity arrives', () async { @@ -308,7 +375,7 @@ void main() { expect(session.maxActiveMentionFetches, 1); }); - test('retains the loaded inbox when a live refresh fails', () async { + test('recovers a live refresh through websocket history fallback', () async { final session = _RecordingSessionNotifier() ..seed(_mentionEvent('existing', 1_700_000_001)); final container = ProviderContainer( @@ -333,7 +400,10 @@ void main() { await _waitFor(() => session.mentionFetchCount >= 2); await Future.delayed(const Duration(milliseconds: 10)); - expect(container.read(inboxItemsProvider).single.id, 'existing'); + expect( + container.read(inboxItemsProvider).map((item) => item.id), + containsAll(['existing', 'newer']), + ); }); } diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index fbb12f7177..a0a832c866 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -7,6 +7,8 @@ import 'package:flutter/rendering.dart' show ScrollDirection; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http_testing; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:buzz/features/channels/channel.dart'; @@ -35,6 +37,7 @@ import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/frosted_app_bar.dart'; import 'package:buzz/shared/widgets/keyboard_dismiss_on_drag.dart'; +import 'package:buzz/shared/widgets/masked_avatar_badge.dart'; import 'package:buzz/shared/widgets/skeleton.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -187,6 +190,7 @@ Widget _buildTestable({ TextScaler textScaler = TextScaler.noScaling, bool disableAnimations = false, RelaySessionNotifier? relaySessionNotifier, + http.Client? mediaClient, }) { final resolvedChannel = channel ?? _testChannel; final fakeChannelsNotifier = @@ -238,6 +242,12 @@ Widget _buildTestable({ relayClientProvider.overrideWithValue( RelayClient(baseUrl: 'http://localhost:3000'), ), + if (mediaClient != null) ...[ + mediaGetAuthServiceProvider.overrideWithValue( + MediaGetAuthService(baseUrl: 'https://relay.example', nsec: null), + ), + mediaHttpClientProvider.overrideWithValue(mediaClient), + ], if (relaySessionNotifier != null) relaySessionProvider.overrideWith(() => relaySessionNotifier), // Compose bar drafts persist through SharedPreferences. @@ -344,6 +354,86 @@ void main() { }); group('ChannelDetailPage', () { + testWidgets('uses the shared 32px masked presence avatar in DM headers', ( + tester, + ) async { + final dmChannel = Channel( + id: _channelId, + name: 'DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Alice'], + participantPubkeys: const ['self', 'alice'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + + final avatarFinder = find.byKey(const ValueKey('dm-header-avatar')); + final avatar = tester.widget(avatarFinder); + expect(tester.getSize(avatarFinder), const Size.square(32)); + expect(avatar.geometry, AvatarBadgeMaskGeometry.presenceDot); + expect(avatar.badge, isNotNull); + expect( + find.descendant(of: avatarFinder, matching: find.byType(ClipPath)), + findsOneWidget, + ); + final name = tester.widget( + find.byKey(const ValueKey('dm-header-name')), + ); + final presence = tester.widget( + find.byKey(const ValueKey('dm-header-presence')), + ); + expect(name.style?.fontSize, 16); + expect(name.style?.fontWeight, FontWeight.w500); + expect(presence.style?.fontSize, 14); + expect(presence.style?.fontWeight, FontWeight.w400); + expect(find.byTooltip('View members'), findsNothing); + }); + + testWidgets('keeps the Members action for group DMs', (tester) async { + final dmChannel = Channel( + id: _channelId, + name: 'DM', + channelType: 'dm', + visibility: 'private', + description: 'Group direct message', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 3, + participants: const ['Self', 'Alice', 'Bob'], + participantPubkeys: const ['self', 'alice', 'bob'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byTooltip('View members'), findsOneWidget); + }); + testWidgets( 'restores the previous channel replay priority after a nested pop', (tester) async { @@ -732,6 +822,12 @@ void main() { joinedAt: DateTime(2025), displayName: 'Alice', ), + ChannelMember( + pubkey: 'agent', + role: 'bot', + joinedAt: DateTime(2025), + displayName: 'Agent', + ), ], ), ); @@ -743,6 +839,65 @@ void main() { expect(find.text('Alice'), findsOneWidget); expect(find.text('Member'), findsOneWidget); expect(find.text('Owner'), findsOneWidget); + expect(find.text('People · 2'), findsOneWidget); + expect(find.text('Agents · 1'), findsOneWidget); + expect(find.text('PEOPLE — 2'), findsNothing); + expect(find.text('BOTS — 1'), findsNothing); + }); + + testWidgets('members sheet has no divider and pads a one-member list', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + messages: const [], + members: [ + ChannelMember( + pubkey: 'self', + role: 'owner', + joinedAt: DateTime(2025), + displayName: 'Self', + ), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('View members')); + await tester.pumpAndSettle(); + + final contentPadding = find.byKey( + const ValueKey('members-sheet-content-padding'), + ); + expect(contentPadding, findsOneWidget); + expect( + find.descendant(of: contentPadding, matching: find.byType(Divider)), + findsNothing, + ); + expect( + (tester.widget(contentPadding).padding as EdgeInsets).bottom, + 0, + ); + final viewport = tester.widget( + find.byKey(const ValueKey('members-sheet-viewport')), + ); + expect(viewport.constraints.maxHeight, 400 + Grid.md); + expect( + (tester + .widget( + find.byKey(const ValueKey('members-sheet-list')), + ) + .padding! + as EdgeInsets) + .bottom, + Grid.md, + ); + expect( + find.byKey(const ValueKey('buzz-sheet-surface-margin')), + findsNothing, + ); + expect(find.text('People · 1'), findsOneWidget); + expect(tester.widget(find.text('People · 1')).style?.fontSize, 14); }); testWidgets('hides composer for archived channels', (tester) async { @@ -1029,6 +1184,46 @@ void main() { ); }); + testWidgets('keeps animated message avatars static and transparent', ( + tester, + ) async { + const posterUrl = 'https://relay.example/media/alice-poster.png'; + const animationUrl = 'https://relay.example/media/alice-avatar.png'; + final profileUrl = + '$posterUrl#buzz-anim=${Uri.encodeComponent(animationUrl)}'; + final mediaClient = http_testing.MockClient( + (_) async => http.Response.bytes(_transparentPng, 200), + ); + addTearDown(mediaClient.close); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg(id: 'animated-avatar', pubkey: 'alice', content: 'Hello'), + ], + users: { + 'alice': UserProfile( + pubkey: 'alice', + displayName: 'Alice', + avatarUrl: profileUrl, + ), + }, + mediaClient: mediaClient, + ), + ); + await tester.pumpAndSettle(); + + expect( + tester.widget(find.byType(CircleAvatar)).backgroundColor, + Colors.transparent, + ); + expect(tester.widget(find.byType(MediaImage)).url, posterUrl); + expect( + find.byKey(const ValueKey('progressive-animated-avatar-animation')), + findsNothing, + ); + }); + testWidgets('long press opens the anchored reaction popover', ( tester, ) async { @@ -2891,6 +3086,107 @@ void main() { expect(find.byType(UserProfileSheet), findsOneWidget); }); + testWidgets( + 'profile sheet shows the poster before autoplay and restores it on tap', + (tester) async { + const posterUrl = 'https://relay.example/media/alice-poster.png'; + const animationUrl = 'https://relay.example/media/alice-avatar.png'; + final profileUrl = + '$posterUrl#buzz-anim=${Uri.encodeComponent(animationUrl)}'; + final animationResponse = Completer(); + final mediaClient = http_testing.MockClient( + (request) => request.url.toString() == animationUrl + ? animationResponse.future + : Future.value(http.Response.bytes(_transparentPng, 200)), + ); + addTearDown(mediaClient.close); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'sys-huddle-animated-avatar', + kind: EventKind.huddleStarted, + pubkey: 'alice', + ), + ], + users: { + 'alice': UserProfile( + pubkey: 'alice', + displayName: 'Alice', + avatarUrl: profileUrl, + ), + }, + mediaClient: mediaClient, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(CircleAvatar)); + await tester.pumpAndSettle(); + + expect(find.byType(UserProfileSheet), findsOneWidget); + expect( + find.byKey(const ValueKey('progressive-animated-avatar-poster')), + findsOneWidget, + ); + expect( + find.byKey( + const ValueKey('progressive-animated-avatar-animation-loading'), + ), + findsOneWidget, + ); + + animationResponse.complete(http.Response.bytes(_transparentPng, 200)); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50)), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey( + const ValueKey('progressive-animated-avatar-animation-ready'), + ), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('progressive-animated-avatar-poster')), + findsNothing, + ); + + await tester.tap(find.byKey(const ValueKey('selected-profile-avatar'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('progressive-animated-avatar-animation')), + findsNothing, + ); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const ValueKey('selected-profile-avatar')), + matching: find.byType(MediaImage), + ), + ) + .url, + posterUrl, + ); + + await tester.tap(find.byKey(const ValueKey('selected-profile-avatar'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('progressive-animated-avatar-animation')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('progressive-animated-avatar-poster')), + findsNothing, + ); + }, + ); + testWidgets('opens a profile sheet from a generic system avatar', ( tester, ) async { @@ -6265,6 +6561,10 @@ List _replayedChannelIds(_RecordingRelaySocket socket) => socket ) .toList(); +final _transparentPng = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', +); + class _TestNavigatorObserver extends NavigatorObserver { int pushCount = 0; diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index b49ee78423..b0387b54f8 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -909,11 +909,12 @@ void main() { final options = find.byKey(const Key('community-switcher-options')); expect(options, findsOneWidget); final editButton = find.byKey(const Key('community-switcher-edit')); + expect(tester.getSize(editButton), const Size(56, 44)); + expect(find.byTooltip('Close sheet'), findsNothing); expect( - tester.getRect(options).top - tester.getRect(editButton).bottom, - closeTo(8, 0.01), + tester.getCenter(editButton).dx, + greaterThan(tester.getCenter(find.text('Switch Community')).dx), ); - expect(tester.getSize(editButton).height, 32); expect(find.text('alpha.example.com'), findsOneWidget); expect(find.text('bravo.example.com'), findsOneWidget); expect(find.text('Rename'), findsNothing); @@ -943,6 +944,10 @@ void main() { final inactiveSelection = find.byKey( const Key('community-switcher-selection-bravo'), ); + expect( + tester.getCenter(editButton).dx, + closeTo(tester.getCenter(activeSelection).dx, 0.01), + ); expect(tester.getSize(activeSelection), const Size.square(40)); expect( tester.getSize(find.byKey(const Key('community-switcher-circle-alpha'))), diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index 116a977852..ea33fb7944 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -306,6 +306,83 @@ void main() { expect(channels.single.lastMessageAt?.millisecondsSinceEpoch, 20 * 1000); }); + test( + 'loads all channel timestamps through one batched relay query', + () async { + final session = _FakeRelaySession( + memberships: [ + _membership(_channelA, myPk), + _membership(_channelB, myPk), + ], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'direct', channelType: 'dm'), + ], + recentMessages: const [ + NostrEvent( + id: 'stream-message', + pubkey: 'alice', + createdAt: 30, + kind: EventKind.streamMessageV2, + tags: [ + ['h', _channelA], + ], + content: 'hello', + sig: 'sig', + ), + NostrEvent( + id: 'dm-message', + pubkey: 'alice', + createdAt: 40, + kind: 9, + tags: [ + ['h', _channelB], + ], + content: 'hello privately', + sig: 'sig', + ), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + final channels = await container.read(channelsProvider.future); + + await _waitUntil(() => session.queryBatches.length == 2); + expect(session.queryBatches, hasLength(2)); + expect(session.queryBatches.first, hasLength(2)); + expect( + session.queryBatches.first + .map((filter) => filter.tags['#h']!.single) + .toSet(), + {_channelA, _channelB}, + ); + expect(session.queryBatches.last, hasLength(2)); + expect( + session.queryBatches.last.every( + (filter) => filter.limit == 1000 && filter.since == 0, + ), + isTrue, + ); + expect( + session.historyFilters.where((filter) { + final kinds = filter.kinds.toSet(); + return kinds.length == EventKind.channelMessageEventKinds.length && + kinds.containsAll(EventKind.channelMessageEventKinds); + }), + isEmpty, + ); + expect( + channels.firstWhere((channel) => channel.id == _channelA).lastMessageAt, + DateTime.fromMillisecondsSinceEpoch(30 * 1000, isUtc: true), + ); + expect( + channels.firstWhere((channel) => channel.id == _channelB).lastMessageAt, + DateTime.fromMillisecondsSinceEpoch(40 * 1000, isUtc: true), + ); + }, + ); + test('ephemeral (TTL) channels appear in the list', () async { // Regression: previously the provider unconditionally dropped any channel // with a `ttl` tag, which made TTL channels invisible on iOS even when the @@ -668,15 +745,18 @@ class _FakeRelaySession extends RelaySessionNotifier { required this.memberships, required this.metadata, this.hiddenDmEvents = const [], + this.recentMessages = const [], this.membershipFailures = 0, }); List memberships; List metadata; final List hiddenDmEvents; + final List recentMessages; int membershipFailures; final List historyFilters = []; + final List> queryBatches = []; final List subscribeFilters = []; final Map _subscriptions = {}; int _nextSubscriptionKey = 0; @@ -747,6 +827,33 @@ class _FakeRelaySession extends RelaySessionNotifier { return const []; } + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + queryBatches.add(filters); + return recentMessages.where((event) { + return filters.any((filter) { + if (!filter.kinds.contains(event.kind)) return false; + for (final entry in filter.tags.entries) { + final tagName = entry.key.startsWith('#') + ? entry.key.substring(1) + : entry.key; + if (!event.tags.any( + (tag) => + tag.length > 1 && + tag[0] == tagName && + entry.value.contains(tag[1]), + )) { + return false; + } + } + return true; + }); + }).toList(); + } + @override Future subscribe( NostrFilter filter, diff --git a/mobile/test/features/channels/message_mention_pubkeys_test.dart b/mobile/test/features/channels/message_mention_pubkeys_test.dart new file mode 100644 index 0000000000..8bc260a7ac --- /dev/null +++ b/mobile/test/features/channels/message_mention_pubkeys_test.dart @@ -0,0 +1,82 @@ +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/message_mention_pubkeys.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _self = 'self'; +const _agent = 'agent'; +const _human = 'human'; + +void main() { + test('implicitly addresses every participating DM recipient', () { + expect( + messageMentionPubkeys( + channel: _channel( + type: 'dm', + participantPubkeys: const [_self, _agent, _human], + ), + senderPubkey: _self, + explicitMentions: const [], + dmRecipientPubkeys: const [_agent, _human], + ), + [_agent, _human], + ); + }); + + test('preserves and deduplicates explicit mentions with DM recipients', () { + expect( + messageMentionPubkeys( + channel: _channel( + type: 'dm', + participantPubkeys: const [_self, _agent, _human], + ), + senderPubkey: _self, + explicitMentions: const [_human, _agent], + dmRecipientPubkeys: const [_agent], + ), + [_human, _agent], + ); + }); + + test('addresses human DMs but not ordinary channel members', () { + expect( + messageMentionPubkeys( + channel: _channel( + type: 'dm', + participantPubkeys: const [_self, _human], + ), + senderPubkey: _self, + explicitMentions: const [], + dmRecipientPubkeys: const [_human], + ), + [_human], + ); + expect( + messageMentionPubkeys( + channel: _channel( + type: 'stream', + participantPubkeys: const [_self, _agent], + ), + senderPubkey: _self, + explicitMentions: const [], + dmRecipientPubkeys: const [_agent], + ), + isEmpty, + ); + }); +} + +Channel _channel({ + required String type, + required List participantPubkeys, +}) => Channel( + id: 'channel', + name: 'Conversation', + channelType: type, + visibility: 'private', + description: '', + createdBy: _self, + createdAt: DateTime(2025), + memberCount: participantPubkeys.length, + participantPubkeys: participantPubkeys, + isMember: true, +); diff --git a/mobile/test/features/channels/send_message_provider_test.dart b/mobile/test/features/channels/send_message_provider_test.dart index 273336f283..2361360d5c 100644 --- a/mobile/test/features/channels/send_message_provider_test.dart +++ b/mobile/test/features/channels/send_message_provider_test.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/send_message_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; @@ -68,6 +70,149 @@ void main() { expect(removedIds, [localMessages.single.id]); }); + test('final signed event addresses the current DM agent member', () async { + final session = _PendingPublishRelaySession(); + final signingKey = nostr.Keys.generate().nsec; + final sender = nostr.Keys( + nostr.Nip19.decode(payload: signingKey).data, + ).public; + final staleAgent = 'a' * 64; + final activeAgent = 'c' * 64; + final human = 'b' * 64; + final send = SendMessage( + signedEventRelay: SignedEventRelay(session: session, nsec: signingKey), + fetchMembers: (_) async => [ + _member(sender), + _member(activeAgent), + _member(human), + ], + readUserCache: () => const {}, + addLocalMessage: (_, _) {}, + completeLocalMessage: (_, _) {}, + removeLocalMessage: (_, _) {}, + ); + + final result = send( + channelId: _channelId, + content: 'hello without a visible mention', + // Metadata still names the replaced agent. Delivery must follow the + // authoritative current membership snapshot instead. + channel: _dmChannel([sender, staleAgent, human]), + mentionPubkeys: const [], + ); + await session.published; + + expect(session.event.content, 'hello without a visible mention'); + expect(session.event.tags.where((tag) => tag.first == 'p').toList(), [ + ['p', activeAgent], + ['p', human], + ]); + + session.accept(); + await result; + }); + + test('final signed event addresses a human DM recipient', () async { + final session = _PendingPublishRelaySession(); + final signingKey = nostr.Keys.generate().nsec; + final sender = nostr.Keys( + nostr.Nip19.decode(payload: signingKey).data, + ).public; + final human = 'b' * 64; + final send = SendMessage( + signedEventRelay: SignedEventRelay(session: session, nsec: signingKey), + fetchMembers: (_) async => [_member(sender), _member(human)], + readUserCache: () => const {}, + addLocalMessage: (_, _) {}, + completeLocalMessage: (_, _) {}, + removeLocalMessage: (_, _) {}, + ); + + final result = send( + channelId: _channelId, + content: 'hello human', + channel: _dmChannel([sender, human]), + mentionPubkeys: const [], + ); + await session.published; + + expect(session.event.tags.where((tag) => tag.first == 'p').toList(), [ + ['p', human], + ]); + + session.accept(); + await result; + }); + + test( + 'falls back to metadata DM recipients when membership is empty', + () async { + final session = _PendingPublishRelaySession(); + final signingKey = nostr.Keys.generate().nsec; + final sender = nostr.Keys( + nostr.Nip19.decode(payload: signingKey).data, + ).public; + final recipient = 'b' * 64; + final send = SendMessage( + signedEventRelay: SignedEventRelay(session: session, nsec: signingKey), + fetchMembers: (_) async => const [], + readUserCache: () => const {}, + addLocalMessage: (_, _) {}, + completeLocalMessage: (_, _) {}, + removeLocalMessage: (_, _) {}, + ); + + final result = send( + channelId: _channelId, + content: 'hello from an unavailable roster', + channel: _dmChannel([sender, recipient]), + mentionPubkeys: const [], + ); + await session.published; + + expect(session.event.tags.where((tag) => tag.first == 'p').toList(), [ + ['p', recipient], + ]); + + session.accept(); + await result; + }, + ); + + test('falls back to metadata DM recipients when membership fails', () async { + final session = _PendingPublishRelaySession(); + final signingKey = nostr.Keys.generate().nsec; + final sender = nostr.Keys( + nostr.Nip19.decode(payload: signingKey).data, + ).public; + final recipientOne = 'b' * 64; + final recipientTwo = 'c' * 64; + final send = SendMessage( + signedEventRelay: SignedEventRelay(session: session, nsec: signingKey), + fetchMembers: (_) async => throw StateError('membership unavailable'), + readUserCache: () => const {}, + addLocalMessage: (_, _) {}, + completeLocalMessage: (_, _) {}, + removeLocalMessage: (_, _) {}, + ); + + final result = send( + channelId: _channelId, + content: 'hello group', + channel: _dmChannel([sender, recipientOne, recipientTwo]), + mentionPubkeys: [recipientOne.toUpperCase()], + ); + await session.published; + + expect(session.event.tags.where((tag) => tag.first == 'p').toList(), [ + ['p', recipientOne], + ['p', recipientTwo], + ]); + + session.accept(); + await result; + }); + test('cancels delivery after the active community changes', () async { final container = ProviderContainer(); addTearDown(container.dispose); @@ -95,6 +240,22 @@ void main() { const _channelId = '11111111-1111-4111-8111-111111111111'; +Channel _dmChannel(List participantPubkeys) => Channel( + id: _channelId, + name: 'DM', + channelType: 'dm', + visibility: 'private', + description: '', + createdBy: participantPubkeys.first, + createdAt: DateTime(2025), + memberCount: participantPubkeys.length, + participantPubkeys: participantPubkeys, + isMember: true, +); + +ChannelMember _member(String pubkey, {String role = 'member'}) => + ChannelMember(pubkey: pubkey, role: role, joinedAt: DateTime(2025)); + class _PendingPublishRelaySession extends RelaySessionNotifier { final Completer _result = Completer(); final Completer _published = Completer(); diff --git a/mobile/test/features/profile/profile_avatar_test.dart b/mobile/test/features/profile/profile_avatar_test.dart index 4562e4166c..f1207e27de 100644 --- a/mobile/test/features/profile/profile_avatar_test.dart +++ b/mobile/test/features/profile/profile_avatar_test.dart @@ -5,13 +5,20 @@ import 'package:buzz/features/profile/profile_avatar.dart'; import 'package:buzz/features/profile/profile_provider.dart'; import 'package:buzz/features/profile/user_profile.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/avatar_image.dart'; import 'package:buzz/shared/widgets/masked_avatar_badge.dart'; void main() { - Widget harness({bool showPresence = true, String presence = 'online'}) { + Widget harness({ + bool showPresence = true, + String presence = 'online', + String? avatarUrl, + }) { return ProviderScope( overrides: [ - profileProvider.overrideWith(_FakeProfileNotifier.new), + profileProvider.overrideWith( + () => _FakeProfileNotifier(avatarUrl: avatarUrl), + ), presenceProvider.overrideWith(() => _FakePresenceNotifier(presence)), ], child: MaterialApp( @@ -31,6 +38,14 @@ void main() { find.byType(MaskedAvatarBadge), ); expect(badge.geometry, AvatarBadgeMaskGeometry.presenceDot); + expect( + tester + .widget( + find.byKey(const ValueKey('profile-avatar-background')), + ) + .color, + AppTheme.light().colorScheme.primaryContainer, + ); expect( tester.widget(find.byType(ClipPath).last).clipper, isA(), @@ -86,12 +101,43 @@ void main() { findsNothing, ); }); + + testWidgets('keeps an animated avatar poster background transparent', ( + tester, + ) async { + const posterUrl = 'https://relay.example/media/poster.png'; + const animationUrl = 'https://relay.example/media/animation.png'; + final avatarUrl = + '$posterUrl#buzz-anim=${Uri.encodeComponent(animationUrl)}'; + + await tester.pumpWidget(harness(avatarUrl: avatarUrl)); + await tester.pump(); + + expect( + tester + .widget( + find.byKey(const ValueKey('profile-avatar-background')), + ) + .color, + Colors.transparent, + ); + expect( + tester + .widget(find.byType(AvatarImageContent)) + .imageUrl, + posterUrl, + ); + }); } class _FakeProfileNotifier extends ProfileNotifier { + _FakeProfileNotifier({this.avatarUrl}); + + final String? avatarUrl; + @override Future build() async => - const UserProfile(pubkey: 'aabb', displayName: 'Test'); + UserProfile(pubkey: 'aabb', displayName: 'Test', avatarUrl: avatarUrl); } class _FakePresenceNotifier extends PresenceNotifier { diff --git a/mobile/test/features/profile/set_status_sheet_test.dart b/mobile/test/features/profile/set_status_sheet_test.dart index 53563758d9..5ae424dfb0 100644 --- a/mobile/test/features/profile/set_status_sheet_test.dart +++ b/mobile/test/features/profile/set_status_sheet_test.dart @@ -2,6 +2,8 @@ import 'package:buzz/features/profile/set_status_sheet.dart'; import 'package:buzz/features/profile/user_status.dart'; import 'package:buzz/features/profile/user_status_provider.dart'; import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -9,7 +11,41 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../helpers/widget_helpers.dart'; void main() { - testWidgets('removes only the emoji from an existing status', (tester) async { + testWidgets('keeps its resting top edge when the keyboard appears', ( + tester, + ) async { + addTearDown(tester.view.reset); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + customEmojiListProvider.overrideWithValue(const []), + userStatusProvider.overrideWith(() => _RecordingUserStatusNotifier()), + ], + child: Builder( + builder: (context) => FilledButton( + onPressed: () => showSetStatusSheet(context), + child: const Text('Open status editor'), + ), + ), + ), + ); + + await tester.tap(find.text('Open status editor')); + await tester.pumpAndSettle(); + final restingTop = tester.getTopLeft(find.byType(BottomSheet)).dy; + + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pumpAndSettle(); + + expect( + tester.getTopLeft(find.byType(BottomSheet)).dy, + closeTo(restingTop, 0.01), + ); + }); + + testWidgets('uses Buzz rows, desktop presets, and a real duration', ( + tester, + ) async { final statusNotifier = _RecordingUserStatusNotifier(); await tester.pumpWidget( WidgetHelpers.testable( @@ -38,6 +74,25 @@ void main() { expect(find.text('\u{1F3AF}'), findsOneWidget); expect(find.byTooltip('Remove status emoji'), findsOneWidget); + expect(find.byKey(const ValueKey('status-input-outline')), findsOneWidget); + expect(find.text('Visible in chats to everyone'), findsNothing); + expect(find.text('Let others know what you\u2019re up to.'), findsNothing); + expect(find.text('1 day'), findsOneWidget); + expect(find.text('In a meeting'), findsOneWidget); + expect(find.text('Commuting'), findsOneWidget); + expect(find.text('Out sick'), findsOneWidget); + expect(find.text('Vacationing'), findsOneWidget); + expect(find.text('Working remotely'), findsOneWidget); + + await tester.tap(find.text('1 day')); + await tester.pumpAndSettle(); + expect(find.text('1 hour'), findsOneWidget); + expect(find.text('8 hours'), findsOneWidget); + expect(find.text('1 week'), findsOneWidget); + expect(find.text('Custom'), findsOneWidget); + await tester.tap(find.text('8 hours')); + await tester.pumpAndSettle(); + expect(find.text('8 hours'), findsOneWidget); await tester.tap(find.byTooltip('Remove status emoji')); await tester.pump(); @@ -49,24 +104,151 @@ void main() { 'Focusing', ); - await tester.tap(find.text('Save')); + final beforeSave = DateTime.now(); + await tester.tap(find.byTooltip('Save status')); await tester.pumpAndSettle(); expect(statusNotifier.savedText, 'Focusing'); expect(statusNotifier.savedEmoji, isEmpty); + expect(statusNotifier.savedExpiresAt, isNotNull); + expect( + statusNotifier.savedExpiresAt!.difference(beforeSave).inSeconds, + inInclusiveRange( + const Duration(hours: 8).inSeconds - 2, + const Duration(hours: 8).inSeconds + 2, + ), + ); + }); + + testWidgets('opens with an out-of-range remote expiration', (tester) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + customEmojiListProvider.overrideWithValue(const []), + userStatusProvider.overrideWith(() => _RecordingUserStatusNotifier()), + ], + child: Builder( + builder: (context) => FilledButton( + onPressed: () => showSetStatusSheet( + context, + currentStatus: const UserStatus( + text: 'Focusing', + emoji: '', + updatedAt: 1, + expiresAt: 9_000_000_000_000, + ), + ), + child: const Text('Open status editor'), + ), + ), + ), + ); + + await tester.tap(find.text('Open status editor')); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.text('Focusing'), findsOneWidget); + expect(find.text('1 day'), findsOneWidget); + }); + + testWidgets('clamps an existing custom date to the Android picker range', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final expiresAt = DateTime.now().add(const Duration(days: 730)); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + customEmojiListProvider.overrideWithValue(const []), + userStatusProvider.overrideWith(() => _RecordingUserStatusNotifier()), + ], + child: Builder( + builder: (context) => FilledButton( + onPressed: () => showSetStatusSheet( + context, + currentStatus: UserStatus( + text: 'Sabbatical', + emoji: '\u{1F3DD}\u{FE0F}', + updatedAt: 1, + expiresAt: expiresAt.millisecondsSinceEpoch ~/ 1000, + ), + ), + child: const Text('Open status editor'), + ), + ), + ), + ); + + await tester.tap(find.text('Open status editor')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Until')); + await tester.pumpAndSettle(); + + final picker = tester.widget( + find.byType(DatePickerDialog), + ); + expect(picker.initialDate, picker.lastDate); + debugDefaultTargetPlatformOverride = null; + }); + + testWidgets('custom duration reveals the native until picker', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final statusNotifier = _RecordingUserStatusNotifier(); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + customEmojiListProvider.overrideWithValue(const []), + userStatusProvider.overrideWith(() => statusNotifier), + ], + child: Builder( + builder: (context) => FilledButton( + onPressed: () => showSetStatusSheet(context), + child: const Text('Open status editor'), + ), + ), + ), + ); + + await tester.tap(find.text('Open status editor')); + await tester.pumpAndSettle(); + await tester.tap(find.text('1 day')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Custom')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Custom')); + await tester.pumpAndSettle(); + + expect(find.byType(CupertinoDatePicker), findsOneWidget); + await tester.tap(find.text('Done')); + await tester.pumpAndSettle(); + + expect(find.text('Custom'), findsOneWidget); + expect(find.text('Until'), findsOneWidget); + debugDefaultTargetPlatformOverride = null; }); } class _RecordingUserStatusNotifier extends UserStatusNotifier { String? savedText; String? savedEmoji; + DateTime? savedExpiresAt; @override Future build() async => null; @override - Future setStatus(String text, String emoji) async { + Future setStatus( + String text, + String emoji, { + DateTime? expiresAt, + }) async { savedText = text; savedEmoji = emoji; + savedExpiresAt = expiresAt; } } diff --git a/mobile/test/features/profile/settings_profile_header_test.dart b/mobile/test/features/profile/settings_profile_header_test.dart index a3c7b3dd9a..ca10c4726c 100644 --- a/mobile/test/features/profile/settings_profile_header_test.dart +++ b/mobile/test/features/profile/settings_profile_header_test.dart @@ -1,18 +1,128 @@ +import 'dart:async'; +import 'dart:convert'; + import 'package:buzz/features/profile/profile_provider.dart'; import 'package:buzz/features/profile/settings_profile_header.dart'; import 'package:buzz/features/profile/user_profile.dart'; import 'package:buzz/features/profile/user_status.dart'; import 'package:buzz/features/profile/user_status_provider.dart'; import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; +import 'package:buzz/shared/relay/media_auth.dart'; +import 'package:buzz/shared/relay/media_image.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/masked_avatar_badge.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http_testing; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../helpers/widget_helpers.dart'; void main() { + testWidgets('shows the poster until the animated avatar is ready', ( + tester, + ) async { + const posterUrl = 'https://relay.example/media/poster.png'; + const animationUrl = 'https://relay.example/media/animation.png'; + final profileUrl = + '$posterUrl#buzz-anim=${Uri.encodeComponent(animationUrl)}'; + final animationResponse = Completer(); + final client = http_testing.MockClient( + (request) => request.url.toString() == animationUrl + ? animationResponse.future + : Future.value(http.Response.bytes(_transparentPng, 200)), + ); + addTearDown(client.close); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith( + () => _FakeProfileNotifier(avatarUrl: profileUrl), + ), + presenceProvider.overrideWith(() => _FakePresenceNotifier('online')), + userStatusProvider.overrideWith(() => _FakeUserStatusNotifier(null)), + customEmojiListProvider.overrideWithValue(const []), + mediaGetAuthServiceProvider.overrideWithValue( + MediaGetAuthService(baseUrl: 'https://relay.example', nsec: null), + ), + mediaHttpClientProvider.overrideWithValue(client), + ], + child: const SettingsProfileHeader(), + ), + ); + await tester.pump(); + await tester.pump(); + + expect( + find.byKey(const ValueKey('progressive-animated-avatar-poster')), + findsOneWidget, + ); + expect( + tester + .widget( + find.byKey(const ValueKey('settings-profile-avatar-background')), + ) + .color, + Colors.transparent, + ); + expect( + find.byKey( + const ValueKey('progressive-animated-avatar-animation-loading'), + ), + findsOneWidget, + ); + expect( + tester + .widgetList(find.byType(MediaImage, skipOffstage: false)) + .map((image) => image.url), + containsAll([posterUrl, animationUrl]), + ); + + animationResponse.complete(http.Response.bytes(_transparentPng, 200)); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50)), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('progressive-animated-avatar-animation-ready')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('progressive-animated-avatar-poster')), + findsNothing, + ); + expect( + tester + .widgetList(find.byType(MediaImage)) + .map((image) => image.url), + [animationUrl], + ); + + await tester.tap(find.byKey(const ValueKey('settings-profile-avatar'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('progressive-animated-avatar-animation')), + findsNothing, + ); + expect(tester.widget(find.byType(MediaImage)).url, posterUrl); + + await tester.tap(find.byKey(const ValueKey('settings-profile-avatar'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('progressive-animated-avatar-animation')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('progressive-animated-avatar-poster')), + findsNothing, + ); + }); + testWidgets('uses a bounded icon for an unresolved status shortcode', ( tester, ) async { @@ -145,15 +255,19 @@ void main() { } class _FakeProfileNotifier extends ProfileNotifier { + _FakeProfileNotifier({this.avatarUrl}); + + final String? avatarUrl; + @override Future build() async => - const UserProfile(pubkey: 'aabb', displayName: 'Test'); + UserProfile(pubkey: 'aabb', displayName: 'Test', avatarUrl: avatarUrl); } class _FakeUserStatusNotifier extends UserStatusNotifier { _FakeUserStatusNotifier(this._status); - final UserStatus _status; + final UserStatus? _status; @override Future build() async => _status; @@ -173,3 +287,8 @@ class _FakePresenceNotifier extends PresenceNotifier { selected.add(status); } } + +final _transparentPng = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAA' + 'AAYAAjCB0C8AAAAASUVORK5CYII=', +); diff --git a/mobile/test/features/profile/user_status_provider_test.dart b/mobile/test/features/profile/user_status_provider_test.dart new file mode 100644 index 0000000000..e57d066a7a --- /dev/null +++ b/mobile/test/features/profile/user_status_provider_test.dart @@ -0,0 +1,235 @@ +import 'package:buzz/features/profile/user_status_cache_provider.dart'; +import 'package:buzz/features/profile/user_status.dart'; +import 'package:buzz/features/profile/user_status_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + test( + 'publishes the selected status expiration on the final signed event', + () async { + final keys = nostr.Keys.generate(); + final relaySession = _RecordingRelaySession(); + final expiresAt = DateTime.fromMillisecondsSinceEpoch( + 1_900_000_000 * 1000, + ); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith( + () => _FixedRelayConfigNotifier(keys.nsec), + ), + relaySessionProvider.overrideWith(() => relaySession), + userStatusCacheProvider.overrideWith(_EmptyUserStatusCache.new), + ], + ); + addTearDown(container.dispose); + + await container.read(userStatusProvider.future); + await container + .read(userStatusProvider.notifier) + .setStatus(' Focusing ', '\u{1F3AF}', expiresAt: expiresAt); + + final event = relaySession.published.single; + expect(event.kind, EventKind.userStatus); + expect(event.content, 'Focusing'); + expect(event.tags, contains(equals(['d', 'general']))); + expect(event.tags, contains(equals(['emoji', '\u{1F3AF}']))); + expect(event.tags, contains(equals(['expiration', '1900000000']))); + }, + ); + + testWidgets('clears the current status and shared cache at expiration', ( + tester, + ) async { + final keys = nostr.Keys.generate(); + final relaySession = _RecordingRelaySession(); + final statusCache = _RecordingUserStatusCache(); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith( + () => _FixedRelayConfigNotifier(keys.nsec), + ), + relaySessionProvider.overrideWith(() => relaySession), + userStatusCacheProvider.overrideWith(() => statusCache), + ], + ); + addTearDown(container.dispose); + + await container.read(userStatusProvider.future); + await container + .read(userStatusProvider.notifier) + .setStatus('Focusing', '🎯', expiresAt: DateTime.now()); + + expect(container.read(userStatusProvider).value, isNotNull); + expect(statusCache.updates.last.$2, isNotNull); + + await tester.pump(const Duration(milliseconds: 1)); + + expect(container.read(userStatusProvider).value, isNull); + expect(statusCache.updates.last, (keys.public.toLowerCase(), null)); + }); + + testWidgets('clears another user status without a new relay event', ( + tester, + ) async { + final container = ProviderContainer( + overrides: [ + relayClientProvider.overrideWithValue( + RelayClient(baseUrl: 'https://relay.example'), + ), + relaySessionProvider.overrideWith(_DisconnectedRelaySession.new), + ], + ); + addTearDown(container.dispose); + + final cache = container.read(userStatusCacheProvider.notifier); + cache.updateStatus( + 'alice', + UserStatus( + text: 'Focusing', + emoji: '', + updatedAt: 1, + expiresAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + ); + expect(container.read(userStatusCacheProvider)['alice'], isNotNull); + + await tester.pump(const Duration(milliseconds: 1)); + + expect(container.read(userStatusCacheProvider)['alice'], isNull); + }); + + test('ignores an out-of-range expiration in the shared cache scheduler', () { + final container = ProviderContainer( + overrides: [ + relayClientProvider.overrideWithValue( + RelayClient(baseUrl: 'https://relay.example'), + ), + relaySessionProvider.overrideWith(_DisconnectedRelaySession.new), + ], + ); + addTearDown(container.dispose); + + final status = UserStatus( + text: 'Focusing', + emoji: '', + updatedAt: 1, + expiresAt: 9_000_000_000_000, + ); + expect(status.expirationDateTime, isNull); + + expect( + () => container + .read(userStatusCacheProvider.notifier) + .updateStatus('alice', status), + returnsNormally, + ); + expect(container.read(userStatusCacheProvider)['alice'], same(status)); + }); + + test( + 'ignores an out-of-range expiration in the current-user scheduler', + () async { + final keys = nostr.Keys.generate(); + final relaySession = _StatusRelaySession( + NostrEvent( + id: 'status-1', + pubkey: keys.public, + createdAt: 1, + kind: EventKind.userStatus, + tags: const [ + ['d', 'general'], + ['expiration', '9000000000000'], + ], + content: 'Focusing', + sig: 'sig', + ), + ); + final container = ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith( + () => _FixedRelayConfigNotifier(keys.nsec), + ), + relaySessionProvider.overrideWith(() => relaySession), + userStatusCacheProvider.overrideWith(_EmptyUserStatusCache.new), + ], + ); + addTearDown(container.dispose); + + final status = await container.read(userStatusProvider.future); + + expect(status?.text, 'Focusing'); + expect(status?.expiresAt, 9_000_000_000_000); + expect(status?.expirationDateTime, isNull); + }, + ); +} + +class _FixedRelayConfigNotifier extends RelayConfigNotifier { + _FixedRelayConfigNotifier(this.nsec); + + final String nsec; + + @override + RelayConfig build() => + RelayConfig(baseUrl: 'https://relay.example', nsec: nsec); +} + +class _RecordingRelaySession extends RelaySessionNotifier { + final List published = []; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => []; + + @override + Future publish( + NostrEvent event, { + Duration timeout = const Duration(seconds: 8), + }) async { + published.add(event); + return event; + } +} + +class _StatusRelaySession extends _RecordingRelaySession { + _StatusRelaySession(this.status); + + final NostrEvent status; + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async => [status]; +} + +class _DisconnectedRelaySession extends RelaySessionNotifier { + @override + SessionState build() => + const SessionState(status: SessionStatus.disconnected); +} + +class _RecordingUserStatusCache extends UserStatusCacheNotifier { + final List<(String, UserStatus?)> updates = []; + + @override + Map build() => {}; + + @override + void updateStatus(String pubkey, UserStatus? status) { + updates.add((pubkey, status)); + } +} + +class _EmptyUserStatusCache extends UserStatusCacheNotifier { + @override + Map build() => {}; +} diff --git a/mobile/test/shared/animated_avatar_test.dart b/mobile/test/shared/animated_avatar_test.dart new file mode 100644 index 0000000000..773f957a4b --- /dev/null +++ b/mobile/test/shared/animated_avatar_test.dart @@ -0,0 +1,36 @@ +import 'package:buzz/shared/animated_avatar.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const posterUrl = 'https://relay.example/media/poster.png'; + const animationUrl = 'https://relay.example/media/animation.png?loop=1'; + + test('parses the selected poster and animated PNG URLs', () { + final url = '$posterUrl#buzz-anim=${Uri.encodeComponent(animationUrl)}'; + + final parsed = parseAnimatedAvatarUrl(url); + + expect(parsed?.posterUrl, posterUrl); + expect(parsed?.animationUrl, animationUrl); + }); + + test('rejects malformed and non-http animated avatar URLs', () { + expect(parseAnimatedAvatarUrl(posterUrl), isNull); + expect(parseAnimatedAvatarUrl('$posterUrl#buzz-anim='), isNull); + expect(parseAnimatedAvatarUrl('$posterUrl#buzz-anim=%E0%A4%A'), isNull); + expect(parseAnimatedAvatarUrl('$posterUrl#buzz-anim=%'), isNull); + expect( + parseAnimatedAvatarUrl( + '$posterUrl#buzz-anim=${Uri.encodeComponent('javascript:alert(1)')}', + ), + isNull, + ); + expect( + parseAnimatedAvatarUrl( + 'data:image/png;base64,xx#buzz-anim=' + '${Uri.encodeComponent(animationUrl)}', + ), + isNull, + ); + }); +} diff --git a/mobile/test/shared/relay/relay_session_test.dart b/mobile/test/shared/relay/relay_session_test.dart index 826e234400..7332075c3f 100644 --- a/mobile/test/shared/relay/relay_session_test.dart +++ b/mobile/test/shared/relay/relay_session_test.dart @@ -105,6 +105,91 @@ void main() { ); }); + test('queryRelay rotates the client after a timeout', () async { + final clients = <_ControlledHttpClient>[]; + final session = RelaySessionNotifier( + httpClientFactory: () { + final client = _ControlledHttpClient(); + clients.add(client); + return client; + }, + ); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => session), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + ), + ), + ], + ); + addTearDown(container.dispose); + container.read(relaySessionProvider); + + await expectLater( + session.queryRelay(const [], timeout: Duration.zero), + throwsA(isA()), + ); + expect(clients.single.closed, isTrue); + + final nextQuery = session.queryRelay(const []); + expect(clients, hasLength(2)); + clients.last.complete(http.Response('[]', 200)); + + expect(await nextQuery, isEmpty); + expect(clients.last.closed, isFalse); + }); + + test( + 'queryRelay defers closing a timed-out client until peer queries finish', + () async { + final clients = <_QueuedControlledHttpClient>[]; + final session = RelaySessionNotifier( + httpClientFactory: () { + final client = _QueuedControlledHttpClient(); + clients.add(client); + return client; + }, + ); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => session), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + ), + ), + ], + ); + addTearDown(container.dispose); + container.read(relaySessionProvider); + await Future.delayed(Duration.zero); + + final timedOutQuery = session.queryRelay( + const [], + timeout: const Duration(milliseconds: 10), + ); + final peerQuery = session.queryRelay(const []); + expect(clients.single.requestCount, 2); + + await expectLater(timedOutQuery, throwsA(isA())); + expect(clients.single.closed, isFalse); + + final nextQuery = session.queryRelay(const []); + expect(clients, hasLength(2)); + clients.first.complete(1, http.Response('[]', 200)); + expect(await peerQuery, isEmpty); + expect(clients.first.closed, isTrue); + + clients.last.complete(0, http.Response('[]', 200)); + expect(await nextQuery, isEmpty); + expect(clients.last.closed, isFalse); + }, + ); + test('queryRelay arms the rate-limit gate from a 429 retry hint', () async { final gateTimers = <_ManualTimer>[]; final gate = RelayRateLimitGate( @@ -1208,6 +1293,59 @@ void main() { }); } +class _ControlledHttpClient extends http.BaseClient { + final _response = Completer(); + bool closed = false; + + @override + Future send(http.BaseRequest request) => + _response.future; + + void complete(http.Response response) { + _response.complete( + http.StreamedResponse( + Stream.value(response.bodyBytes), + response.statusCode, + headers: response.headers, + reasonPhrase: response.reasonPhrase, + request: response.request, + ), + ); + } + + @override + void close() => closed = true; +} + +class _QueuedControlledHttpClient extends http.BaseClient { + final List> _responses = []; + bool closed = false; + + int get requestCount => _responses.length; + + @override + Future send(http.BaseRequest request) { + final response = Completer(); + _responses.add(response); + return response.future; + } + + void complete(int requestIndex, http.Response response) { + _responses[requestIndex].complete( + http.StreamedResponse( + Stream.value(response.bodyBytes), + response.statusCode, + headers: response.headers, + reasonPhrase: response.reasonPhrase, + request: response.request, + ), + ); + } + + @override + void close() => closed = true; +} + class _QueryHarness { final ProviderContainer container; final RelaySessionNotifier session; diff --git a/mobile/test/shared/widgets/avatar_image_test.dart b/mobile/test/shared/widgets/avatar_image_test.dart index 79bb03b6b5..cfaab6d4f1 100644 --- a/mobile/test/shared/widgets/avatar_image_test.dart +++ b/mobile/test/shared/widgets/avatar_image_test.dart @@ -4,17 +4,21 @@ import 'package:buzz/shared/widgets/avatar_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; void main() { const svg = '' '🦝'; - Widget subject(String? imageUrl) => MaterialApp( - home: AvatarImage( - imageUrl: imageUrl, - radius: 16, - fallback: const Text('R'), + Widget subject(String? imageUrl, {Color? backgroundColor}) => ProviderScope( + child: MaterialApp( + home: AvatarImage( + imageUrl: imageUrl, + radius: 16, + backgroundColor: backgroundColor, + fallback: const Text('R'), + ), ), ); @@ -55,6 +59,28 @@ void main() { expect(find.text('R'), findsOneWidget); }); + testWidgets('renders animated-avatar posters without an opaque background', ( + tester, + ) async { + const posterUrl = 'https://relay.example/media/poster.png'; + const animationUrl = 'https://relay.example/media/animation.png'; + final animatedUrl = + '$posterUrl#buzz-anim=${Uri.encodeComponent(animationUrl)}'; + + await tester.pumpWidget(subject(animatedUrl, backgroundColor: Colors.red)); + + expect( + tester.widget(find.byType(CircleAvatar)).backgroundColor, + Colors.transparent, + ); + expect( + tester + .widget(find.byType(AvatarImageContent)) + .imageUrl, + posterUrl, + ); + }); + testWidgets('reuses parsed raster bytes across parent rebuilds', ( tester, ) async { diff --git a/mobile/test/shared/widgets/modal_presentation_test.dart b/mobile/test/shared/widgets/modal_presentation_test.dart index 068187b545..85767e3f89 100644 --- a/mobile/test/shared/widgets/modal_presentation_test.dart +++ b/mobile/test/shared/widgets/modal_presentation_test.dart @@ -3,6 +3,7 @@ import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/modal_presentation.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -30,6 +31,10 @@ void main() { ), findsOneWidget, ); + final contentClip = tester.widget( + find.byKey(const ValueKey('concentric-sheet-content-clip')), + ); + expect(contentClip.borderRadius, BorderRadius.circular(Radii.dialog)); } finally { debugDefaultTargetPlatformOverride = null; } @@ -75,6 +80,66 @@ void main() { }, ); + testWidgets('native titled sheets leave the concentric surface unobscured', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const surfaceChannel = MethodChannel('buzz/concentric_sheet_surface'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + surfaceChannel, + (call) async => call.method == 'isSupported' ? true : null, + ); + try { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Builder( + builder: (context) => FilledButton( + onPressed: () => showBuzzModalBottomSheet( + context: context, + title: 'Members', + builder: (_) => const Text('Sheet body'), + ), + child: const Text('Open sheet'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open sheet')); + await tester.pumpAndSettle(); + + final nativeSurface = tester.widget(find.byType(UiKitView)); + expect( + nativeSurface.creationParams, + containsPair('color', lightColorScheme.surface.toARGB32()), + ); + expect(nativeSurface.creationParams, isNot(contains('headerGradient'))); + expect( + find.byKey(const ValueKey('buzz-sheet-header-gradient')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('buzz-sheet-surface-clip')), + findsNothing, + ); + final contentClip = tester.widget( + find.byKey(const ValueKey('concentric-sheet-content-clip')), + ); + expect(contentClip.borderRadius, BorderRadius.circular(Radii.dialog * 2)); + expect(contentClip.clipBehavior, Clip.antiAlias); + expect(find.text('Members'), findsOneWidget); + } finally { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + surfaceChannel, + null, + ); + debugDefaultTargetPlatformOverride = null; + } + }); + testWidgets( 'non-iOS sheets use Flutter drag handle and shared close control', (tester) async { @@ -88,6 +153,7 @@ void main() { builder: (context) => FilledButton( onPressed: () => showBuzzModalBottomSheet( context: context, + title: 'Sheet title', showDragHandle: true, builder: (_) => const Text('Sheet body'), ), @@ -102,8 +168,51 @@ void main() { await tester.pumpAndSettle(); final closeButton = find.byTooltip('Close sheet'); + final title = find.byKey(const ValueKey('buzz-sheet-title')); expect(closeButton, findsOneWidget); + expect(title, findsOneWidget); + expect( + find.byKey(const ValueKey('buzz-sheet-surface-clip')), + findsOneWidget, + ); expect(tester.getSize(closeButton), const Size.square(44)); + expect(tester.widget(title).style?.fontSize, 16); + expect( + find.byKey(const ValueKey('buzz-sheet-header-gradient')), + findsNothing, + ); + expect( + tester + .widget( + find.byKey(const ValueKey('buzz-sheet-surface')), + ) + .color, + lightColorScheme.surface, + ); + expect( + tester.getTopLeft(find.text('Sheet body')).dy - + tester + .getTopLeft(find.byKey(const ValueKey('buzz-sheet-surface'))) + .dy, + 80, + ); + expect(find.byType(BackdropFilter), findsNothing); + expect( + tester.widget(find.byType(BottomSheet)).backgroundColor, + Colors.transparent, + ); + expect( + tester.getCenter(title).dx, + closeTo(tester.getCenter(find.byType(BottomSheet)).dx, 0.01), + ); + expect( + tester.getCenter(title).dy, + closeTo(tester.getCenter(closeButton).dy, 0.01), + ); + expect( + tester.getCenter(closeButton).dx, + greaterThan(tester.getCenter(title).dx), + ); final closeGutter = find.ancestor( of: closeButton, matching: find.byWidgetPredicate( @@ -125,17 +234,33 @@ void main() { expect(gutterRect.right - closeRect.right, Grid.gutter); expect( tester.widget(find.byType(BottomSheet)).showDragHandle, - isTrue, + isFalse, ); expect( find.byKey(const ValueKey('buzz-sheet-drag-handle')), - findsNothing, + findsOneWidget, + ); + expect( + tester.getTopLeft(closeButton).dy - + tester.getTopLeft(find.byType(BottomSheet)).dy, + Grid.gutter, + ); + final dismissHandle = find.bySemanticsLabel('Dismiss').first; + expect(dismissHandle, findsOneWidget); + final semantics = tester.getSemantics(dismissHandle); + expect(semantics.flagsCollection.isButton, isTrue); + expect( + semantics.getSemanticsData().hasAction(SemanticsAction.tap), + isTrue, + ); + tester.binding.performSemanticsAction( + SemanticsActionEvent( + type: SemanticsAction.tap, + viewId: tester.view.viewId, + nodeId: semantics.id, + ), ); - expect(find.text('Sheet body'), findsOneWidget); - - await tester.tap(closeButton); await tester.pumpAndSettle(); - expect(find.text('Sheet body'), findsNothing); } finally { debugDefaultTargetPlatformOverride = null; @@ -178,7 +303,14 @@ void main() { ); expect(internalHandle, findsOneWidget); expect(tester.getSize(internalHandle), const Size(32, 4)); - expect(find.bySemanticsLabel('Drag handle'), findsOneWidget); + final dismissHandle = find.bySemanticsLabel('Dismiss'); + expect(dismissHandle, findsOneWidget); + final semantics = tester.getSemantics(dismissHandle); + expect(semantics.flagsCollection.isButton, isTrue); + expect( + semantics.getSemanticsData().hasAction(SemanticsAction.tap), + isTrue, + ); expect(find.byTooltip('Close sheet'), findsOneWidget); expect(find.text('Sheet body'), findsOneWidget); } finally {