diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index d9f3582e1a..f1b362f58a 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -1,3 +1,4 @@ +import 'huddle_timeline.dart'; import '../../shared/widgets/modal_presentation.dart'; import '../../shared/widgets/ios_scroll_to_top.dart'; import 'message_footer_layout.dart'; @@ -67,6 +68,8 @@ import 'message_actions.dart'; import 'message_action_backdrop_state.dart'; import 'message_long_press_region.dart'; import 'message_content.dart'; +import '../../shared/widgets/message_attachment_corners.dart'; +import 'message_media_avatar.dart'; import 'message_skeleton_body.dart'; import '../../shared/read_state/deferred_read_state_update.dart'; import '../../shared/read_state/read_state_provider.dart'; @@ -611,7 +614,12 @@ class ChannelDetailPage extends HookConsumerWidget { currentPubkey: currentPubkey, ); final timelineEntries = buildMainTimelineEntries( - timelineMessages, + // End events still update the original card through allMessages. + timelineMessages + .where( + (message) => !isCollapsedHuddleEnding(message, timelineMessages), + ) + .toList(), relaySummaries: ref .read(channelMessagesProvider(channel.id).notifier) .threadSummaries, diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart index 201b6e0b3d..50957fd051 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart @@ -330,15 +330,13 @@ class _HuddleJoinSurface extends ConsumerWidget { }; return Padding( - padding: const EdgeInsets.only( - left: messageAvatarSize + messageAvatarContentGap, - top: Grid.xxs, - ), + padding: const EdgeInsets.only(top: Grid.xxs), child: Container( + key: ValueKey('huddle-card-${message.id}'), padding: const EdgeInsets.all(Grid.twelve), decoration: BoxDecoration( color: context.colors.primary.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(Radii.md), + borderRadius: conversationAttachmentCorners, border: Border.all( color: context.colors.primary.withValues(alpha: 0.18), ), 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 bda5701e62..6597ce04e9 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -34,6 +34,7 @@ class _MessageBubble extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final messageSnapshotKey = useMemoized(GlobalKey.new, const []); + final mediaOffset = useMessageMediaOffset(message.content, message.tags); final hasLocalReplies = ref.watch( threadLocalRepliesProvider( ThreadRepliesArgs( @@ -124,271 +125,303 @@ class _MessageBubble extends HookConsumerWidget { ); } - return Padding( - padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0), - child: Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(Radii.md), - // The media carousel intentionally continues through the list's trailing - // gutter. InkWell still clips its ink to [borderRadius], while leaving - // overflowing message content visible. - clipBehavior: Clip.none, - child: MessageLongPressInkWell( - key: ValueKey('message-row-${message.id}'), - onLongPressDetails: openMessageActions, + return NotificationListener( + onNotification: (notification) { + mediaOffset.value = notification.offset; + return true; + }, + child: Padding( + padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0), + child: Material( + color: Colors.transparent, borderRadius: BorderRadius.circular(Radii.md), - highlightColor: context.colors.primary.withValues(alpha: 0.1), - snapshotKey: messageSnapshotKey, - // Tap opens existing threads; long-press can start a new one. - // MessageContent handles mention, channel-link, and media taps. - onTap: (!hasReplies && !hasLocalReplies) || allMessages == null - ? null - : () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: message, - allMessages: allMessages!, - channelId: currentChannelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, + // The media carousel intentionally continues through the list's trailing + // gutter. InkWell still clips its ink to [borderRadius], while leaving + // overflowing message content visible. + clipBehavior: Clip.none, + child: MessageLongPressInkWell( + key: ValueKey('message-row-${message.id}'), + onLongPressDetails: openMessageActions, + borderRadius: BorderRadius.circular(Radii.md), + highlightColor: context.colors.primary.withValues(alpha: 0.1), + snapshotKey: messageSnapshotKey, + // Tap opens existing threads; long-press can start a new one. + // MessageContent handles mention, channel-link, and media taps. + onTap: (!hasReplies && !hasLocalReplies) || allMessages == null + ? null + : () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: message, + allMessages: allMessages!, + channelId: currentChannelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), ), ), - ), - child: Padding( - padding: EdgeInsets.only( - bottom: showAvatar ? Grid.quarter : Grid.quarter / 2, - ), - child: MessageFooterLayout( - animateSize: true, - overlap: message.reactions.isNotEmpty || replyFooter != null - ? Grid.xs - : 0, - footer: message.reactions.isNotEmpty || replyFooter != null - ? Padding( - padding: const EdgeInsets.only( - left: - messageAvatarSize + - messageAvatarContentGap + - Grid.xxs, - ), - child: Wrap( - spacing: Grid.xxs, - runSpacing: Grid.half, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - if (message.reactions.isNotEmpty) - ReactionRow( - combined: true, - messageId: message.id, - channelId: currentChannelId, - reactions: message.reactions, - onToggle: (emoji) => - toggleReaction(ref, message, emoji), - showAddButton: isMember && !isArchived, - onAddReaction: () => showAddReactionPicker( - context: context, - ref: ref, - message: message, + child: Padding( + padding: EdgeInsets.only( + bottom: showAvatar ? Grid.quarter : Grid.quarter / 2, + ), + child: MessageFooterLayout( + animateSize: true, + overlap: message.reactions.isNotEmpty || replyFooter != null + ? Grid.xs + : 0, + footer: message.reactions.isNotEmpty || replyFooter != null + ? Padding( + padding: const EdgeInsets.only( + left: + messageAvatarSize + + messageAvatarContentGap + + Grid.xxs, + ), + child: Wrap( + spacing: Grid.xxs, + runSpacing: Grid.half, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (message.reactions.isNotEmpty) + ReactionRow( + combined: true, + messageId: message.id, + channelId: currentChannelId, + reactions: message.reactions, + onToggle: (emoji) => + toggleReaction(ref, message, emoji), + showAddButton: isMember && !isArchived, + onAddReaction: () => showAddReactionPicker( + context: context, + ref: ref, + message: message, + ), ), - ), - if (replyFooter != null) - Padding( - padding: const EdgeInsets.only(bottom: Grid.xxs), - child: replyFooter, - ), - ], - ), - ) - : const SizedBox.shrink(), - child: RepaintBoundary( - key: messageSnapshotKey, - child: Stack( - clipBehavior: Clip.none, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - const SizedBox(width: messageAvatarSize), - const SizedBox(width: messageAvatarContentGap), - Expanded( - child: Padding( - padding: EdgeInsets.only( - top: showAuthor ? Grid.half : 0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - Padding( - padding: const EdgeInsets.only( - bottom: Grid.quarter, - left: Grid.xs, - ), - child: Row( - children: [ - Expanded( - child: MessageAuthorMeta( - showTimestamp: true, - nameStyle: messageUsernameTextStyle - .copyWith( - fontSize: 13.1, - height: 17 / 13.1, - ), - metadataStyle: - messageMetadataTextStyle - .copyWith( - fontSize: 13.1, - height: 17 / 13.1, - ), - timestampStyle: - messageTimestampTextStyle - .copyWith( - fontSize: 11, - height: 17 / 11, - ), - displayName: displayName, - username: messageUsernameLabel( - profile, - ), - timestamp: formatMessageTime( - message.createdAt, - ), - nameColor: context.colors.onSurface, - metadataColor: - context.colors.onSurfaceVariant, - onAuthorTap: () => - showUserProfileSheet( - context, - message.pubkey, - names: - channelIdentityNamesProvider( - currentChannelId, + if (replyFooter != null) + Padding( + padding: const EdgeInsets.only( + bottom: Grid.xxs, + ), + child: replyFooter, + ), + ], + ), + ) + : const SizedBox.shrink(), + child: RepaintBoundary( + key: messageSnapshotKey, + child: Stack( + clipBehavior: Clip.none, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const SizedBox(width: messageAvatarSize), + const SizedBox(width: messageAvatarContentGap), + Expanded( + child: Padding( + padding: EdgeInsets.only( + top: showAuthor ? Grid.half : 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + Padding( + padding: const EdgeInsets.only( + bottom: Grid.quarter, + left: Grid.xs, + ), + child: Row( + children: [ + Expanded( + child: MessageAuthorMeta( + showTimestamp: true, + nameStyle: + messageUsernameTextStyle + .copyWith( + fontSize: 13.1, + height: 17 / 13.1, ), - ), - displayNameKey: ValueKey( - 'message-author-${message.id}', - ), - usernameKey: ValueKey( - 'message-username-${message.id}', - ), - timestampKey: ValueKey( - 'message-timestamp-${message.id}', - ), - ), - ), - if (message.edited) ...[ - const SizedBox(width: Grid.half), - Text( - '(edited)', - style: context.textTheme.labelSmall - ?.copyWith( - color: context - .colors - .onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), - ], - ], - ), - ), - MessageContent( - content: message.content, - bubble: true, - hasReactions: - message.reactions.isNotEmpty || - replyFooter != null, - bubbleEnd: showAvatar, - bubbleStart: showAuthor, - ownMessage: - currentPubkey?.toLowerCase() == pk, - mentionNames: resolvedMentionNames, - mentionLabels: mentionLabels, - agentMentionPubkeys: agentMentionPubkeys, - channelNames: channelNames, - tags: message.tags, - baseStyle: messageBodyTextStyle.copyWith( - color: context.colors.onSurface, - ), - scaleEmojiOnly: true, - mediaCarouselTrailingOverflow: Grid.gutter, - onMediaReply: allMessages == null - ? null - : () { - if (!context.mounted) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: message, - allMessages: allMessages!, - channelId: currentChannelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, + metadataStyle: + messageMetadataTextStyle + .copyWith( + fontSize: 13.1, + height: 17 / 13.1, + ), + timestampStyle: + messageTimestampTextStyle + .copyWith( + fontSize: 11, + height: 17 / 11, + ), + displayName: displayName, + username: messageUsernameLabel( + profile, + ), + timestamp: formatMessageTime( + message.createdAt, + ), + nameColor: + context.colors.onSurface, + metadataColor: context + .colors + .onSurfaceVariant, + onAuthorTap: () => + showUserProfileSheet( + context, + message.pubkey, + names: + channelIdentityNamesProvider( + currentChannelId, + ), + ), + displayNameKey: ValueKey( + 'message-author-${message.id}', + ), + usernameKey: ValueKey( + 'message-username-${message.id}', + ), + timestampKey: ValueKey( + 'message-timestamp-${message.id}', ), ), - ); - }, - onMediaMore: (viewerContext, imageUrl) => - showImageActions( - context: viewerContext, - ref: ref, - message: message, - channelId: currentChannelId, - imageUrl: imageUrl, - canManageMessage: canManageMessage, - onDeleted: () { - if (viewerContext.mounted) { - Navigator.of( - viewerContext, - ).maybePop(); - } - }, + ), + if (message.edited) ...[ + const SizedBox(width: Grid.half), + Text( + '(edited)', + style: context + .textTheme + .labelSmall + ?.copyWith( + color: context + .colors + .onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ], + ], ), - onChannelTap: (channelId) { - openChannelLink( - context: context, - ref: ref, - channelId: channelId, - currentChannelId: currentChannelId, - ); - }, - onMentionTap: (pubkey) => - showUserProfileSheet( - context, - pubkey, - names: channelIdentityNamesProvider( - currentChannelId, + ), + MessageContent( + content: message.content, + bubble: true, + hasReactions: + message.reactions.isNotEmpty || + replyFooter != null, + bubbleEnd: showAvatar, + bubbleStart: showAuthor, + ownMessage: + currentPubkey?.toLowerCase() == pk, + mentionNames: resolvedMentionNames, + mentionLabels: mentionLabels, + agentMentionPubkeys: agentMentionPubkeys, + channelNames: channelNames, + tags: message.tags, + baseStyle: messageBodyTextStyle.copyWith( + color: context.colors.onSurface, + ), + scaleEmojiOnly: true, + mediaCarouselTrailingOverflow: Grid.gutter, + onMediaReply: allMessages == null + ? null + : () { + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + ThreadDetailPage( + threadHead: message, + allMessages: allMessages!, + channelId: + currentChannelId, + currentPubkey: + currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + onMediaMore: (viewerContext, imageUrl) => + showImageActions( + context: viewerContext, + ref: ref, + message: message, + channelId: currentChannelId, + imageUrl: imageUrl, + canManageMessage: canManageMessage, + onDeleted: () { + if (viewerContext.mounted) { + Navigator.of( + viewerContext, + ).maybePop(); + } + }, ), - ), - ), - ], + onChannelTap: (channelId) { + openChannelLink( + context: context, + ref: ref, + channelId: channelId, + currentChannelId: currentChannelId, + ); + }, + onMentionTap: (pubkey) => + showUserProfileSheet( + context, + pubkey, + names: channelIdentityNamesProvider( + currentChannelId, + ), + ), + ), + ], + ), ), ), - ), - ], - ), - if (showAvatar) - Positioned( - left: 0, - bottom: 0, - child: GestureDetector( - onTap: () => showUserProfileSheet( - context, - message.pubkey, - names: channelIdentityNamesProvider( - currentChannelId, - ), - ), - child: _UserAvatar( - profile: profile, - pubkey: message.pubkey, - isAgent: isAgent, - ), - ), + ], ), - ], + if (showAvatar) + Positioned( + left: 0, + bottom: 0, + child: MessageMediaAvatar( + offset: mediaOffset, + child: Semantics( + container: true, + label: 'Open $displayName profile', + button: true, + excludeSemantics: true, + onTap: () => showUserProfileSheet( + context, + message.pubkey, + names: channelIdentityNamesProvider( + currentChannelId, + ), + ), + child: GestureDetector( + onTap: () => showUserProfileSheet( + context, + message.pubkey, + names: channelIdentityNamesProvider( + currentChannelId, + ), + ), + child: _UserAvatar( + profile: profile, + pubkey: message.pubkey, + isAgent: isAgent, + ), + ), + ), + ), + ), + ], + ), ), ), ), diff --git a/mobile/lib/features/channels/channel_detail_page/system_rows.dart b/mobile/lib/features/channels/channel_detail_page/system_rows.dart index df581c6b47..ffaeffeade 100644 --- a/mobile/lib/features/channels/channel_detail_page/system_rows.dart +++ b/mobile/lib/features/channels/channel_detail_page/system_rows.dart @@ -23,7 +23,9 @@ class _SystemMessageRow extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final spotlightKey = useMemoized(() => GlobalKey()); final systemEvent = message.systemEvent; - if (systemEvent == null) return const SizedBox.shrink(); + if (systemEvent == null) { + return const SizedBox.shrink(); + } final userCache = ref.watch(userCacheProvider); final sourceMessages = groupedMessages ?? [message]; @@ -130,6 +132,46 @@ class _SystemMessageRow extends HookConsumerWidget { ); } + final isHuddle = + systemEvent.type == SystemEventType.huddleStarted && + systemEvent.ephemeralChannelId != null; + final actor = (systemEvent.actorPubkey ?? message.pubkey).toLowerCase(); + Widget withActor(Widget content) { + if (!isHuddle) return content; + return Row( + key: ValueKey('huddle-activity-${message.id}'), + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Semantics( + container: true, + label: 'Open ${resolveLabel(actor)} profile', + button: true, + excludeSemantics: true, + onTap: () => showUserProfileSheet( + context, + actor, + names: channelIdentityNamesProvider(channelId), + ), + child: GestureDetector( + excludeFromSemantics: true, + onTap: () => showUserProfileSheet( + context, + actor, + names: channelIdentityNamesProvider(channelId), + ), + child: _UserAvatar( + profile: userCache[actor], + pubkey: actor, + isAgent: userCache[actor]?.ownerPubkey != null, + ), + ), + ), + const SizedBox(width: messageAvatarContentGap), + Expanded(child: content), + ], + ); + } + return Material( color: Colors.transparent, borderRadius: BorderRadius.circular(Radii.md), @@ -143,67 +185,105 @@ class _SystemMessageRow extends HookConsumerWidget { child: Padding( padding: const EdgeInsets.symmetric(vertical: Grid.xxs), child: Column( - crossAxisAlignment: CrossAxisAlignment.center, + crossAxisAlignment: isHuddle + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, children: [ - RepaintBoundary( - key: spotlightKey, - child: Align( - alignment: Alignment.center, - child: Container( - key: ValueKey('activity-card-${message.id}'), - padding: const EdgeInsets.symmetric( - horizontal: Grid.twelve, - vertical: Grid.xxs, - ), - decoration: BoxDecoration( - color: context.colors.onSurface.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(Radii.container), - ), - child: IntrinsicWidth( - child: groupedMembership != null - ? _MembershipSystemMessageContent( - event: groupedMembership, - channelId: channelId, - createdAt: message.createdAt, - resolveLabel: resolveLabel, - userCache: userCache, - ) - : messageStyleActor != null && - messageStyleActor.isNotEmpty && - messageStyleAction != null - ? _MessageStyleSystemMessageContent( - displayPubkey: messageStyleActor, - channelId: channelId, - createdAt: message.createdAt, - resolveLabel: resolveLabel, - userCache: userCache, - actionSpans: messageStyleAction, - ) - : Text.rich( - TextSpan( - children: [ - TextSpan( - text: systemEvent.describe(resolveLabel), - ), - ], + if (!isHuddle) + RepaintBoundary( + key: spotlightKey, + child: Align( + alignment: isHuddle + ? Alignment.centerLeft + : Alignment.center, + child: Container( + key: ValueKey('activity-card-${message.id}'), + padding: const EdgeInsets.symmetric( + horizontal: Grid.twelve, + vertical: Grid.xxs, + ), + decoration: BoxDecoration( + color: context.colors.onSurface.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(Radii.container), + ), + child: IntrinsicWidth( + child: groupedMembership != null + ? _MembershipSystemMessageContent( + event: groupedMembership, + channelId: channelId, + createdAt: message.createdAt, + resolveLabel: resolveLabel, + userCache: userCache, + ) + : messageStyleActor != null && + messageStyleActor.isNotEmpty && + messageStyleAction != null + ? _MessageStyleSystemMessageContent( + displayPubkey: messageStyleActor, + channelId: channelId, + createdAt: message.createdAt, + resolveLabel: resolveLabel, + userCache: userCache, + actionSpans: messageStyleAction, + ) + : Text.rich( + TextSpan( + children: [ + TextSpan( + text: systemEvent.describe(resolveLabel), + ), + ], + ), + textAlign: TextAlign.center, + style: _systemActionTextStyle(context), ), - textAlign: TextAlign.center, - style: _systemActionTextStyle(context), - ), + ), ), ), ), - ), if (systemEvent.type == SystemEventType.huddleStarted && systemEvent.ephemeralChannelId != null) - _HuddleJoinSurface( - message: message, - allMessages: allMessages ?? const [], - parentChannelId: channelId, - isMember: isMember, - isArchived: isArchived, + MessageFooterLayout( + animateSize: true, + overlap: reactions.isNotEmpty ? Grid.xs : 0, + footer: reactions.isEmpty + ? const SizedBox.shrink() + : Padding( + padding: const EdgeInsets.only( + left: + messageAvatarSize + + messageAvatarContentGap + + Grid.xxs, + ), + child: ReactionRow( + combined: true, + messageId: message.id, + channelId: channelId, + reactions: reactions, + onToggle: (emoji) => + toggleReaction(ref, message, emoji), + showAddButton: isMember && !isArchived, + onAddReaction: () => showAddReactionPicker( + context: context, + ref: ref, + message: message, + ), + ), + ), + child: withActor( + RepaintBoundary( + key: spotlightKey, + child: _HuddleJoinSurface( + message: message, + allMessages: allMessages ?? const [], + parentChannelId: channelId, + isMember: isMember, + isArchived: isArchived, + ), + ), + ), ), - if (reactions.isNotEmpty) + if (!isHuddle && reactions.isNotEmpty) IntrinsicWidth( child: ReactionRow( messageId: message.id, @@ -415,7 +495,7 @@ class _MembershipSystemMessageContent extends StatelessWidget { } TextStyle? _systemActionTextStyle(BuildContext context) { - return context.textTheme.bodySmall?.copyWith( + return bodyExtraSmallTextStyle.copyWith( color: context.colors.onSurfaceVariant, ); } diff --git a/mobile/lib/features/channels/huddle_timeline.dart b/mobile/lib/features/channels/huddle_timeline.dart new file mode 100644 index 0000000000..3900c61409 --- /dev/null +++ b/mobile/lib/features/channels/huddle_timeline.dart @@ -0,0 +1,22 @@ +import 'timeline_message.dart'; + +/// Endings collapse only when a visible start card represents them and no +/// reactions on the ending need their own interactive surface. +bool isCollapsedHuddleEnding( + TimelineMessage message, + Iterable loaded, +) { + final event = message.systemEvent; + if (event?.type != SystemEventType.huddleEnded || + event?.ephemeralChannelId == null || + message.reactions.isNotEmpty) { + return false; + } + return loaded.any( + (start) => + start.parentId == null && + start.systemEvent?.type == SystemEventType.huddleStarted && + start.systemEvent?.ephemeralChannelId == event!.ephemeralChannelId && + start.createdAt <= message.createdAt, + ); +} diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 2fd262ea14..2d60fc54d0 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -38,6 +38,7 @@ import 'message_content/bubble_segments.dart'; import 'message_mention_pill.dart'; import 'message_gallery.dart'; import 'message_gallery_frame.dart'; +import 'message_media_avatar.dart'; import 'message_media_geometry.dart'; import 'voice_note_attachment.dart'; @@ -194,6 +195,12 @@ class MessageContent extends HookConsumerWidget { final markdownTheme = GptMarkdownTheme.of(context); final chatColors = ChatColors.of(context); final ownTextColor = chatColors.foreground; + // Desktop uses blue links on neutral bubbles and the bubble ink on colors. + final linkColor = ownMessage && ownTextColor == Colors.white + ? ownTextColor + : context.colors.brightness == Brightness.dark + ? const Color(0xFF83C4FF) + : const Color(0xFF0B5FA8); final baseTextStyle = (baseStyle ?? context.textTheme.bodyMedium?.copyWith( @@ -410,13 +417,7 @@ class MessageContent extends HookConsumerWidget { color: ownMessage ? chatColors.outgoing : context.colors.secondaryContainer, - shape: _MessageBubbleShape( - start: bubbleStart && index == 0, - end: - bubbleEnd && - trailingGallery == null && - index == segments.length - 1, - ), + shape: const _MessageBubbleShape(), child: Padding( padding: EdgeInsets.fromLTRB( Grid.xs, @@ -426,38 +427,48 @@ class MessageContent extends HookConsumerWidget { ? Grid.xs : Grid.xxs, ), - child: ownMessage - ? Theme( - data: Theme.of(context).copyWith( - colorScheme: context.colors.copyWith( - primary: ownTextColor, - ), - ), - child: GptMarkdownTheme( - gptThemeData: markdownTheme.copyWith( - h1: markdownTheme.h1?.copyWith( - color: ownTextColor, - ), - h2: markdownTheme.h2?.copyWith( - color: ownTextColor, - ), - h3: markdownTheme.h3?.copyWith( - color: ownTextColor, - ), - h4: markdownTheme.h4?.copyWith( - color: ownTextColor, - ), - h5: markdownTheme.h5?.copyWith( - color: ownTextColor, - ), - h6: markdownTheme.h6?.copyWith( - color: ownTextColor, - ), - ), - child: renderMarkdown(segment.text), - ), - ) - : renderMarkdown(segment.text), + child: Theme( + data: Theme.of(context).copyWith( + colorScheme: context.colors.copyWith( + primary: linkColor, + ), + ), + child: GptMarkdownTheme( + gptThemeData: markdownTheme.copyWith( + h1: markdownTheme.h1?.copyWith( + color: ownMessage + ? ownTextColor + : context.colors.onSurface, + ), + h2: markdownTheme.h2?.copyWith( + color: ownMessage + ? ownTextColor + : context.colors.onSurface, + ), + h3: markdownTheme.h3?.copyWith( + color: ownMessage + ? ownTextColor + : context.colors.onSurface, + ), + h4: markdownTheme.h4?.copyWith( + color: ownMessage + ? ownTextColor + : context.colors.onSurface, + ), + h5: markdownTheme.h5?.copyWith( + color: ownMessage + ? ownTextColor + : context.colors.onSurface, + ), + h6: markdownTheme.h6?.copyWith( + color: ownMessage + ? ownTextColor + : context.colors.onSurface, + ), + ), + child: renderMarkdown(segment.text), + ), + ), ), ), ), diff --git a/mobile/lib/features/channels/message_content/bubble_shape.dart b/mobile/lib/features/channels/message_content/bubble_shape.dart index 6c3f5676b0..87f2119cec 100644 --- a/mobile/lib/features/channels/message_content/bubble_shape.dart +++ b/mobile/lib/features/channels/message_content/bubble_shape.dart @@ -1,49 +1,12 @@ part of '../message_content.dart'; -/// Grouped message corners without a tail. -class _MessageBubbleShape extends ShapeBorder { - const _MessageBubbleShape({required this.start, required this.end}); - - final bool start; - final bool end; - - @override - EdgeInsetsGeometry get dimensions => EdgeInsets.zero; - - @override - ShapeBorder scale(double t) => this; - - @override - Path getOuterPath(Rect rect, {TextDirection? textDirection}) { - final round = Radius.circular( - math.min(Radii.container, rect.shortestSide / 2), - ); - const joined = Radius.circular(Radii.sm); - final body = Path() - ..addRRect( - RRect.fromRectAndCorners( - rect, - topLeft: start ? round : joined, - topRight: round, - bottomLeft: end ? round : joined, - bottomRight: round, - ), +/// Uniform, tail-free corners for every message in a sender run. +class _MessageBubbleShape extends RoundedRectangleBorder { + const _MessageBubbleShape() + : super( + borderRadius: const BorderRadius.all(Radius.circular(Radii.container)), ); - return body; - } - - @override - Path getInnerPath(Rect rect, {TextDirection? textDirection}) => - getOuterPath(rect, textDirection: textDirection); - - @override - void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) {} } BorderRadius _attachmentCorners({required bool start, required bool end}) => - BorderRadius.only( - topLeft: Radius.circular(start ? Radii.container : Radii.sm), - topRight: const Radius.circular(Radii.container), - bottomLeft: Radius.circular(end ? Radii.container : Radii.sm), - bottomRight: const Radius.circular(Radii.container), - ); + conversationAttachmentCorners; diff --git a/mobile/lib/features/channels/message_content/media_carousel.dart b/mobile/lib/features/channels/message_content/media_carousel.dart index d847db1670..5165be8de5 100644 --- a/mobile/lib/features/channels/message_content/media_carousel.dart +++ b/mobile/lib/features/channels/message_content/media_carousel.dart @@ -260,6 +260,10 @@ class _MessageCarouselViewport extends HookWidget { child: NotificationListener( onNotification: (notification) { if (notification.depth == 0 && notification.metrics is PageMetrics) { + MessageGalleryScrollNotification( + notification.metrics.pixels - + notification.metrics.minScrollExtent, + ).dispatch(context); final index = (notification.metrics as PageMetrics).page!.round(); if (index != currentIndex) onPageChanged(index); } @@ -268,7 +272,11 @@ class _MessageCarouselViewport extends HookWidget { child: CustomScrollView( controller: controller, scrollDirection: Axis.horizontal, - physics: const _CarouselPagePhysics(), + physics: _CarouselPagePhysics( + parent: Theme.of(context).platform == TargetPlatform.iOS + ? const BouncingScrollPhysics() + : const ClampingScrollPhysics(), + ), clipBehavior: Clip.none, slivers: [ SliverPadding( diff --git a/mobile/lib/features/channels/message_gallery_frame.dart b/mobile/lib/features/channels/message_gallery_frame.dart index b43ea7cfa3..6c3b3627a8 100644 --- a/mobile/lib/features/channels/message_gallery_frame.dart +++ b/mobile/lib/features/channels/message_gallery_frame.dart @@ -39,18 +39,20 @@ class MessageGalleryFrame extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - if (loading) - ExcludeSemantics( - child: Stack( - alignment: Alignment.centerLeft, - children: [ - Opacity(opacity: 0, child: label), - const SkeletonBar(width: 48, height: 10), - ], - ), - ) - else - label, + Padding( + padding: const EdgeInsetsDirectional.only(start: Grid.xs), + child: loading + ? ExcludeSemantics( + child: Stack( + alignment: Alignment.centerLeft, + children: [ + Opacity(opacity: 0, child: label), + const SkeletonBar(width: 48, height: 10), + ], + ), + ) + : label, + ), const SizedBox(height: Grid.half + Grid.quarter), child, ], diff --git a/mobile/lib/features/channels/message_media_avatar.dart b/mobile/lib/features/channels/message_media_avatar.dart new file mode 100644 index 0000000000..d8822b2bee --- /dev/null +++ b/mobile/lib/features/channels/message_media_avatar.dart @@ -0,0 +1,75 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import 'message_gallery.dart'; +import 'message_media.dart'; +import 'message_content/link_normalizer.dart'; + +import '../../shared/theme/message_typography.dart'; + +/// Retains gallery displacement across caption edits, resetting for new images. +ValueNotifier useMessageMediaOffset( + String content, + List> tags, +) { + final gallery = extractTrailingImageGallery(normalizeBareLinks(content), { + for (final entry in parseImetaTags(tags).entries) + normalizeMarkdownDestination(entry.key): entry.value, + }); + final signature = gallery?.items.map((item) => item.url).join('\u0000'); + final offset = useValueNotifier(0.0); + useEffect(() { + offset.value = 0; + return null; + }, [signature]); + return offset; +} + +/// Reports gallery displacement so the author clears the overflowing images. +class MessageGalleryScrollNotification extends Notification { + /// Creates a displacement update relative to the first image. + const MessageGalleryScrollNotification(this.offset); + + /// Gallery displacement in logical pixels. + final double offset; +} + +/// Fades the author away as the gallery moves into its gutter. +/// Remains hidden on later images and returns when the first image returns. +class MessageMediaAvatar extends StatelessWidget { + /// Connects the author presentation to its message's gallery position. + const MessageMediaAvatar({ + super.key, + required this.offset, + required this.child, + }); + + /// Gallery displacement relative to the first image. + final ValueListenable offset; + + /// Author avatar and its profile action. + final Widget child; + + @override + Widget build(BuildContext context) => ValueListenableBuilder( + valueListenable: offset, + child: child, + builder: (context, offset, child) { + final visibility = 1 - (offset / messageAvatarSize).clamp(0.0, 1.0); + return IgnorePointer( + ignoring: visibility < 1, + child: ExcludeSemantics( + excluding: visibility < 1, + child: Opacity( + opacity: visibility, + child: Transform.scale( + scale: MediaQuery.disableAnimationsOf(context) ? 1 : visibility, + child: child, + ), + ), + ), + ); + }, + ); +} diff --git a/mobile/lib/features/channels/message_mention_pill.dart b/mobile/lib/features/channels/message_mention_pill.dart index 8b58bad991..4d825a7452 100644 --- a/mobile/lib/features/channels/message_mention_pill.dart +++ b/mobile/lib/features/channels/message_mention_pill.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; -import '../../shared/theme/buzz_icons.dart'; +import '../../shared/widgets/buzz_agent_icon.dart'; import '../../shared/theme/theme.dart'; -/// The inline identity pill shared by chat mentions and channel actions. +/// The inline identity label shared by chat mentions and channel actions. class MessageMentionPill extends StatelessWidget { /// Visible contextual identity name. final String label; @@ -11,7 +11,7 @@ class MessageMentionPill extends StatelessWidget { /// Full identity label for assistive technologies. final String? semanticsLabel; - /// Whether to use the bot icon instead of the @ prefix. + /// Whether to use the desktop robot icon instead of the @ prefix. final bool isAgent; /// Typography inherited from the surrounding message. @@ -41,38 +41,21 @@ class MessageMentionPill extends StatelessWidget { ); final fontSize = style.fontSize ?? 16; - return Container( - padding: const EdgeInsets.fromLTRB( - Grid.half, - Grid.quarter + 1, - Grid.half, - Grid.quarter, - ), - decoration: BoxDecoration( - color: context.colors.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(Radii.sm), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if (isAgent) ...[ - Icon( - BuzzIcons.bot, - size: fontSize * 0.95, - color: context.colors.primary, - ), - const SizedBox(width: Grid.quarter + 1), - ] else - Transform.translate( - offset: const Offset(0, -Grid.quarter), - child: Text('@', style: style), - ), - Flexible( - child: Text(label, style: style, semanticsLabel: semanticsLabel), + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (isAgent) + BuzzAgentIcon(size: fontSize, color: context.colors.primary) + else + Transform.translate( + offset: const Offset(0, -Grid.quarter), + child: Text('@', style: style), ), - ], - ), + Flexible( + child: Text(label, style: style, semanticsLabel: semanticsLabel), + ), + ], ); } } diff --git a/mobile/lib/features/channels/reading_marks.dart b/mobile/lib/features/channels/reading_marks.dart index 1ec4f0624b..0210a570d7 100644 --- a/mobile/lib/features/channels/reading_marks.dart +++ b/mobile/lib/features/channels/reading_marks.dart @@ -1,3 +1,4 @@ +import 'huddle_timeline.dart'; import 'dart:async'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -99,6 +100,7 @@ MapEntry? _catchUpMark({ // the live bottom, for example after a jump into history. final showsNewest = !loaded.any( (message) => + (threadRootId != null || !isCollapsedHuddleEnding(message, loaded)) && message.createdAt > bottom.createdAt && (threadRootId != null ? _threadRootOf(message) == threadRootId @@ -106,9 +108,8 @@ MapEntry? _catchUpMark({ ); if (!showsNewest) return null; - // Cut at the bottom row. A newer reply's time would also read a top-level - // message that arrives late with an earlier time. A clock ahead of now, on - // this device or the sender's, must not read messages before they arrive. + // Never read beyond the last rendered message: a message between that + // row and a hidden lifecycle event may still arrive out of order. final cut = bottom.createdAt < now ? bottom.createdAt : now; final key = threadRootId != null ? threadActivityContextKey(threadRootId) diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 4372a31af3..7890cd9622 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -52,6 +52,7 @@ import 'message_actions.dart'; import 'message_action_backdrop_state.dart'; import 'message_long_press_region.dart'; import 'message_content.dart'; +import 'message_media_avatar.dart'; import 'reaction_row.dart'; import '../../shared/read_state/read_state_provider.dart'; import 'send_message_provider.dart'; diff --git a/mobile/lib/features/channels/thread_detail_page/thread_message.dart b/mobile/lib/features/channels/thread_detail_page/thread_message.dart index b28da14bd9..aa4f6e1d71 100644 --- a/mobile/lib/features/channels/thread_detail_page/thread_message.dart +++ b/mobile/lib/features/channels/thread_detail_page/thread_message.dart @@ -37,6 +37,7 @@ class _ThreadMessage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final messageSnapshotKey = useMemoized(GlobalKey.new, const []); + final mediaOffset = useMessageMediaOffset(message.content, message.tags); final pk = message.pubkey.toLowerCase(); final profile = ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? @@ -127,249 +128,277 @@ class _ThreadMessage extends HookConsumerWidget { alpha: _landingHighlightOpacity * highlightProgress, ); - return Padding( - padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0), - child: DecoratedBox( - key: ValueKey('thread-message-${message.id}'), - decoration: BoxDecoration( - color: highlightColor, - borderRadius: BorderRadius.circular(Radii.md), - ), - child: Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(Radii.md), - // The media carousel intentionally continues through the list's - // trailing gutter. InkWell still clips its ink to [borderRadius], - // while leaving overflowing message content visible. - clipBehavior: Clip.none, - child: MessageLongPressInkWell( - key: ValueKey('thread-message-row-${message.id}'), - onLongPressDetails: openMessageActions, + return NotificationListener( + onNotification: (notification) { + mediaOffset.value = notification.offset; + return true; + }, + child: Padding( + padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0), + child: DecoratedBox( + key: ValueKey('thread-message-${message.id}'), + decoration: BoxDecoration( + color: highlightColor, borderRadius: BorderRadius.circular(Radii.md), - highlightColor: context.colors.primary.withValues(alpha: 0.1), - snapshotKey: messageSnapshotKey, - child: Padding( - padding: EdgeInsets.only( - bottom: showAvatar ? Grid.quarter : Grid.quarter / 2, - ), - child: MessageFooterLayout( - animateSize: true, - overlap: message.reactions.isNotEmpty ? Grid.xs : 0, - footer: (isThreadHead || message.reactions.isNotEmpty) - ? Padding( - padding: const EdgeInsets.only( - left: - messageAvatarSize + - messageAvatarContentGap + - Grid.xxs, - ), - child: ReactionRow( - combined: true, - messageId: message.id, - channelId: channelId, - reactions: message.reactions, - onToggle: (emoji) => - toggleReaction(ref, message, emoji), - showAddButton: isMember && !isArchived, - onAddReaction: () => showAddReactionPicker( - context: context, - ref: ref, - message: message, + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(Radii.md), + // The media carousel intentionally continues through the list's + // trailing gutter. InkWell still clips its ink to [borderRadius], + // while leaving overflowing message content visible. + clipBehavior: Clip.none, + child: MessageLongPressInkWell( + key: ValueKey('thread-message-row-${message.id}'), + onLongPressDetails: openMessageActions, + borderRadius: BorderRadius.circular(Radii.md), + highlightColor: context.colors.primary.withValues(alpha: 0.1), + snapshotKey: messageSnapshotKey, + child: Padding( + padding: EdgeInsets.only( + bottom: showAvatar ? Grid.quarter : Grid.quarter / 2, + ), + child: MessageFooterLayout( + animateSize: true, + overlap: message.reactions.isNotEmpty ? Grid.xs : 0, + footer: (isThreadHead || message.reactions.isNotEmpty) + ? Padding( + padding: const EdgeInsets.only( + left: + messageAvatarSize + + messageAvatarContentGap + + Grid.xxs, ), - ), - ) - : const SizedBox.shrink(), - child: RepaintBoundary( - key: messageSnapshotKey, - child: Stack( - clipBehavior: Clip.none, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - const SizedBox(width: messageAvatarSize), - const SizedBox(width: messageAvatarContentGap), - Expanded( - child: Padding( - padding: EdgeInsets.only( - top: showAuthor ? Grid.half : 0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - Padding( - padding: const EdgeInsets.only( - bottom: Grid.quarter, - left: Grid.xs, - ), - child: Row( - children: [ - Expanded( - child: MessageAuthorMeta( - showTimestamp: true, - nameStyle: - messageUsernameTextStyle - .copyWith( - fontSize: 13.1, - height: 17 / 13.1, - ), - metadataStyle: - messageMetadataTextStyle - .copyWith( - fontSize: 13.1, - height: 17 / 13.1, - ), - timestampStyle: - messageTimestampTextStyle - .copyWith( - fontSize: 11, - height: 17 / 11, - ), - displayName: displayName, - username: messageUsernameLabel( - profile, - ), - timestamp: formatMessageTime( - message.createdAt, - ), - nameColor: - context.colors.onSurface, - metadataColor: context - .colors - .onSurfaceVariant, - onAuthorTap: () => - showUserProfileSheet( - context, - message.pubkey, - names: - channelIdentityNamesProvider( - channelId, + child: ReactionRow( + combined: true, + messageId: message.id, + channelId: channelId, + reactions: message.reactions, + onToggle: (emoji) => + toggleReaction(ref, message, emoji), + showAddButton: isMember && !isArchived, + onAddReaction: () => showAddReactionPicker( + context: context, + ref: ref, + message: message, + ), + ), + ) + : const SizedBox.shrink(), + child: RepaintBoundary( + key: messageSnapshotKey, + child: Stack( + clipBehavior: Clip.none, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const SizedBox(width: messageAvatarSize), + const SizedBox(width: messageAvatarContentGap), + Expanded( + child: Padding( + padding: EdgeInsets.only( + top: showAuthor ? Grid.half : 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + Padding( + padding: const EdgeInsets.only( + bottom: Grid.quarter, + left: Grid.xs, + ), + child: Row( + children: [ + Expanded( + child: MessageAuthorMeta( + showTimestamp: true, + nameStyle: + messageUsernameTextStyle + .copyWith( + fontSize: 13.1, + height: 17 / 13.1, ), - ), - displayNameKey: ValueKey( - 'thread-message-author-${message.id}', - ), - usernameKey: ValueKey( - 'thread-message-username-${message.id}', - ), - timestampKey: ValueKey( - 'thread-message-timestamp-${message.id}', + metadataStyle: + messageMetadataTextStyle + .copyWith( + fontSize: 13.1, + height: 17 / 13.1, + ), + timestampStyle: + messageTimestampTextStyle + .copyWith( + fontSize: 11, + height: 17 / 11, + ), + displayName: displayName, + username: messageUsernameLabel( + profile, + ), + timestamp: formatMessageTime( + message.createdAt, + ), + nameColor: + context.colors.onSurface, + metadataColor: context + .colors + .onSurfaceVariant, + onAuthorTap: () => + showUserProfileSheet( + context, + message.pubkey, + names: + channelIdentityNamesProvider( + channelId, + ), + ), + displayNameKey: ValueKey( + 'thread-message-author-${message.id}', + ), + usernameKey: ValueKey( + 'thread-message-username-${message.id}', + ), + timestampKey: ValueKey( + 'thread-message-timestamp-${message.id}', + ), ), ), - ), - if (message.edited) ...[ - const SizedBox(width: Grid.half), - Text( - '(edited)', - style: context - .textTheme - .labelSmall - ?.copyWith( - color: context - .colors - .onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), - ], - ], - ), - ), - MessageContent( - content: message.content, - bubble: true, - hasReactions: message.reactions.isNotEmpty, - bubbleEnd: showAvatar, - bubbleStart: showAuthor, - ownMessage: - currentPubkey?.toLowerCase() == pk, - mentionNames: resolvedMentionNames, - mentionLabels: mentionLabels, - agentMentionPubkeys: agentMentionPubkeys, - channelNames: channelNames, - tags: message.tags, - baseStyle: messageBodyTextStyle.copyWith( - color: context.colors.onSurface, - ), - scaleEmojiOnly: true, - mediaCarouselTrailingOverflow: Grid.gutter, - onMediaReply: allMessages == null - ? null - : () { - if (!context.mounted) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - ThreadDetailPage( - threadHead: message, - allMessages: allMessages!, - channelId: channelId, - currentPubkey: - currentPubkey, - isMember: isMember, - isArchived: isArchived, + if (message.edited) ...[ + const SizedBox(width: Grid.half), + Text( + '(edited)', + style: context + .textTheme + .labelSmall + ?.copyWith( + color: context + .colors + .onSurfaceVariant, + fontStyle: + FontStyle.italic, ), ), - ); - }, - onMediaMore: (viewerContext, imageUrl) => - showImageActions( - context: viewerContext, - ref: ref, - message: message, - channelId: channelId, - imageUrl: imageUrl, - canManageMessage: canManageMessage, - onDeleted: () { - if (viewerContext.mounted) { - Navigator.of( - viewerContext, - ).maybePop(); - } - }, + ], + ], ), - onChannelTap: (targetChannelId) { - openChannelLink( - context: context, - ref: ref, - channelId: targetChannelId, - currentChannelId: channelId, - ); - }, - onMentionTap: (pubkey) => - showUserProfileSheet( - context, - pubkey, - names: channelIdentityNamesProvider( - channelId, + ), + MessageContent( + content: message.content, + bubble: true, + hasReactions: + message.reactions.isNotEmpty, + bubbleEnd: showAvatar, + bubbleStart: showAuthor, + ownMessage: + currentPubkey?.toLowerCase() == pk, + mentionNames: resolvedMentionNames, + mentionLabels: mentionLabels, + agentMentionPubkeys: agentMentionPubkeys, + channelNames: channelNames, + tags: message.tags, + baseStyle: messageBodyTextStyle.copyWith( + color: context.colors.onSurface, + ), + scaleEmojiOnly: true, + mediaCarouselTrailingOverflow: + Grid.gutter, + onMediaReply: allMessages == null + ? null + : () { + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + ThreadDetailPage( + threadHead: message, + allMessages: + allMessages!, + channelId: channelId, + currentPubkey: + currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ), + ); + }, + onMediaMore: (viewerContext, imageUrl) => + showImageActions( + context: viewerContext, + ref: ref, + message: message, + channelId: channelId, + imageUrl: imageUrl, + canManageMessage: canManageMessage, + onDeleted: () { + if (viewerContext.mounted) { + Navigator.of( + viewerContext, + ).maybePop(); + } + }, ), - ), + onChannelTap: (targetChannelId) { + openChannelLink( + context: context, + ref: ref, + channelId: targetChannelId, + currentChannelId: channelId, + ); + }, + onMentionTap: (pubkey) => + showUserProfileSheet( + context, + pubkey, + names: channelIdentityNamesProvider( + channelId, + ), + ), + ), + ], + ), + ), + ), + ], + ), + if (showAvatar) + Positioned( + left: 0, + bottom: 0, + child: MessageMediaAvatar( + offset: mediaOffset, + child: Semantics( + container: true, + label: 'Open $displayName profile', + button: true, + excludeSemantics: true, + onTap: () => showUserProfileSheet( + context, + message.pubkey, + names: channelIdentityNamesProvider( + channelId, ), - ], + ), + child: GestureDetector( + onTap: () => showUserProfileSheet( + context, + message.pubkey, + names: channelIdentityNamesProvider( + channelId, + ), + ), + child: _Avatar( + profile: profile, + pubkey: message.pubkey, + isAgent: isAgent, + ), + ), ), ), ), - ], - ), - if (showAvatar) - Positioned( - left: 0, - bottom: 0, - child: GestureDetector( - onTap: () => showUserProfileSheet( - context, - message.pubkey, - names: channelIdentityNamesProvider(channelId), - ), - child: _Avatar( - profile: profile, - pubkey: message.pubkey, - isAgent: isAgent, - ), - ), - ), - ], + ], + ), ), ), ), diff --git a/mobile/lib/features/channels/timeline_message.dart b/mobile/lib/features/channels/timeline_message.dart index 3e2e72005b..ed932f60c6 100644 --- a/mobile/lib/features/channels/timeline_message.dart +++ b/mobile/lib/features/channels/timeline_message.dart @@ -89,7 +89,9 @@ class SystemEvent { try { final content = jsonDecode(event.content); if (content is Map) { - ephemeralChannelId = _readString(content, 'ephemeral_channel_id'); + ephemeralChannelId = _canonicalHuddleRoomId( + content['ephemeral_channel_id'], + ); } } catch (_) { // The row still renders, but joining requires the validated room ID. @@ -787,3 +789,24 @@ String? _readString(Map json, String key) { final value = json[key]; return value is String ? value : null; } + +// Match Rust Uuid::parse_str: simple, hyphenated, URN and braced forms. +String? _canonicalHuddleRoomId(Object? value) { + if (value is! String) return null; + var text = value; + if (text.length == 45 && text.startsWith('urn:uuid:')) { + text = text.substring(9); + } else if (text.length == 38 && text.startsWith('{') && text.endsWith('}')) { + text = text.substring(1, 37); + } + if (RegExp(r'^[0-9a-fA-F]{32}$').hasMatch(text)) { + text = + '${text.substring(0, 8)}-${text.substring(8, 12)}-${text.substring(12, 16)}-${text.substring(16, 20)}-${text.substring(20)}'; + } + if (!RegExp( + r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', + ).hasMatch(text)) { + return null; + } + return text.toLowerCase(); +} diff --git a/mobile/lib/shared/widgets/buzz_agent_icon.dart b/mobile/lib/shared/widgets/buzz_agent_icon.dart new file mode 100644 index 0000000000..64fd35ac2e --- /dev/null +++ b/mobile/lib/shared/widgets/buzz_agent_icon.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +/// The desktop design system's soft, wide robot mark. +class BuzzAgentIcon extends StatelessWidget { + const BuzzAgentIcon({super.key, required this.size, required this.color}); + + final double size; + final Color color; + + @override + Widget build(BuildContext context) => SvgPicture.string( + '' + '' + '' + '' + '' + '' + '', + width: size, + height: size, + colorFilter: ColorFilter.mode(color, BlendMode.srcIn), + excludeFromSemantics: true, + ); +} diff --git a/mobile/lib/shared/widgets/message_attachment_corners.dart b/mobile/lib/shared/widgets/message_attachment_corners.dart index d9b609c60c..4c8b227bc3 100644 --- a/mobile/lib/shared/widgets/message_attachment_corners.dart +++ b/mobile/lib/shared/widgets/message_attachment_corners.dart @@ -1,5 +1,12 @@ import 'package:flutter/widgets.dart'; +import '../theme/app_theme.dart'; + +/// Shared conversation corners for media, voice notes, and huddle cards. +const conversationAttachmentCorners = BorderRadius.all( + Radius.circular(Radii.container), +); + /// Shares conversation corner geometry with attachment surfaces. class MessageAttachmentCorners extends InheritedWidget { final BorderRadius borderRadius; diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 67c425f364..402ecf2f7f 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1,3 +1,5 @@ +import 'package:flutter/semantics.dart'; +import 'package:buzz/features/channels/message_media_avatar.dart'; import 'dart:async'; import 'package:buzz/shared/push/push_bridge.dart'; import 'package:buzz/features/channels/channel_actions_sheet.dart'; @@ -88,6 +90,7 @@ part 'channel_detail_page_test/loading_review_tests.dart'; part 'channel_detail_page_test/presence_tests.dart'; part 'channel_detail_page_test/action_row_tests.dart'; part 'channel_detail_page_test/bubble_tests.dart'; +part 'channel_detail_page_test/review_avatar_tests.dart'; const _channelId = '11111111-2222-4333-8444-555555555555'; const _huddleChannelId = '8d764100-fd8f-44cf-9c98-6d8fbd739b8c'; @@ -518,6 +521,7 @@ void main() { presenceTests(); actionRowTests(); bubbleLayoutTests(); + reviewAvatarTests(); setUp(() async { SharedPreferences.setMockInitialValues({}); _testPrefs = await SharedPreferences.getInstance(); @@ -2162,6 +2166,60 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('hidden huddle end does not block bottom catch-up', ( + tester, + ) async { + final readState = _SynchronousReadStateNotifier( + const ReadStateState( + isReady: true, + pubkey: 'self', + contexts: {}, + version: 0, + ), + ); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'represented-start', + kind: EventKind.huddleStarted, + createdAt: 900, + ), + _textMsg( + id: 'msg1', + pubkey: 'alice', + content: 'First', + createdAt: 1100, + ), + _textMsg( + id: 'msg2', + pubkey: 'alice', + content: 'Latest', + createdAt: 1200, + ), + _huddleMsg( + id: 'hidden-end', + kind: EventKind.huddleEnded, + createdAt: 1300, + ), + ], + readStateNotifier: readState, + ), + ); + + expect(tester.takeException(), isNull); + await tester.pump(); + // Opening the channel does not read it; holding still at the bottom + // does, without moving the channel mark. + expect(readState.markedContexts, isEmpty); + + await tester.pump(const Duration(milliseconds: 300)); + + expect(readState.markedContexts, {'activity:$_channelId': 1200}); + expect(tester.takeException(), isNull); + }); + testWidgets('reading the bottom ends a manual channel unread only', ( tester, ) async { @@ -4132,9 +4190,55 @@ void main() { final carouselRect = tester.getRect(carousel); final imageCountRect = tester.getRect(imageCount); - expect(carouselRect.left, imageCountRect.left); + expect(imageCountRect.left, carouselRect.left + Grid.xs); + expect( + imageCountRect.left, + tester + .getRect(find.byKey(const ValueKey('message-author-gallery'))) + .left, + ); expect(carouselRect.right, tester.view.physicalSize.width); expect(carouselRect.top - imageCountRect.bottom, Grid.half + 2); + final galleryAvatar = find.descendant( + of: find.byKey(const ValueKey('message-row-gallery')), + matching: find.byType(MessageMediaAvatar), + ); + double avatarOpacity() => tester + .widget( + find + .descendant(of: galleryAvatar, matching: find.byType(Opacity)) + .first, + ) + .opacity; + expect(avatarOpacity(), 1); + final scroll = tester.widget( + find.descendant( + of: carousel, + matching: find.byType(CustomScrollView), + ), + ); + final controller = scroll.controller! as PageController; + final gesture = await tester.startGesture(tester.getCenter(carousel)); + await gesture.moveBy(const Offset(-45, 0)); + await tester.pump(); + await gesture.moveBy(const Offset(-20, 0)); + await tester.pump(); + expect(avatarOpacity(), lessThan(1)); + await gesture.moveBy(const Offset(-100, 0)); + await tester.pump(); + expect(avatarOpacity(), 0); + await gesture.up(); + await tester.pumpAndSettle(); + controller.jumpToPage(1); + await tester.pumpAndSettle(); + expect(avatarOpacity(), 0); + controller.jumpToPage(0); + await tester.pumpAndSettle(); + expect(avatarOpacity(), 1); + await tester.drag(carousel, const Offset(100, 0)); + await tester.pump(); + expect(controller.offset, greaterThanOrEqualTo(0)); + await tester.pumpAndSettle(); final messageMaterial = find .ancestor( @@ -6216,7 +6320,7 @@ void main() { expect(createdAction, findsOneWidget); expect(find.byType(CircleAvatar), findsNothing); final nameText = tester.widget(find.text('Alice')); - expect(nameText.style?.fontSize, 14); + expect(nameText.style?.fontSize, 12); expect(nameText.style?.fontWeight, FontWeight.w400); expect( find.byKey(const ValueKey('system-message-username-alice')), @@ -6229,10 +6333,49 @@ void main() { final createdText = tester.widget(createdAction); expect( effectiveFontSizeForText(createdText.text, 'created this channel'), - 14, + 12, ); }); + testWidgets( + 'huddle reactions attach to the card and open the shared tray', + (tester) async { + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg(id: 'huddle-tray', kind: EventKind.huddleStarted), + _reaction(id: 'huddle-reaction', targetId: 'huddle-tray'), + ], + ), + ); + await tester.pumpAndSettle(); + final tray = find.byKey(const ValueKey('combined-reaction-tray')); + final card = find.byKey(const ValueKey('huddle-card-huddle-tray')); + expect(tray, findsOneWidget); + final avatar = find.descendant( + of: find.byKey(const ValueKey('huddle-activity-huddle-tray')), + matching: find.byType(AvatarImage), + ); + expect(avatar, findsOneWidget); + expect( + tester.getRect(avatar).bottom, + closeTo(tester.getRect(card).bottom, 0.01), + ); + + expect(tester.getRect(tray).top, lessThan(tester.getRect(card).bottom)); + expect( + tester.getRect(tray).left, + greaterThan(tester.getRect(card).left), + ); + await tester.tap(tray); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('reaction-details-sheet')), + findsOneWidget, + ); + }, + ); + testWidgets('renders a compact huddle activity row', (tester) async { await tester.pumpWidget( _buildTestable( @@ -6244,9 +6387,20 @@ void main() { ); await tester.pumpAndSettle(); - expect(find.text('Alice'), findsOneWidget); - expect(findRichText('started a huddle'), findsOneWidget); - expect(find.byType(CircleAvatar), findsNothing); + expect(findRichText('started a huddle'), findsNothing); + expect(find.text('Huddle ended'), findsOneWidget); + final activity = find.byKey(const ValueKey('huddle-activity-huddle-1')); + final avatar = find.descendant( + of: activity, + matching: find.byType(AvatarImage), + ); + expect(avatar, findsOneWidget); + expect( + tester.getRect(avatar).right, + lessThan(tester.getRect(find.text('Huddle ended')).left), + ); + + expect(find.byType(CircleAvatar), findsOneWidget); expect( find.byKey(const ValueKey('system-message-timestamp-alice')), findsNothing, @@ -6305,7 +6459,7 @@ void main() { testWidgets('disables a different Huddle card during an active call', ( tester, ) async { - const otherHuddleChannelId = 'other-huddle-channel'; + const otherHuddleChannelId = '9d764100-fd8f-44cf-9c98-6d8fbd739b8c'; final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; await tester.pumpWidget( _buildTestable( @@ -9066,11 +9220,17 @@ void main() { (kind: EventKind.huddleEnded, action: 'ended the huddle'), ]) { testWidgets( - '${huddleEvent.action} retains its system layout beside bubble messages', + '${huddleEvent.action} is represented by its card without a notice', (tester) async { await tester.pumpWidget( _buildTestable( messages: [ + if (huddleEvent.kind == EventKind.huddleEnded) + _huddleMsg( + id: 'represented-start', + kind: EventKind.huddleStarted, + createdAt: 900, + ), _textMsg( id: 'regular-message', pubkey: 'alice', @@ -9101,22 +9261,20 @@ void main() { final huddleRow = find.byKey( const ValueKey('system-message-row-huddle-message'), ); - final regularAvatar = tester.getRect( - find - .descendant(of: regularRow, matching: find.byType(CircleAvatar)) - .first, - ); - expect( - find.descendant(of: huddleRow, matching: find.byType(CircleAvatar)), - findsNothing, - ); - final huddleAuthor = tester.getRect( - find.byKey(const ValueKey('system-message-author-bob')), - ); - final huddleBody = tester.getRect(findRichText(huddleEvent.action)); - expect(huddleAuthor.left, greaterThanOrEqualTo(huddleBody.left)); - expect(huddleBody.top, greaterThan(regularAvatar.bottom)); - expect(huddleAuthor.top, greaterThanOrEqualTo(huddleBody.top)); + expect(regularRow, findsOneWidget); + expect(findRichText(huddleEvent.action), findsNothing); + if (huddleEvent.kind == EventKind.huddleStarted) { + expect(find.text('Huddle ended'), findsOneWidget); + expect( + find.descendant( + of: huddleRow, + matching: find.byType(AvatarImage), + ), + findsOneWidget, + ); + } else { + expect(huddleRow, findsNothing); + } }, ); } @@ -9173,16 +9331,12 @@ void main() { 'message-row-message-alice', 'message-row-message-bob', 'system-message-row-membership-carol', - 'system-message-row-huddle-dave', 'message-row-message-erin', ].map((key) => tester.getRect(find.byKey(ValueKey(key)))).toList(); for (var index = 1; index < rows.length; index++) { expect(rows[index].top, greaterThanOrEqualTo(rows[index - 1].bottom)); } - for (final key in [ - 'system-message-row-membership-carol', - 'system-message-row-huddle-dave', - ]) { + for (final key in ['system-message-row-membership-carol']) { expect( find.descendant( of: find.byKey(ValueKey(key)), @@ -9449,32 +9603,78 @@ void main() { }, ); - testWidgets('opens a profile sheet from a huddle activity name', ( + testWidgets('malformed huddle start keeps its fallback activity row', ( tester, ) async { + await tester.pumpWidget( + _buildTestable( + messages: [ + const NostrEvent( + id: 'malformed-huddle', + pubkey: 'alice', + createdAt: 1000, + kind: EventKind.huddleStarted, + tags: [ + ['h', _channelId], + ], + content: '{}', + sig: '', + ), + ], + ), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('activity-card-malformed-huddle')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('huddle-activity-malformed-huddle')), + findsNothing, + ); + }); + + testWidgets('opens a profile sheet from a huddle card avatar', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + final alice = 'a' * 64; + await tester.pumpWidget( _buildTestable( messages: [ _huddleMsg( id: 'sys-huddle-avatar', kind: EventKind.huddleStarted, - pubkey: 'alice', + pubkey: alice, ), ], - users: { - 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), - }, + users: {alice: UserProfile(pubkey: alice, displayName: 'Alice')}, ), ); await tester.pumpAndSettle(); - await tester.tap( - find.byKey(const ValueKey('system-message-author-alice')), + final action = tester.getSemantics( + find.bySemanticsLabel('Open Alice profile'), ); + expect(action.getSemanticsData().flagsCollection.isButton, isTrue); + expect(action.getSemanticsData().hasAction(SemanticsAction.tap), isTrue); + tester.binding.renderViews.single.owner!.semanticsOwner!.performAction( + action.id, + SemanticsAction.tap, + ); + semantics.dispose(); await tester.pumpAndSettle(); expect(find.text('Copy public key'), findsOneWidget); expect(find.byType(UserProfileSheet), findsOneWidget); + expect( + find.descendant( + of: find.byType(UserProfileSheet), + matching: find.text('Alice'), + ), + findsWidgets, + ); }); testWidgets( @@ -9513,9 +9713,7 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap( - find.byKey(const ValueKey('system-message-author-alice')), - ); + await tester.tap(find.byType(AvatarImage).first); await tester.pumpAndSettle(); expect(find.byType(UserProfileSheet), findsOneWidget); @@ -9659,7 +9857,7 @@ void main() { Navigator.of(tester.element(find.byType(UserProfileSheet))).pop(); await tester.pumpAndSettle(); final addedText = tester.widget(addedAction); - expect(effectiveFontSizeForText(addedText.text, 'added '), 14); + expect(effectiveFontSizeForText(addedText.text, 'added '), 12); }); for (final removal in [false, true]) { diff --git a/mobile/test/features/channels/channel_detail_page_test/review_avatar_tests.dart b/mobile/test/features/channels/channel_detail_page_test/review_avatar_tests.dart new file mode 100644 index 0000000000..6dc920fe7a --- /dev/null +++ b/mobile/test/features/channels/channel_detail_page_test/review_avatar_tests.dart @@ -0,0 +1,330 @@ +part of '../channel_detail_page_test.dart'; + +void reviewAvatarTests() { + for (final payload in [ + '{', + '{}', + '{"ephemeral_channel_id":null}', + '{"ephemeral_channel_id":7}', + '{"ephemeral_channel_id":""}', + '{"ephemeral_channel_id":" "}', + '{"ephemeral_channel_id":"not-a-uuid"}', + '{"ephemeral_channel_id":"8d764100fd8f44cf9c986d8fbd739b8z"}', + ]) { + testWidgets('invalid huddle has fallback and no invitation: $payload', ( + tester, + ) async { + final event = NostrEvent( + id: 'invalid-room', + pubkey: 'alice', + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + kind: EventKind.huddleStarted, + tags: const [ + ['h', _channelId], + ], + content: payload, + sig: '', + ); + await tester.pumpWidget( + _buildTestable(messages: [event], huddleLifecycle: [event]), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('activity-card-invalid-room')), + findsOneWidget, + ); + expect(findRichText('started a huddle'), findsOneWidget); + expect( + find.byKey(const ValueKey('huddle-card-invalid-room')), + findsNothing, + ); + expect(find.widgetWithText(FilledButton, 'Join'), findsNothing); + expect(find.byTooltip('Join Huddle'), findsNothing); + expect(find.byTooltip('Open Huddle'), findsNothing); + expect(find.byTooltip('Start Huddle'), findsOneWidget); + }); + } + for (final room in [ + _huddleChannelId, + _huddleChannelId.toUpperCase(), + _huddleChannelId.replaceAll('-', ''), + 'urn:uuid:$_huddleChannelId', + '{$_huddleChannelId}', + ]) { + testWidgets( + 'relay-valid huddle UUID has row and header invitation: $room', + (tester) async { + final event = _huddleMsg( + id: 'valid-room', + kind: EventKind.huddleStarted, + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, + ephemeralChannelId: room, + ); + expect( + SystemEvent.fromHuddleEvent(event)!.ephemeralChannelId, + _huddleChannelId, + ); + await tester.pumpWidget( + _buildTestable(messages: [event], huddleLifecycle: [event]), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('huddle-card-valid-room')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('huddle-Join-$_huddleChannelId')), + findsOneWidget, + ); + expect(find.byTooltip('Open Huddle'), findsOneWidget); + }, + ); + } + testWidgets( + 'bounded window retains orphan ending and reads its visible bottom', + (tester) async { + final readState = _SynchronousReadStateNotifier( + const ReadStateState( + isReady: true, + pubkey: 'self', + contexts: {}, + version: 0, + ), + ); + final events = [ + _huddleMsg( + id: 'different-start', + kind: EventKind.huddleStarted, + createdAt: 999, + ephemeralChannelId: '9d764100-fd8f-44cf-9c98-6d8fbd739b8c', + ), + for (var i = 0; i < 198; i++) + _textMsg( + id: 'window-$i', + pubkey: 'alice', + content: 'Message $i', + createdAt: 1000 + i, + ), + _huddleMsg( + id: 'orphan-end', + kind: EventKind.huddleEnded, + createdAt: 1300, + ), + ]; + await tester.pumpWidget( + _buildTestable(messages: events, readStateNotifier: readState), + ); + await tester.pumpAndSettle(); + expect(findRichText('ended the huddle'), findsOneWidget); + expect( + find.byKey(const ValueKey('activity-card-orphan-end')), + findsOneWidget, + ); + await tester.pump(const Duration(milliseconds: 300)); + expect(readState.markedContexts['activity:$_channelId'], 1300); + }, + ); + testWidgets( + 'paired huddle ending with reactions retains its reaction surface', + (tester) async { + late _EndingReactionActions actions; + await tester.pumpWidget( + _buildTestable( + createChannelActions: (ref) => actions = _EndingReactionActions(ref), + messages: [ + _huddleMsg(id: 'paired-start', kind: EventKind.huddleStarted), + _huddleMsg( + id: 'reacted-end', + kind: EventKind.huddleEnded, + createdAt: 1100, + ), + _reaction( + id: 'end-reaction', + targetId: 'reacted-end', + pubkey: 'self', + ), + ], + ), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('activity-card-reacted-end')), + findsOneWidget, + ); + final reactions = tester.widgetList( + find.byType(ReactionRow), + ); + expect( + reactions.any( + (row) => + row.messageId == 'reacted-end' && + row.reactions.any((reaction) => reaction.emoji == '👍'), + ), + isTrue, + ); + await tester.tap(find.text('👍').first); + await tester.pump(); + expect(actions.removed, 'end-reaction'); + }, + ); + for (final thread in [false, true]) { + testWidgets( + 'production gallery profile semantics and caption edit thread=$thread', + (tester) async { + final handle = tester.ensureSemantics(); + final alice = 'a' * 64; + NostrEvent message(String caption, [String second = 'two']) => _textMsg( + id: threadIdRoot, + pubkey: alice, + content: + '$caption\n![One](https://example.com/one.png)\n![Two](https://example.com/$second.png)', + ); + final notifier = _FakeMessagesNotifier([message('Original')]); + await tester.pumpWidget( + _buildTestable( + messages: const [], + messagesNotifier: notifier, + users: {alice: UserProfile(pubkey: alice, displayName: 'Alice')}, + home: thread + ? Consumer( + builder: (context, ref, _) { + final messages = formatTimeline( + ref.watch(channelMessagesProvider(_channelId)).value ?? + [], + currentPubkey: 'self', + ); + return ThreadDetailPage( + threadHead: messages.first, + allMessages: messages, + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + ); + }, + ) + : null, + ), + ); + await tester.pumpAndSettle(); + final gate = find.byType(MessageMediaAvatar).first; + final avatar = find + .descendant(of: gate, matching: find.byType(AvatarImage)) + .first; + final profile = tester.getSemantics( + find.bySemanticsLabel('Open Alice profile'), + ); + expect(profile.getSemanticsData().flagsCollection.isButton, isTrue); + expect( + profile.getSemanticsData().hasAction(SemanticsAction.tap), + isTrue, + ); + bool profileIsAccessible() { + var found = false; + void visit(SemanticsNode node) { + if (node.id == profile.id && + node.getSemanticsData().hasAction(SemanticsAction.tap)) { + found = true; + } + node.visitChildren((child) { + visit(child); + return true; + }); + } + + visit( + tester + .binding + .renderViews + .single + .owner! + .semanticsOwner! + .rootSemanticsNode!, + ); + return found; + } + + final carousel = find.byKey(const ValueKey('message-media-carousel')); + final controller = + tester + .widget( + find.descendant( + of: carousel, + matching: find.byType(CustomScrollView), + ), + ) + .controller! + as PageController; + expect(profileIsAccessible(), isTrue); + controller.jumpTo(-10); + await tester.pump(); + expect(profileIsAccessible(), isTrue); + controller.jumpToPage(1); + await tester.pumpAndSettle(); + expect(profileIsAccessible(), isFalse); + final pageBeforeEdit = controller.page; + expect(pageBeforeEdit, greaterThan(0.5)); + notifier.setMessages([message('Edited caption')]); + await tester.pumpAndSettle(); + expect(controller.page, pageBeforeEdit); + expect(profileIsAccessible(), isFalse); + controller.jumpToPage(0); + await tester.pumpAndSettle(); + // The semantics node may be recreated after exclusion. + expect( + tester + .getSemantics(avatar) + .getSemanticsData() + .hasAction(SemanticsAction.tap), + isTrue, + ); + controller.jumpToPage(1); + await tester.pumpAndSettle(); + notifier.setMessages([message('New media', 'replacement')]); + await tester.pumpAndSettle(); + final replacementController = + tester + .widget( + find.descendant( + of: carousel, + matching: find.byType(CustomScrollView), + ), + ) + .controller! + as PageController; + expect(replacementController.page, 0); + final restored = tester.getSemantics( + find.bySemanticsLabel('Open Alice profile'), + ); + expect(restored.getSemanticsData().flagsCollection.isButton, isTrue); + expect( + restored.getSemanticsData().hasAction(SemanticsAction.tap), + isTrue, + ); + tester.binding.renderViews.single.owner!.semanticsOwner!.performAction( + restored.id, + SemanticsAction.tap, + ); + handle.dispose(); + await tester.pumpAndSettle(); + expect(find.byType(UserProfileSheet), findsOneWidget); + expect( + find.descendant( + of: find.byType(UserProfileSheet), + matching: find.text('Alice'), + ), + findsWidgets, + ); + }, + ); + } +} + +class _EndingReactionActions extends _FakeChannelActions { + _EndingReactionActions(super.ref); + String? removed; + @override + Future removeReaction(String reactionEventId, String emoji) async { + removed = reactionEventId; + } +} diff --git a/mobile/test/features/channels/message_content_bubble_cases.dart b/mobile/test/features/channels/message_content_bubble_cases.dart index 5baf1154bb..c4a91bdb75 100644 --- a/mobile/test/features/channels/message_content_bubble_cases.dart +++ b/mobile/test/features/channels/message_content_bubble_cases.dart @@ -32,6 +32,38 @@ void bubbleContentTests() { } }); + for (final brightness in Brightness.values) { + testWidgets('bubbles inherit every chat color in $brightness', ( + tester, + ) async { + for (final choice in chatColorChoices) { + final theme = mobileAppearanceTheme(brightness, choice.wireValue); + for (final own in [false, true]) { + await tester.pumpWidget( + _testable( + MessageContent( + content: 'Themed message', + bubble: true, + ownMessage: own, + ), + theme: theme, + ), + ); + await tester.pumpAndSettle(); + final surface = tester.widget( + find.byType(LocalSendBubbleSurface), + ); + expect( + surface.color, + own + ? theme.extension()!.outgoing + : theme.colorScheme.secondaryContainer, + ); + } + } + }); + } + for (final dark in [false, true]) { testWidgets('outgoing headings, mentions and links contrast in dark=$dark', ( tester, @@ -52,7 +84,9 @@ void bubbleContentTests() { ), ); await tester.pumpAndSettle(); - final foreground = dark ? Colors.black : Colors.white; + final foreground = ChatColors.of( + tester.element(find.byType(MessageContent)), + ).foreground; for (final heading in ['H1', 'H2', 'H3', 'H4', 'H5', 'H6']) { final text = tester.widget(_findRich(heading).last); final colors = []; @@ -71,16 +105,24 @@ void bubbleContentTests() { inspect(text.text, null); expect(colors, [foreground]); } + final linkColor = foreground == Colors.white + ? foreground + : dark + ? const Color(0xFF83C4FF) + : const Color(0xFF0B5FA8); for (final label in ['Alice', 'Helper']) { - expect(tester.widget(find.text(label)).style?.color, foreground); + expect(tester.widget(find.text(label)).style?.color, linkColor); } - expect(tester.widget(find.byIcon(BuzzIcons.bot)).color, foreground); + expect( + tester.widget(find.byType(BuzzAgentIcon)).color, + linkColor, + ); final link = tester.widget( find.byWidgetPredicate( (w) => w is Text && w.textSpan?.toPlainText() == 'Website', ), ); - expect(link.textSpan?.style?.color, foreground); + expect(link.textSpan?.style?.color, linkColor); await tester.tap(find.text('Alice')); expect(tapped, 'person'); await tester.tap(find.text('Helper')); @@ -114,34 +156,37 @@ void bubbleContentTests() { }); } - testWidgets('image corners follow the conversation group', (tester) async { - for (final grouped in [false, true]) { - await tester.pumpWidget( - _testable( - MessageContent( - content: '![photo](https://example.com/photo.png)', - bubble: true, - bubbleStart: !grouped, - bubbleEnd: !grouped, + testWidgets( + 'image corners remain uniform throughout the conversation group', + (tester) async { + for (final grouped in [false, true]) { + await tester.pumpWidget( + _testable( + MessageContent( + content: '![photo](https://example.com/photo.png)', + bubble: true, + bubbleStart: !grouped, + bubbleEnd: !grouped, + ), ), - ), - ); - await tester.pumpAndSettle(); - final frame = tester.widget( - find.byKey( - const ValueKey( - 'message-media-image-preview:https://example.com/photo.png', + ); + await tester.pumpAndSettle(); + final frame = tester.widget( + find.byKey( + const ValueKey( + 'message-media-image-preview:https://example.com/photo.png', + ), ), - ), - ); - final corners = - (frame.decoration! as BoxDecoration).borderRadius! as BorderRadius; - expect(corners.topLeft.x, grouped ? Radii.sm : Radii.container); - expect(corners.bottomLeft.x, grouped ? Radii.sm : Radii.container); - expect(corners.topRight.x, Radii.container); - expect(corners.bottomRight.x, Radii.container); - } - }); + ); + final corners = + (frame.decoration! as BoxDecoration).borderRadius! as BorderRadius; + expect(corners.topLeft.x, Radii.container); + expect(corners.bottomLeft.x, Radii.container); + expect(corners.topRight.x, Radii.container); + expect(corners.bottomRight.x, Radii.container); + } + }, + ); testWidgets('photo gallery stays outside its caption bubble', (tester) async { await tester.pumpWidget( diff --git a/mobile/test/features/channels/message_content_stability_test.dart b/mobile/test/features/channels/message_content_stability_test.dart index b301d90b41..08762340fd 100644 --- a/mobile/test/features/channels/message_content_stability_test.dart +++ b/mobile/test/features/channels/message_content_stability_test.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/widgets/buzz_agent_icon.dart'; import 'package:buzz/features/channels/message_content.dart'; import 'package:buzz/features/channels/media_viewer_page.dart'; import 'package:buzz/shared/custom_emoji/custom_emoji.dart'; @@ -9,7 +10,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:gpt_markdown/gpt_markdown.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:buzz/shared/theme/buzz_icons.dart'; class _Fixture { final revision = ValueNotifier(0); @@ -197,7 +197,7 @@ void main() { fixture.agents.add('alice-key'); await fixture.refresh(tester); expect(_components(tester), isNot(same(before))); - expect(find.byIcon(BuzzIcons.bot), findsOneWidget); + expect(find.byType(BuzzAgentIcon), findsOneWidget); before = _components(tester); fixture.mentionHandler = false; fixture.tappedId = null; @@ -210,7 +210,7 @@ void main() { await fixture.refresh(tester); await tester.tap(find.text('ALICE')); expect(fixture.tappedId, 'second-key'); - expect(find.byIcon(BuzzIcons.bot), findsNothing); + expect(find.byType(BuzzAgentIcon), findsNothing); }, ); diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 6cdf1e327a..6f28932c16 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/widgets/buzz_agent_icon.dart'; import 'package:buzz/features/channels/local_send_bubble_surface.dart'; import 'dart:async'; import 'dart:convert'; @@ -42,6 +43,7 @@ Widget _testable( List overrides = const [], bool disableAnimations = false, bool dark = false, + ThemeData? theme, VideoPreviewFrameLoader? videoPreviewFrameLoader, }) { return ProviderScope( @@ -52,7 +54,7 @@ Widget _testable( ...overrides, ], child: MaterialApp( - theme: dark ? AppTheme.dark() : AppTheme.light(), + theme: theme ?? (dark ? AppTheme.dark() : AppTheme.light()), // Use MaterialApp.builder so the MediaQuery override (including // disableAnimations) applies to every pushed route, not just the // home scaffold. Navigator-pushed routes (e.g. MediaVideoViewerPage) @@ -2818,7 +2820,7 @@ Photos ), ); - expect(find.byIcon(BuzzIcons.bot), findsOneWidget); + expect(find.byType(BuzzAgentIcon), findsOneWidget); expect(find.text('@'), findsNothing); expect(find.text('Helper Bot'), findsOneWidget); }); @@ -2836,7 +2838,7 @@ Photos ), ); - expect(find.byIcon(BuzzIcons.bot), findsOneWidget); + expect(find.byType(BuzzAgentIcon), findsOneWidget); expect(find.text('Helper Bot'), findsOneWidget); expect(_allRichText(tester), isNot(contains('Bot Bot'))); }); diff --git a/mobile/test/features/channels/message_media_avatar_test.dart b/mobile/test/features/channels/message_media_avatar_test.dart new file mode 100644 index 0000000000..79c923e235 --- /dev/null +++ b/mobile/test/features/channels/message_media_avatar_test.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:buzz/features/channels/message_media_avatar.dart'; + +void main() { + testWidgets('caption edits retain offset but replacing images resets it', ( + tester, + ) async { + late ValueNotifier offset; + Widget build(String caption, String second) => MaterialApp( + home: HookBuilder( + builder: (context) { + offset = useMessageMediaOffset( + '$caption\n![one](https://example.com/one.png)\n![two](https://example.com/$second.png)', + const [], + ); + return MessageMediaAvatar( + offset: offset, + child: const SizedBox(width: 42, height: 42), + ); + }, + ), + ); + await tester.pumpWidget(build('Original', 'two')); + offset.value = 200; + await tester.pump(); + await tester.pumpWidget(build('Edited caption', 'two')); + expect(offset.value, 200); + await tester.pumpWidget(build('Edited caption', 'three')); + expect(offset.value, 0); + }); + + testWidgets('gallery displacement hides the complete profile action', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + + final offset = ValueNotifier(0); + addTearDown(offset.dispose); + var taps = 0; + await tester.pumpWidget( + MaterialApp( + home: MessageMediaAvatar( + offset: offset, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => taps++, + child: Semantics( + label: 'Open Alice profile', + child: const SizedBox(width: 40, height: 40), + ), + ), + ), + ), + ); + for (final displacement in [0.0, -100.0, 100.0, 0.0]) { + offset.value = displacement; + await tester.pump(); + final hidden = displacement > 0; + var hasProfileAction = false; + void visit(SemanticsNode node) { + if (node.label == 'Open Alice profile' && + node.getSemanticsData().hasAction(SemanticsAction.tap)) { + hasProfileAction = true; + } + node.visitChildren((child) { + visit(child); + return true; + }); + } + + visit( + tester + .binding + .renderViews + .single + .owner! + .semanticsOwner! + .rootSemanticsNode!, + ); + expect(hasProfileAction, !hidden); + final opacity = tester.widget( + find.descendant( + of: find.byType(MessageMediaAvatar), + matching: find.byType(Opacity), + ), + ); + expect(opacity.opacity, hidden ? 0 : 1); + } + await tester.tap(find.byType(MessageMediaAvatar)); + expect(taps, 1); + semantics.dispose(); + }); +} diff --git a/mobile/test/features/channels/reading_marks_test.dart b/mobile/test/features/channels/reading_marks_test.dart index 4b0b510e0f..4dfcb3bd63 100644 --- a/mobile/test/features/channels/reading_marks_test.dart +++ b/mobile/test/features/channels/reading_marks_test.dart @@ -59,6 +59,56 @@ Map _marks({ ); void main() { + test('hidden endings never advance catch-up past the visible row', () { + final bottom = _msg('visible', 100); + final end = TimelineMessage( + id: 'end', + pubkey: 'alice', + createdAt: 150, + content: '', + systemEvent: const SystemEvent( + type: SystemEventType.huddleEnded, + ephemeralChannelId: 'room', + ), + ); + Map marks(List loaded, int now) => + readingMarks( + readState: _state(), + channelId: _channel, + isDm: false, + currentPubkey: _self, + loaded: [ + TimelineMessage( + id: 'start', + pubkey: 'alice', + createdAt: 50, + content: '', + systemEvent: const SystemEvent( + type: SystemEventType.huddleStarted, + ephemeralChannelId: 'room', + ), + ), + ...loaded, + ], + visible: [], + bottom: bottom, + now: now, + ); + expect(marks([bottom, end], 200)['activity:$_channel'], 100); + expect(marks([bottom, end], 125)['activity:$_channel'], 100); + expect(marks([bottom, end], 90)['activity:$_channel'], 90); + expect(marks([bottom, _msg('unseen', 120), end], 200), isEmpty); + final late = _msg('late', 120); + expect( + _marks( + readState: _state(marks([bottom, end], 200)), + loaded: [bottom, late, end], + visible: [late], + ), + {'msg:late': 120}, + ); + }); + group('channel timeline', () { test('the bottom writes one catch-up mark instead of message marks', () { final a = _msg('a', 100); diff --git a/mobile/test/features/channels/timeline_message_test.dart b/mobile/test/features/channels/timeline_message_test.dart index c2e6d3f3e5..7c0d5573a0 100644 --- a/mobile/test/features/channels/timeline_message_test.dart +++ b/mobile/test/features/channels/timeline_message_test.dart @@ -559,6 +559,23 @@ void main() { expect(result[0].systemEvent!.type, SystemEventType.channelCreated); }); + test('huddle room IDs normalize canonical uppercase UUIDs', () { + final event = NostrEvent( + id: 'upper', + pubkey: 'alice', + createdAt: 1000, + kind: EventKind.huddleStarted, + tags: const [], + content: + '{"ephemeral_channel_id":"8D764100-FD8F-44CF-9C98-6D8FBD739B8C"}', + sig: '', + ); + expect( + SystemEvent.fromHuddleEvent(event)!.ephemeralChannelId, + '8d764100-fd8f-44cf-9c98-6d8fbd739b8c', + ); + }); + test('huddle start and end events render as system rows', () { final result = formatTimeline([ _huddleEvent(id: 'h1', kind: EventKind.huddleStarted), diff --git a/mobile/test/features/search/search_page_test.dart b/mobile/test/features/search/search_page_test.dart index 525363b54a..76970915a8 100644 --- a/mobile/test/features/search/search_page_test.dart +++ b/mobile/test/features/search/search_page_test.dart @@ -1,3 +1,4 @@ +import 'package:buzz/shared/widgets/buzz_agent_icon.dart'; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/channels_provider.dart'; @@ -15,7 +16,6 @@ import 'package:buzz/shared/widgets/frosted_app_bar.dart'; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:buzz/shared/theme/buzz_icons.dart'; import '../../helpers/widget_helpers.dart'; @@ -1085,7 +1085,7 @@ void main() { ); expect(content.mentionNames, {agentPubkey: 'Helper Bot'}); expect(content.agentMentionPubkeys, contains(agentPubkey)); - expect(find.byIcon(BuzzIcons.bot), findsOneWidget); + expect(find.byType(BuzzAgentIcon), findsOneWidget); }); testWidgets('does not label an unjoined channel as having zero members', (