Polish mobile bubbles, mentions, galleries, and huddle cards (#8359)

Builds on the merged themes work in #8358 and inherits its appearance
and chat-color settings.

Polishes mobile conversations with uniform rounded bubbles,
desktop-style text-only mentions and agent icon, aligned gallery counts,
fading gallery avatars, and standard iOS scrolling. Huddles use rounded
cards with attached reaction trays, without duplicate start/end notices;
other activity rows stay centered with smaller text.

Validation: mobile checks, the full mobile test suite, and the gallery
regression test passed. Release build installed on iPhone. Full `just
ci` could not finish: desktop compilation exhausted local disk space.

### Snapshots
Flutter-rendered light and dark previews using seeded sample messages.

| Light | Dark |
| --- | --- |
|
![light](https://raw.githubusercontent.com/block/buzz/62346699199dd707579f46d21608c2a49a8095ef/pr-8359--light.png)
|
![dark](https://raw.githubusercontent.com/block/buzz/62346699199dd707579f46d21608c2a49a8095ef/pr-8359--dark.png)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
klopez4212
2026-10-15 12:39:21 -04:00
committed by GitHub
parent 9092950620
commit 42b395cdef
26 changed files with 1764 additions and 755 deletions

View File

@@ -1,3 +1,4 @@
import 'huddle_timeline.dart';
import '../../shared/widgets/modal_presentation.dart'; import '../../shared/widgets/modal_presentation.dart';
import '../../shared/widgets/ios_scroll_to_top.dart'; import '../../shared/widgets/ios_scroll_to_top.dart';
import 'message_footer_layout.dart'; import 'message_footer_layout.dart';
@@ -67,6 +68,8 @@ import 'message_actions.dart';
import 'message_action_backdrop_state.dart'; import 'message_action_backdrop_state.dart';
import 'message_long_press_region.dart'; import 'message_long_press_region.dart';
import 'message_content.dart'; import 'message_content.dart';
import '../../shared/widgets/message_attachment_corners.dart';
import 'message_media_avatar.dart';
import 'message_skeleton_body.dart'; import 'message_skeleton_body.dart';
import '../../shared/read_state/deferred_read_state_update.dart'; import '../../shared/read_state/deferred_read_state_update.dart';
import '../../shared/read_state/read_state_provider.dart'; import '../../shared/read_state/read_state_provider.dart';
@@ -611,7 +614,12 @@ class ChannelDetailPage extends HookConsumerWidget {
currentPubkey: currentPubkey, currentPubkey: currentPubkey,
); );
final timelineEntries = buildMainTimelineEntries( final timelineEntries = buildMainTimelineEntries(
timelineMessages, // End events still update the original card through allMessages.
timelineMessages
.where(
(message) => !isCollapsedHuddleEnding(message, timelineMessages),
)
.toList(),
relaySummaries: ref relaySummaries: ref
.read(channelMessagesProvider(channel.id).notifier) .read(channelMessagesProvider(channel.id).notifier)
.threadSummaries, .threadSummaries,

View File

@@ -330,15 +330,13 @@ class _HuddleJoinSurface extends ConsumerWidget {
}; };
return Padding( return Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(top: Grid.xxs),
left: messageAvatarSize + messageAvatarContentGap,
top: Grid.xxs,
),
child: Container( child: Container(
key: ValueKey('huddle-card-${message.id}'),
padding: const EdgeInsets.all(Grid.twelve), padding: const EdgeInsets.all(Grid.twelve),
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.colors.primary.withValues(alpha: 0.08), color: context.colors.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(Radii.md), borderRadius: conversationAttachmentCorners,
border: Border.all( border: Border.all(
color: context.colors.primary.withValues(alpha: 0.18), color: context.colors.primary.withValues(alpha: 0.18),
), ),

View File

@@ -34,6 +34,7 @@ class _MessageBubble extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final messageSnapshotKey = useMemoized(GlobalKey.new, const []); final messageSnapshotKey = useMemoized(GlobalKey.new, const []);
final mediaOffset = useMessageMediaOffset(message.content, message.tags);
final hasLocalReplies = ref.watch( final hasLocalReplies = ref.watch(
threadLocalRepliesProvider( threadLocalRepliesProvider(
ThreadRepliesArgs( ThreadRepliesArgs(
@@ -124,271 +125,303 @@ class _MessageBubble extends HookConsumerWidget {
); );
} }
return Padding( return NotificationListener<MessageGalleryScrollNotification>(
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0), onNotification: (notification) {
child: Material( mediaOffset.value = notification.offset;
color: Colors.transparent, return true;
borderRadius: BorderRadius.circular(Radii.md), },
// The media carousel intentionally continues through the list's trailing child: Padding(
// gutter. InkWell still clips its ink to [borderRadius], while leaving padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0),
// overflowing message content visible. child: Material(
clipBehavior: Clip.none, color: Colors.transparent,
child: MessageLongPressInkWell(
key: ValueKey('message-row-${message.id}'),
onLongPressDetails: openMessageActions,
borderRadius: BorderRadius.circular(Radii.md), borderRadius: BorderRadius.circular(Radii.md),
highlightColor: context.colors.primary.withValues(alpha: 0.1), // The media carousel intentionally continues through the list's trailing
snapshotKey: messageSnapshotKey, // gutter. InkWell still clips its ink to [borderRadius], while leaving
// Tap opens existing threads; long-press can start a new one. // overflowing message content visible.
// MessageContent handles mention, channel-link, and media taps. clipBehavior: Clip.none,
onTap: (!hasReplies && !hasLocalReplies) || allMessages == null child: MessageLongPressInkWell(
? null key: ValueKey('message-row-${message.id}'),
: () => Navigator.of(context).push( onLongPressDetails: openMessageActions,
MaterialPageRoute<void>( borderRadius: BorderRadius.circular(Radii.md),
builder: (_) => ThreadDetailPage( highlightColor: context.colors.primary.withValues(alpha: 0.1),
threadHead: message, snapshotKey: messageSnapshotKey,
allMessages: allMessages!, // Tap opens existing threads; long-press can start a new one.
channelId: currentChannelId, // MessageContent handles mention, channel-link, and media taps.
currentPubkey: currentPubkey, onTap: (!hasReplies && !hasLocalReplies) || allMessages == null
isMember: isMember, ? null
isArchived: isArchived, : () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: allMessages!,
channelId: currentChannelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
),
), ),
), ),
), child: Padding(
child: Padding( padding: EdgeInsets.only(
padding: EdgeInsets.only( bottom: showAvatar ? Grid.quarter : Grid.quarter / 2,
bottom: showAvatar ? Grid.quarter : Grid.quarter / 2, ),
), child: MessageFooterLayout(
child: MessageFooterLayout( animateSize: true,
animateSize: true, overlap: message.reactions.isNotEmpty || replyFooter != null
overlap: message.reactions.isNotEmpty || replyFooter != null ? Grid.xs
? Grid.xs : 0,
: 0, footer: message.reactions.isNotEmpty || replyFooter != null
footer: message.reactions.isNotEmpty || replyFooter != null ? Padding(
? Padding( padding: const EdgeInsets.only(
padding: const EdgeInsets.only( left:
left: messageAvatarSize +
messageAvatarSize + messageAvatarContentGap +
messageAvatarContentGap + Grid.xxs,
Grid.xxs, ),
), child: Wrap(
child: Wrap( spacing: Grid.xxs,
spacing: Grid.xxs, runSpacing: Grid.half,
runSpacing: Grid.half, crossAxisAlignment: WrapCrossAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center, children: [
children: [ if (message.reactions.isNotEmpty)
if (message.reactions.isNotEmpty) ReactionRow(
ReactionRow( combined: true,
combined: true, messageId: message.id,
messageId: message.id, channelId: currentChannelId,
channelId: currentChannelId, reactions: message.reactions,
reactions: message.reactions, onToggle: (emoji) =>
onToggle: (emoji) => toggleReaction(ref, message, emoji),
toggleReaction(ref, message, emoji), showAddButton: isMember && !isArchived,
showAddButton: isMember && !isArchived, onAddReaction: () => showAddReactionPicker(
onAddReaction: () => showAddReactionPicker( context: context,
context: context, ref: ref,
ref: ref, message: message,
message: message, ),
), ),
), if (replyFooter != null)
if (replyFooter != null) Padding(
Padding( padding: const EdgeInsets.only(
padding: const EdgeInsets.only(bottom: Grid.xxs), bottom: Grid.xxs,
child: replyFooter, ),
), child: replyFooter,
], ),
), ],
) ),
: const SizedBox.shrink(), )
child: RepaintBoundary( : const SizedBox.shrink(),
key: messageSnapshotKey, child: RepaintBoundary(
child: Stack( key: messageSnapshotKey,
clipBehavior: Clip.none, child: Stack(
children: [ clipBehavior: Clip.none,
Row( children: [
crossAxisAlignment: CrossAxisAlignment.end, Row(
children: [ crossAxisAlignment: CrossAxisAlignment.end,
const SizedBox(width: messageAvatarSize), children: [
const SizedBox(width: messageAvatarContentGap), const SizedBox(width: messageAvatarSize),
Expanded( const SizedBox(width: messageAvatarContentGap),
child: Padding( Expanded(
padding: EdgeInsets.only( child: Padding(
top: showAuthor ? Grid.half : 0, padding: EdgeInsets.only(
), top: showAuthor ? Grid.half : 0,
child: Column( ),
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
if (showAuthor) children: [
Padding( if (showAuthor)
padding: const EdgeInsets.only( Padding(
bottom: Grid.quarter, padding: const EdgeInsets.only(
left: Grid.xs, bottom: Grid.quarter,
), left: Grid.xs,
child: Row( ),
children: [ child: Row(
Expanded( children: [
child: MessageAuthorMeta( Expanded(
showTimestamp: true, child: MessageAuthorMeta(
nameStyle: messageUsernameTextStyle showTimestamp: true,
.copyWith( nameStyle:
fontSize: 13.1, messageUsernameTextStyle
height: 17 / 13.1, .copyWith(
), fontSize: 13.1,
metadataStyle: height: 17 / 13.1,
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,
), ),
), metadataStyle:
displayNameKey: ValueKey( messageMetadataTextStyle
'message-author-${message.id}', .copyWith(
), fontSize: 13.1,
usernameKey: ValueKey( height: 17 / 13.1,
'message-username-${message.id}', ),
), timestampStyle:
timestampKey: ValueKey( messageTimestampTextStyle
'message-timestamp-${message.id}', .copyWith(
), fontSize: 11,
), height: 17 / 11,
), ),
if (message.edited) ...[ displayName: displayName,
const SizedBox(width: Grid.half), username: messageUsernameLabel(
Text( profile,
'(edited)', ),
style: context.textTheme.labelSmall timestamp: formatMessageTime(
?.copyWith( message.createdAt,
color: context ),
.colors nameColor:
.onSurfaceVariant, context.colors.onSurface,
fontStyle: FontStyle.italic, metadataColor: context
), .colors
), .onSurfaceVariant,
], onAuthorTap: () =>
], showUserProfileSheet(
), context,
), message.pubkey,
MessageContent( names:
content: message.content, channelIdentityNamesProvider(
bubble: true, currentChannelId,
hasReactions: ),
message.reactions.isNotEmpty || ),
replyFooter != null, displayNameKey: ValueKey(
bubbleEnd: showAvatar, 'message-author-${message.id}',
bubbleStart: showAuthor, ),
ownMessage: usernameKey: ValueKey(
currentPubkey?.toLowerCase() == pk, 'message-username-${message.id}',
mentionNames: resolvedMentionNames, ),
mentionLabels: mentionLabels, timestampKey: ValueKey(
agentMentionPubkeys: agentMentionPubkeys, 'message-timestamp-${message.id}',
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<void>(
builder: (_) => ThreadDetailPage(
threadHead: message,
allMessages: allMessages!,
channelId: currentChannelId,
currentPubkey: currentPubkey,
isMember: isMember,
isArchived: isArchived,
), ),
), ),
); ),
}, if (message.edited) ...[
onMediaMore: (viewerContext, imageUrl) => const SizedBox(width: Grid.half),
showImageActions( Text(
context: viewerContext, '(edited)',
ref: ref, style: context
message: message, .textTheme
channelId: currentChannelId, .labelSmall
imageUrl: imageUrl, ?.copyWith(
canManageMessage: canManageMessage, color: context
onDeleted: () { .colors
if (viewerContext.mounted) { .onSurfaceVariant,
Navigator.of( fontStyle: FontStyle.italic,
viewerContext, ),
).maybePop(); ),
} ],
}, ],
), ),
onChannelTap: (channelId) { ),
openChannelLink( MessageContent(
context: context, content: message.content,
ref: ref, bubble: true,
channelId: channelId, hasReactions:
currentChannelId: currentChannelId, message.reactions.isNotEmpty ||
); replyFooter != null,
}, bubbleEnd: showAvatar,
onMentionTap: (pubkey) => bubbleStart: showAuthor,
showUserProfileSheet( ownMessage:
context, currentPubkey?.toLowerCase() == pk,
pubkey, mentionNames: resolvedMentionNames,
names: channelIdentityNamesProvider( mentionLabels: mentionLabels,
currentChannelId, 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<void>(
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,
),
),
),
),
),
],
),
), ),
), ),
), ),

View File

@@ -23,7 +23,9 @@ class _SystemMessageRow extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final spotlightKey = useMemoized(() => GlobalKey()); final spotlightKey = useMemoized(() => GlobalKey());
final systemEvent = message.systemEvent; final systemEvent = message.systemEvent;
if (systemEvent == null) return const SizedBox.shrink(); if (systemEvent == null) {
return const SizedBox.shrink();
}
final userCache = ref.watch(userCacheProvider); final userCache = ref.watch(userCacheProvider);
final sourceMessages = groupedMessages ?? [message]; 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( return Material(
color: Colors.transparent, color: Colors.transparent,
borderRadius: BorderRadius.circular(Radii.md), borderRadius: BorderRadius.circular(Radii.md),
@@ -143,67 +185,105 @@ class _SystemMessageRow extends HookConsumerWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs), padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: isHuddle
? CrossAxisAlignment.start
: CrossAxisAlignment.center,
children: [ children: [
RepaintBoundary( if (!isHuddle)
key: spotlightKey, RepaintBoundary(
child: Align( key: spotlightKey,
alignment: Alignment.center, child: Align(
child: Container( alignment: isHuddle
key: ValueKey('activity-card-${message.id}'), ? Alignment.centerLeft
padding: const EdgeInsets.symmetric( : Alignment.center,
horizontal: Grid.twelve, child: Container(
vertical: Grid.xxs, key: ValueKey('activity-card-${message.id}'),
), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration( horizontal: Grid.twelve,
color: context.colors.onSurface.withValues(alpha: 0.06), vertical: Grid.xxs,
borderRadius: BorderRadius.circular(Radii.container), ),
), decoration: BoxDecoration(
child: IntrinsicWidth( color: context.colors.onSurface.withValues(alpha: 0.06),
child: groupedMembership != null borderRadius: BorderRadius.circular(Radii.container),
? _MembershipSystemMessageContent( ),
event: groupedMembership, child: IntrinsicWidth(
channelId: channelId, child: groupedMembership != null
createdAt: message.createdAt, ? _MembershipSystemMessageContent(
resolveLabel: resolveLabel, event: groupedMembership,
userCache: userCache, channelId: channelId,
) createdAt: message.createdAt,
: messageStyleActor != null && resolveLabel: resolveLabel,
messageStyleActor.isNotEmpty && userCache: userCache,
messageStyleAction != null )
? _MessageStyleSystemMessageContent( : messageStyleActor != null &&
displayPubkey: messageStyleActor, messageStyleActor.isNotEmpty &&
channelId: channelId, messageStyleAction != null
createdAt: message.createdAt, ? _MessageStyleSystemMessageContent(
resolveLabel: resolveLabel, displayPubkey: messageStyleActor,
userCache: userCache, channelId: channelId,
actionSpans: messageStyleAction, createdAt: message.createdAt,
) resolveLabel: resolveLabel,
: Text.rich( userCache: userCache,
TextSpan( actionSpans: messageStyleAction,
children: [ )
TextSpan( : Text.rich(
text: systemEvent.describe(resolveLabel), TextSpan(
), children: [
], TextSpan(
text: systemEvent.describe(resolveLabel),
),
],
),
textAlign: TextAlign.center,
style: _systemActionTextStyle(context),
), ),
textAlign: TextAlign.center, ),
style: _systemActionTextStyle(context),
),
), ),
), ),
), ),
),
if (systemEvent.type == SystemEventType.huddleStarted && if (systemEvent.type == SystemEventType.huddleStarted &&
systemEvent.ephemeralChannelId != null) systemEvent.ephemeralChannelId != null)
_HuddleJoinSurface( MessageFooterLayout(
message: message, animateSize: true,
allMessages: allMessages ?? const [], overlap: reactions.isNotEmpty ? Grid.xs : 0,
parentChannelId: channelId, footer: reactions.isEmpty
isMember: isMember, ? const SizedBox.shrink()
isArchived: isArchived, : 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( IntrinsicWidth(
child: ReactionRow( child: ReactionRow(
messageId: message.id, messageId: message.id,
@@ -415,7 +495,7 @@ class _MembershipSystemMessageContent extends StatelessWidget {
} }
TextStyle? _systemActionTextStyle(BuildContext context) { TextStyle? _systemActionTextStyle(BuildContext context) {
return context.textTheme.bodySmall?.copyWith( return bodyExtraSmallTextStyle.copyWith(
color: context.colors.onSurfaceVariant, color: context.colors.onSurfaceVariant,
); );
} }

View File

@@ -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<TimelineMessage> 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,
);
}

View File

@@ -38,6 +38,7 @@ import 'message_content/bubble_segments.dart';
import 'message_mention_pill.dart'; import 'message_mention_pill.dart';
import 'message_gallery.dart'; import 'message_gallery.dart';
import 'message_gallery_frame.dart'; import 'message_gallery_frame.dart';
import 'message_media_avatar.dart';
import 'message_media_geometry.dart'; import 'message_media_geometry.dart';
import 'voice_note_attachment.dart'; import 'voice_note_attachment.dart';
@@ -194,6 +195,12 @@ class MessageContent extends HookConsumerWidget {
final markdownTheme = GptMarkdownTheme.of(context); final markdownTheme = GptMarkdownTheme.of(context);
final chatColors = ChatColors.of(context); final chatColors = ChatColors.of(context);
final ownTextColor = chatColors.foreground; 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 = final baseTextStyle =
(baseStyle ?? (baseStyle ??
context.textTheme.bodyMedium?.copyWith( context.textTheme.bodyMedium?.copyWith(
@@ -410,13 +417,7 @@ class MessageContent extends HookConsumerWidget {
color: ownMessage color: ownMessage
? chatColors.outgoing ? chatColors.outgoing
: context.colors.secondaryContainer, : context.colors.secondaryContainer,
shape: _MessageBubbleShape( shape: const _MessageBubbleShape(),
start: bubbleStart && index == 0,
end:
bubbleEnd &&
trailingGallery == null &&
index == segments.length - 1,
),
child: Padding( child: Padding(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
Grid.xs, Grid.xs,
@@ -426,38 +427,48 @@ class MessageContent extends HookConsumerWidget {
? Grid.xs ? Grid.xs
: Grid.xxs, : Grid.xxs,
), ),
child: ownMessage child: Theme(
? Theme( data: Theme.of(context).copyWith(
data: Theme.of(context).copyWith( colorScheme: context.colors.copyWith(
colorScheme: context.colors.copyWith( primary: linkColor,
primary: ownTextColor, ),
), ),
), child: GptMarkdownTheme(
child: GptMarkdownTheme( gptThemeData: markdownTheme.copyWith(
gptThemeData: markdownTheme.copyWith( h1: markdownTheme.h1?.copyWith(
h1: markdownTheme.h1?.copyWith( color: ownMessage
color: ownTextColor, ? ownTextColor
), : context.colors.onSurface,
h2: markdownTheme.h2?.copyWith( ),
color: ownTextColor, h2: markdownTheme.h2?.copyWith(
), color: ownMessage
h3: markdownTheme.h3?.copyWith( ? ownTextColor
color: ownTextColor, : context.colors.onSurface,
), ),
h4: markdownTheme.h4?.copyWith( h3: markdownTheme.h3?.copyWith(
color: ownTextColor, color: ownMessage
), ? ownTextColor
h5: markdownTheme.h5?.copyWith( : context.colors.onSurface,
color: ownTextColor, ),
), h4: markdownTheme.h4?.copyWith(
h6: markdownTheme.h6?.copyWith( color: ownMessage
color: ownTextColor, ? ownTextColor
), : context.colors.onSurface,
), ),
child: renderMarkdown(segment.text), h5: markdownTheme.h5?.copyWith(
), color: ownMessage
) ? ownTextColor
: renderMarkdown(segment.text), : context.colors.onSurface,
),
h6: markdownTheme.h6?.copyWith(
color: ownMessage
? ownTextColor
: context.colors.onSurface,
),
),
child: renderMarkdown(segment.text),
),
),
), ),
), ),
), ),

View File

@@ -1,49 +1,12 @@
part of '../message_content.dart'; part of '../message_content.dart';
/// Grouped message corners without a tail. /// Uniform, tail-free corners for every message in a sender run.
class _MessageBubbleShape extends ShapeBorder { class _MessageBubbleShape extends RoundedRectangleBorder {
const _MessageBubbleShape({required this.start, required this.end}); const _MessageBubbleShape()
: super(
final bool start; borderRadius: const BorderRadius.all(Radius.circular(Radii.container)),
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,
),
); );
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 _attachmentCorners({required bool start, required bool end}) =>
BorderRadius.only( conversationAttachmentCorners;
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),
);

View File

@@ -260,6 +260,10 @@ class _MessageCarouselViewport extends HookWidget {
child: NotificationListener<ScrollUpdateNotification>( child: NotificationListener<ScrollUpdateNotification>(
onNotification: (notification) { onNotification: (notification) {
if (notification.depth == 0 && notification.metrics is PageMetrics) { 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(); final index = (notification.metrics as PageMetrics).page!.round();
if (index != currentIndex) onPageChanged(index); if (index != currentIndex) onPageChanged(index);
} }
@@ -268,7 +272,11 @@ class _MessageCarouselViewport extends HookWidget {
child: CustomScrollView( child: CustomScrollView(
controller: controller, controller: controller,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
physics: const _CarouselPagePhysics(), physics: _CarouselPagePhysics(
parent: Theme.of(context).platform == TargetPlatform.iOS
? const BouncingScrollPhysics()
: const ClampingScrollPhysics(),
),
clipBehavior: Clip.none, clipBehavior: Clip.none,
slivers: [ slivers: [
SliverPadding( SliverPadding(

View File

@@ -39,18 +39,20 @@ class MessageGalleryFrame extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (loading) Padding(
ExcludeSemantics( padding: const EdgeInsetsDirectional.only(start: Grid.xs),
child: Stack( child: loading
alignment: Alignment.centerLeft, ? ExcludeSemantics(
children: [ child: Stack(
Opacity(opacity: 0, child: label), alignment: Alignment.centerLeft,
const SkeletonBar(width: 48, height: 10), children: [
], Opacity(opacity: 0, child: label),
), const SkeletonBar(width: 48, height: 10),
) ],
else ),
label, )
: label,
),
const SizedBox(height: Grid.half + Grid.quarter), const SizedBox(height: Grid.half + Grid.quarter),
child, child,
], ],

View File

@@ -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<double> useMessageMediaOffset(
String content,
List<List<String>> 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<double> offset;
/// Author avatar and its profile action.
final Widget child;
@override
Widget build(BuildContext context) => ValueListenableBuilder<double>(
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,
),
),
),
);
},
);
}

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../shared/theme/buzz_icons.dart'; import '../../shared/widgets/buzz_agent_icon.dart';
import '../../shared/theme/theme.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 { class MessageMentionPill extends StatelessWidget {
/// Visible contextual identity name. /// Visible contextual identity name.
final String label; final String label;
@@ -11,7 +11,7 @@ class MessageMentionPill extends StatelessWidget {
/// Full identity label for assistive technologies. /// Full identity label for assistive technologies.
final String? semanticsLabel; 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; final bool isAgent;
/// Typography inherited from the surrounding message. /// Typography inherited from the surrounding message.
@@ -41,38 +41,21 @@ class MessageMentionPill extends StatelessWidget {
); );
final fontSize = style.fontSize ?? 16; final fontSize = style.fontSize ?? 16;
return Container( return Row(
padding: const EdgeInsets.fromLTRB( mainAxisSize: MainAxisSize.min,
Grid.half, crossAxisAlignment: CrossAxisAlignment.center,
Grid.quarter + 1, children: [
Grid.half, if (isAgent)
Grid.quarter, BuzzAgentIcon(size: fontSize, color: context.colors.primary)
), else
decoration: BoxDecoration( Transform.translate(
color: context.colors.primary.withValues(alpha: 0.1), offset: const Offset(0, -Grid.quarter),
borderRadius: BorderRadius.circular(Radii.sm), child: Text('@', style: style),
),
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),
), ),
], Flexible(
), child: Text(label, style: style, semanticsLabel: semanticsLabel),
),
],
); );
} }
} }

View File

@@ -1,3 +1,4 @@
import 'huddle_timeline.dart';
import 'dart:async'; import 'dart:async';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
@@ -99,6 +100,7 @@ MapEntry<String, int>? _catchUpMark({
// the live bottom, for example after a jump into history. // the live bottom, for example after a jump into history.
final showsNewest = !loaded.any( final showsNewest = !loaded.any(
(message) => (message) =>
(threadRootId != null || !isCollapsedHuddleEnding(message, loaded)) &&
message.createdAt > bottom.createdAt && message.createdAt > bottom.createdAt &&
(threadRootId != null (threadRootId != null
? _threadRootOf(message) == threadRootId ? _threadRootOf(message) == threadRootId
@@ -106,9 +108,8 @@ MapEntry<String, int>? _catchUpMark({
); );
if (!showsNewest) return null; if (!showsNewest) return null;
// Cut at the bottom row. A newer reply's time would also read a top-level // Never read beyond the last rendered message: a message between that
// message that arrives late with an earlier time. A clock ahead of now, on // row and a hidden lifecycle event may still arrive out of order.
// this device or the sender's, must not read messages before they arrive.
final cut = bottom.createdAt < now ? bottom.createdAt : now; final cut = bottom.createdAt < now ? bottom.createdAt : now;
final key = threadRootId != null final key = threadRootId != null
? threadActivityContextKey(threadRootId) ? threadActivityContextKey(threadRootId)

View File

@@ -52,6 +52,7 @@ import 'message_actions.dart';
import 'message_action_backdrop_state.dart'; import 'message_action_backdrop_state.dart';
import 'message_long_press_region.dart'; import 'message_long_press_region.dart';
import 'message_content.dart'; import 'message_content.dart';
import 'message_media_avatar.dart';
import 'reaction_row.dart'; import 'reaction_row.dart';
import '../../shared/read_state/read_state_provider.dart'; import '../../shared/read_state/read_state_provider.dart';
import 'send_message_provider.dart'; import 'send_message_provider.dart';

View File

@@ -37,6 +37,7 @@ class _ThreadMessage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final messageSnapshotKey = useMemoized(GlobalKey.new, const []); final messageSnapshotKey = useMemoized(GlobalKey.new, const []);
final mediaOffset = useMessageMediaOffset(message.content, message.tags);
final pk = message.pubkey.toLowerCase(); final pk = message.pubkey.toLowerCase();
final profile = final profile =
ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
@@ -127,249 +128,277 @@ class _ThreadMessage extends HookConsumerWidget {
alpha: _landingHighlightOpacity * highlightProgress, alpha: _landingHighlightOpacity * highlightProgress,
); );
return Padding( return NotificationListener<MessageGalleryScrollNotification>(
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0), onNotification: (notification) {
child: DecoratedBox( mediaOffset.value = notification.offset;
key: ValueKey('thread-message-${message.id}'), return true;
decoration: BoxDecoration( },
color: highlightColor, child: Padding(
borderRadius: BorderRadius.circular(Radii.md), padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0),
), child: DecoratedBox(
child: Material( key: ValueKey('thread-message-${message.id}'),
color: Colors.transparent, decoration: BoxDecoration(
borderRadius: BorderRadius.circular(Radii.md), color: highlightColor,
// 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), borderRadius: BorderRadius.circular(Radii.md),
highlightColor: context.colors.primary.withValues(alpha: 0.1), ),
snapshotKey: messageSnapshotKey, child: Material(
child: Padding( color: Colors.transparent,
padding: EdgeInsets.only( borderRadius: BorderRadius.circular(Radii.md),
bottom: showAvatar ? Grid.quarter : Grid.quarter / 2, // The media carousel intentionally continues through the list's
), // trailing gutter. InkWell still clips its ink to [borderRadius],
child: MessageFooterLayout( // while leaving overflowing message content visible.
animateSize: true, clipBehavior: Clip.none,
overlap: message.reactions.isNotEmpty ? Grid.xs : 0, child: MessageLongPressInkWell(
footer: (isThreadHead || message.reactions.isNotEmpty) key: ValueKey('thread-message-row-${message.id}'),
? Padding( onLongPressDetails: openMessageActions,
padding: const EdgeInsets.only( borderRadius: BorderRadius.circular(Radii.md),
left: highlightColor: context.colors.primary.withValues(alpha: 0.1),
messageAvatarSize + snapshotKey: messageSnapshotKey,
messageAvatarContentGap + child: Padding(
Grid.xxs, padding: EdgeInsets.only(
), bottom: showAvatar ? Grid.quarter : Grid.quarter / 2,
child: ReactionRow( ),
combined: true, child: MessageFooterLayout(
messageId: message.id, animateSize: true,
channelId: channelId, overlap: message.reactions.isNotEmpty ? Grid.xs : 0,
reactions: message.reactions, footer: (isThreadHead || message.reactions.isNotEmpty)
onToggle: (emoji) => ? Padding(
toggleReaction(ref, message, emoji), padding: const EdgeInsets.only(
showAddButton: isMember && !isArchived, left:
onAddReaction: () => showAddReactionPicker( messageAvatarSize +
context: context, messageAvatarContentGap +
ref: ref, Grid.xxs,
message: message,
), ),
), child: ReactionRow(
) combined: true,
: const SizedBox.shrink(), messageId: message.id,
child: RepaintBoundary( channelId: channelId,
key: messageSnapshotKey, reactions: message.reactions,
child: Stack( onToggle: (emoji) =>
clipBehavior: Clip.none, toggleReaction(ref, message, emoji),
children: [ showAddButton: isMember && !isArchived,
Row( onAddReaction: () => showAddReactionPicker(
crossAxisAlignment: CrossAxisAlignment.end, context: context,
children: [ ref: ref,
const SizedBox(width: messageAvatarSize), message: message,
const SizedBox(width: messageAvatarContentGap), ),
Expanded( ),
child: Padding( )
padding: EdgeInsets.only( : const SizedBox.shrink(),
top: showAuthor ? Grid.half : 0, child: RepaintBoundary(
), key: messageSnapshotKey,
child: Column( child: Stack(
crossAxisAlignment: CrossAxisAlignment.start, clipBehavior: Clip.none,
children: [ children: [
if (showAuthor) Row(
Padding( crossAxisAlignment: CrossAxisAlignment.end,
padding: const EdgeInsets.only( children: [
bottom: Grid.quarter, const SizedBox(width: messageAvatarSize),
left: Grid.xs, const SizedBox(width: messageAvatarContentGap),
), Expanded(
child: Row( child: Padding(
children: [ padding: EdgeInsets.only(
Expanded( top: showAuthor ? Grid.half : 0,
child: MessageAuthorMeta( ),
showTimestamp: true, child: Column(
nameStyle: crossAxisAlignment: CrossAxisAlignment.start,
messageUsernameTextStyle children: [
.copyWith( if (showAuthor)
fontSize: 13.1, Padding(
height: 17 / 13.1, padding: const EdgeInsets.only(
), bottom: Grid.quarter,
metadataStyle: left: Grid.xs,
messageMetadataTextStyle ),
.copyWith( child: Row(
fontSize: 13.1, children: [
height: 17 / 13.1, Expanded(
), child: MessageAuthorMeta(
timestampStyle: showTimestamp: true,
messageTimestampTextStyle nameStyle:
.copyWith( messageUsernameTextStyle
fontSize: 11, .copyWith(
height: 17 / 11, fontSize: 13.1,
), height: 17 / 13.1,
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,
), ),
), metadataStyle:
displayNameKey: ValueKey( messageMetadataTextStyle
'thread-message-author-${message.id}', .copyWith(
), fontSize: 13.1,
usernameKey: ValueKey( height: 17 / 13.1,
'thread-message-username-${message.id}', ),
), timestampStyle:
timestampKey: ValueKey( messageTimestampTextStyle
'thread-message-timestamp-${message.id}', .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) ...[
if (message.edited) ...[ const SizedBox(width: Grid.half),
const SizedBox(width: Grid.half), Text(
Text( '(edited)',
'(edited)', style: context
style: context .textTheme
.textTheme .labelSmall
.labelSmall ?.copyWith(
?.copyWith( color: context
color: context .colors
.colors .onSurfaceVariant,
.onSurfaceVariant, fontStyle:
fontStyle: FontStyle.italic, 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<void>(
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( MessageContent(
context: context, content: message.content,
ref: ref, bubble: true,
channelId: targetChannelId, hasReactions:
currentChannelId: channelId, message.reactions.isNotEmpty,
); bubbleEnd: showAvatar,
}, bubbleStart: showAuthor,
onMentionTap: (pubkey) => ownMessage:
showUserProfileSheet( currentPubkey?.toLowerCase() == pk,
context, mentionNames: resolvedMentionNames,
pubkey, mentionLabels: mentionLabels,
names: channelIdentityNamesProvider( agentMentionPubkeys: agentMentionPubkeys,
channelId, 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<void>(
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,
),
),
),
],
), ),
), ),
), ),

View File

@@ -89,7 +89,9 @@ class SystemEvent {
try { try {
final content = jsonDecode(event.content); final content = jsonDecode(event.content);
if (content is Map) { if (content is Map) {
ephemeralChannelId = _readString(content, 'ephemeral_channel_id'); ephemeralChannelId = _canonicalHuddleRoomId(
content['ephemeral_channel_id'],
);
} }
} catch (_) { } catch (_) {
// The row still renders, but joining requires the validated room ID. // The row still renders, but joining requires the validated room ID.
@@ -787,3 +789,24 @@ String? _readString(Map<dynamic, dynamic> json, String key) {
final value = json[key]; final value = json[key];
return value is String ? value : null; 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();
}

View File

@@ -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(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" '
'fill="none" stroke="currentColor" stroke-width="2" '
'stroke-linecap="round" stroke-linejoin="round">'
'<rect x="4.75" y="7" width="14.5" height="13.25" rx="4.5"/>'
'<path d="M12 7V2.5M3.25 11.5v4.25M20.75 11.5v4.25"/>'
'<g fill="currentColor" stroke="none">'
'<circle cx="9" cy="13.25" r="1.4"/>'
'<circle cx="15" cy="13.25" r="1.4"/>'
'</g></svg>',
width: size,
height: size,
colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
excludeFromSemantics: true,
);
}

View File

@@ -1,5 +1,12 @@
import 'package:flutter/widgets.dart'; 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. /// Shares conversation corner geometry with attachment surfaces.
class MessageAttachmentCorners extends InheritedWidget { class MessageAttachmentCorners extends InheritedWidget {
final BorderRadius borderRadius; final BorderRadius borderRadius;

View File

@@ -1,3 +1,5 @@
import 'package:flutter/semantics.dart';
import 'package:buzz/features/channels/message_media_avatar.dart';
import 'dart:async'; import 'dart:async';
import 'package:buzz/shared/push/push_bridge.dart'; import 'package:buzz/shared/push/push_bridge.dart';
import 'package:buzz/features/channels/channel_actions_sheet.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/presence_tests.dart';
part 'channel_detail_page_test/action_row_tests.dart'; part 'channel_detail_page_test/action_row_tests.dart';
part 'channel_detail_page_test/bubble_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 _channelId = '11111111-2222-4333-8444-555555555555';
const _huddleChannelId = '8d764100-fd8f-44cf-9c98-6d8fbd739b8c'; const _huddleChannelId = '8d764100-fd8f-44cf-9c98-6d8fbd739b8c';
@@ -518,6 +521,7 @@ void main() {
presenceTests(); presenceTests();
actionRowTests(); actionRowTests();
bubbleLayoutTests(); bubbleLayoutTests();
reviewAvatarTests();
setUp(() async { setUp(() async {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
_testPrefs = await SharedPreferences.getInstance(); _testPrefs = await SharedPreferences.getInstance();
@@ -2162,6 +2166,60 @@ void main() {
expect(tester.takeException(), isNull); 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', ( testWidgets('reading the bottom ends a manual channel unread only', (
tester, tester,
) async { ) async {
@@ -4132,9 +4190,55 @@ void main() {
final carouselRect = tester.getRect(carousel); final carouselRect = tester.getRect(carousel);
final imageCountRect = tester.getRect(imageCount); 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.right, tester.view.physicalSize.width);
expect(carouselRect.top - imageCountRect.bottom, Grid.half + 2); 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<Opacity>(
find
.descendant(of: galleryAvatar, matching: find.byType(Opacity))
.first,
)
.opacity;
expect(avatarOpacity(), 1);
final scroll = tester.widget<CustomScrollView>(
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 final messageMaterial = find
.ancestor( .ancestor(
@@ -6216,7 +6320,7 @@ void main() {
expect(createdAction, findsOneWidget); expect(createdAction, findsOneWidget);
expect(find.byType(CircleAvatar), findsNothing); expect(find.byType(CircleAvatar), findsNothing);
final nameText = tester.widget<Text>(find.text('Alice')); final nameText = tester.widget<Text>(find.text('Alice'));
expect(nameText.style?.fontSize, 14); expect(nameText.style?.fontSize, 12);
expect(nameText.style?.fontWeight, FontWeight.w400); expect(nameText.style?.fontWeight, FontWeight.w400);
expect( expect(
find.byKey(const ValueKey('system-message-username-alice')), find.byKey(const ValueKey('system-message-username-alice')),
@@ -6229,10 +6333,49 @@ void main() {
final createdText = tester.widget<RichText>(createdAction); final createdText = tester.widget<RichText>(createdAction);
expect( expect(
effectiveFontSizeForText(createdText.text, 'created this channel'), 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 { testWidgets('renders a compact huddle activity row', (tester) async {
await tester.pumpWidget( await tester.pumpWidget(
_buildTestable( _buildTestable(
@@ -6244,9 +6387,20 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Alice'), findsOneWidget); expect(findRichText('started a huddle'), findsNothing);
expect(findRichText('started a huddle'), findsOneWidget); expect(find.text('Huddle ended'), findsOneWidget);
expect(find.byType(CircleAvatar), findsNothing); 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( expect(
find.byKey(const ValueKey('system-message-timestamp-alice')), find.byKey(const ValueKey('system-message-timestamp-alice')),
findsNothing, findsNothing,
@@ -6305,7 +6459,7 @@ void main() {
testWidgets('disables a different Huddle card during an active call', ( testWidgets('disables a different Huddle card during an active call', (
tester, tester,
) async { ) async {
const otherHuddleChannelId = 'other-huddle-channel'; const otherHuddleChannelId = '9d764100-fd8f-44cf-9c98-6d8fbd739b8c';
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
await tester.pumpWidget( await tester.pumpWidget(
_buildTestable( _buildTestable(
@@ -9066,11 +9220,17 @@ void main() {
(kind: EventKind.huddleEnded, action: 'ended the huddle'), (kind: EventKind.huddleEnded, action: 'ended the huddle'),
]) { ]) {
testWidgets( testWidgets(
'${huddleEvent.action} retains its system layout beside bubble messages', '${huddleEvent.action} is represented by its card without a notice',
(tester) async { (tester) async {
await tester.pumpWidget( await tester.pumpWidget(
_buildTestable( _buildTestable(
messages: [ messages: [
if (huddleEvent.kind == EventKind.huddleEnded)
_huddleMsg(
id: 'represented-start',
kind: EventKind.huddleStarted,
createdAt: 900,
),
_textMsg( _textMsg(
id: 'regular-message', id: 'regular-message',
pubkey: 'alice', pubkey: 'alice',
@@ -9101,22 +9261,20 @@ void main() {
final huddleRow = find.byKey( final huddleRow = find.byKey(
const ValueKey('system-message-row-huddle-message'), const ValueKey('system-message-row-huddle-message'),
); );
final regularAvatar = tester.getRect( expect(regularRow, findsOneWidget);
find expect(findRichText(huddleEvent.action), findsNothing);
.descendant(of: regularRow, matching: find.byType(CircleAvatar)) if (huddleEvent.kind == EventKind.huddleStarted) {
.first, expect(find.text('Huddle ended'), findsOneWidget);
); expect(
expect( find.descendant(
find.descendant(of: huddleRow, matching: find.byType(CircleAvatar)), of: huddleRow,
findsNothing, matching: find.byType(AvatarImage),
); ),
final huddleAuthor = tester.getRect( findsOneWidget,
find.byKey(const ValueKey('system-message-author-bob')), );
); } else {
final huddleBody = tester.getRect(findRichText(huddleEvent.action)); expect(huddleRow, findsNothing);
expect(huddleAuthor.left, greaterThanOrEqualTo(huddleBody.left)); }
expect(huddleBody.top, greaterThan(regularAvatar.bottom));
expect(huddleAuthor.top, greaterThanOrEqualTo(huddleBody.top));
}, },
); );
} }
@@ -9173,16 +9331,12 @@ void main() {
'message-row-message-alice', 'message-row-message-alice',
'message-row-message-bob', 'message-row-message-bob',
'system-message-row-membership-carol', 'system-message-row-membership-carol',
'system-message-row-huddle-dave',
'message-row-message-erin', 'message-row-message-erin',
].map((key) => tester.getRect(find.byKey(ValueKey(key)))).toList(); ].map((key) => tester.getRect(find.byKey(ValueKey(key)))).toList();
for (var index = 1; index < rows.length; index++) { for (var index = 1; index < rows.length; index++) {
expect(rows[index].top, greaterThanOrEqualTo(rows[index - 1].bottom)); expect(rows[index].top, greaterThanOrEqualTo(rows[index - 1].bottom));
} }
for (final key in [ for (final key in ['system-message-row-membership-carol']) {
'system-message-row-membership-carol',
'system-message-row-huddle-dave',
]) {
expect( expect(
find.descendant( find.descendant(
of: find.byKey(ValueKey(key)), 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, tester,
) async { ) 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( await tester.pumpWidget(
_buildTestable( _buildTestable(
messages: [ messages: [
_huddleMsg( _huddleMsg(
id: 'sys-huddle-avatar', id: 'sys-huddle-avatar',
kind: EventKind.huddleStarted, kind: EventKind.huddleStarted,
pubkey: 'alice', pubkey: alice,
), ),
], ],
users: { users: {alice: UserProfile(pubkey: alice, displayName: 'Alice')},
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
), ),
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap( final action = tester.getSemantics(
find.byKey(const ValueKey('system-message-author-alice')), 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(); await tester.pumpAndSettle();
expect(find.text('Copy public key'), findsOneWidget); expect(find.text('Copy public key'), findsOneWidget);
expect(find.byType(UserProfileSheet), findsOneWidget); expect(find.byType(UserProfileSheet), findsOneWidget);
expect(
find.descendant(
of: find.byType(UserProfileSheet),
matching: find.text('Alice'),
),
findsWidgets,
);
}); });
testWidgets( testWidgets(
@@ -9513,9 +9713,7 @@ void main() {
); );
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap( await tester.tap(find.byType(AvatarImage).first);
find.byKey(const ValueKey('system-message-author-alice')),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(UserProfileSheet), findsOneWidget); expect(find.byType(UserProfileSheet), findsOneWidget);
@@ -9659,7 +9857,7 @@ void main() {
Navigator.of(tester.element(find.byType(UserProfileSheet))).pop(); Navigator.of(tester.element(find.byType(UserProfileSheet))).pop();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final addedText = tester.widget<RichText>(addedAction); final addedText = tester.widget<RichText>(addedAction);
expect(effectiveFontSizeForText(addedText.text, 'added '), 14); expect(effectiveFontSizeForText(addedText.text, 'added '), 12);
}); });
for (final removal in [false, true]) { for (final removal in [false, true]) {

View File

@@ -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<ReactionRow>(
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<CustomScrollView>(
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<CustomScrollView>(
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<void> removeReaction(String reactionEventId, String emoji) async {
removed = reactionEventId;
}
}

View File

@@ -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<LocalSendBubbleSurface>(
find.byType(LocalSendBubbleSurface),
);
expect(
surface.color,
own
? theme.extension<ChatColors>()!.outgoing
: theme.colorScheme.secondaryContainer,
);
}
}
});
}
for (final dark in [false, true]) { for (final dark in [false, true]) {
testWidgets('outgoing headings, mentions and links contrast in dark=$dark', ( testWidgets('outgoing headings, mentions and links contrast in dark=$dark', (
tester, tester,
@@ -52,7 +84,9 @@ void bubbleContentTests() {
), ),
); );
await tester.pumpAndSettle(); 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']) { for (final heading in ['H1', 'H2', 'H3', 'H4', 'H5', 'H6']) {
final text = tester.widget<RichText>(_findRich(heading).last); final text = tester.widget<RichText>(_findRich(heading).last);
final colors = <Color?>[]; final colors = <Color?>[];
@@ -71,16 +105,24 @@ void bubbleContentTests() {
inspect(text.text, null); inspect(text.text, null);
expect(colors, [foreground]); expect(colors, [foreground]);
} }
final linkColor = foreground == Colors.white
? foreground
: dark
? const Color(0xFF83C4FF)
: const Color(0xFF0B5FA8);
for (final label in ['Alice', 'Helper']) { for (final label in ['Alice', 'Helper']) {
expect(tester.widget<Text>(find.text(label)).style?.color, foreground); expect(tester.widget<Text>(find.text(label)).style?.color, linkColor);
} }
expect(tester.widget<Icon>(find.byIcon(BuzzIcons.bot)).color, foreground); expect(
tester.widget<BuzzAgentIcon>(find.byType(BuzzAgentIcon)).color,
linkColor,
);
final link = tester.widget<Text>( final link = tester.widget<Text>(
find.byWidgetPredicate( find.byWidgetPredicate(
(w) => w is Text && w.textSpan?.toPlainText() == 'Website', (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')); await tester.tap(find.text('Alice'));
expect(tapped, 'person'); expect(tapped, 'person');
await tester.tap(find.text('Helper')); await tester.tap(find.text('Helper'));
@@ -114,34 +156,37 @@ void bubbleContentTests() {
}); });
} }
testWidgets('image corners follow the conversation group', (tester) async { testWidgets(
for (final grouped in [false, true]) { 'image corners remain uniform throughout the conversation group',
await tester.pumpWidget( (tester) async {
_testable( for (final grouped in [false, true]) {
MessageContent( await tester.pumpWidget(
content: '![photo](https://example.com/photo.png)', _testable(
bubble: true, MessageContent(
bubbleStart: !grouped, content: '![photo](https://example.com/photo.png)',
bubbleEnd: !grouped, bubble: true,
bubbleStart: !grouped,
bubbleEnd: !grouped,
),
), ),
), );
); await tester.pumpAndSettle();
await tester.pumpAndSettle(); final frame = tester.widget<Container>(
final frame = tester.widget<Container>( find.byKey(
find.byKey( const ValueKey(
const ValueKey( 'message-media-image-preview:https://example.com/photo.png',
'message-media-image-preview:https://example.com/photo.png', ),
), ),
), );
); final corners =
final corners = (frame.decoration! as BoxDecoration).borderRadius! as BorderRadius;
(frame.decoration! as BoxDecoration).borderRadius! as BorderRadius; expect(corners.topLeft.x, Radii.container);
expect(corners.topLeft.x, grouped ? Radii.sm : Radii.container); expect(corners.bottomLeft.x, Radii.container);
expect(corners.bottomLeft.x, grouped ? Radii.sm : Radii.container); expect(corners.topRight.x, Radii.container);
expect(corners.topRight.x, Radii.container); expect(corners.bottomRight.x, Radii.container);
expect(corners.bottomRight.x, Radii.container); }
} },
}); );
testWidgets('photo gallery stays outside its caption bubble', (tester) async { testWidgets('photo gallery stays outside its caption bubble', (tester) async {
await tester.pumpWidget( await tester.pumpWidget(

View File

@@ -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/message_content.dart';
import 'package:buzz/features/channels/media_viewer_page.dart'; import 'package:buzz/features/channels/media_viewer_page.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji.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:flutter_test/flutter_test.dart';
import 'package:gpt_markdown/gpt_markdown.dart'; import 'package:gpt_markdown/gpt_markdown.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:buzz/shared/theme/buzz_icons.dart';
class _Fixture { class _Fixture {
final revision = ValueNotifier(0); final revision = ValueNotifier(0);
@@ -197,7 +197,7 @@ void main() {
fixture.agents.add('alice-key'); fixture.agents.add('alice-key');
await fixture.refresh(tester); await fixture.refresh(tester);
expect(_components(tester), isNot(same(before))); expect(_components(tester), isNot(same(before)));
expect(find.byIcon(BuzzIcons.bot), findsOneWidget); expect(find.byType(BuzzAgentIcon), findsOneWidget);
before = _components(tester); before = _components(tester);
fixture.mentionHandler = false; fixture.mentionHandler = false;
fixture.tappedId = null; fixture.tappedId = null;
@@ -210,7 +210,7 @@ void main() {
await fixture.refresh(tester); await fixture.refresh(tester);
await tester.tap(find.text('ALICE')); await tester.tap(find.text('ALICE'));
expect(fixture.tappedId, 'second-key'); expect(fixture.tappedId, 'second-key');
expect(find.byIcon(BuzzIcons.bot), findsNothing); expect(find.byType(BuzzAgentIcon), findsNothing);
}, },
); );

View File

@@ -1,3 +1,4 @@
import 'package:buzz/shared/widgets/buzz_agent_icon.dart';
import 'package:buzz/features/channels/local_send_bubble_surface.dart'; import 'package:buzz/features/channels/local_send_bubble_surface.dart';
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
@@ -42,6 +43,7 @@ Widget _testable(
List<Override> overrides = const [], List<Override> overrides = const [],
bool disableAnimations = false, bool disableAnimations = false,
bool dark = false, bool dark = false,
ThemeData? theme,
VideoPreviewFrameLoader? videoPreviewFrameLoader, VideoPreviewFrameLoader? videoPreviewFrameLoader,
}) { }) {
return ProviderScope( return ProviderScope(
@@ -52,7 +54,7 @@ Widget _testable(
...overrides, ...overrides,
], ],
child: MaterialApp( child: MaterialApp(
theme: dark ? AppTheme.dark() : AppTheme.light(), theme: theme ?? (dark ? AppTheme.dark() : AppTheme.light()),
// Use MaterialApp.builder so the MediaQuery override (including // Use MaterialApp.builder so the MediaQuery override (including
// disableAnimations) applies to every pushed route, not just the // disableAnimations) applies to every pushed route, not just the
// home scaffold. Navigator-pushed routes (e.g. MediaVideoViewerPage) // 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('@'), findsNothing);
expect(find.text('Helper Bot'), findsOneWidget); 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(find.text('Helper Bot'), findsOneWidget);
expect(_allRichText(tester), isNot(contains('Bot Bot'))); expect(_allRichText(tester), isNot(contains('Bot Bot')));
}); });

View File

@@ -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<double> 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<double>(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<Opacity>(
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();
});
}

View File

@@ -59,6 +59,56 @@ Map<String, int> _marks({
); );
void main() { 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<String, int> marks(List<TimelineMessage> 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', () { group('channel timeline', () {
test('the bottom writes one catch-up mark instead of message marks', () { test('the bottom writes one catch-up mark instead of message marks', () {
final a = _msg('a', 100); final a = _msg('a', 100);

View File

@@ -559,6 +559,23 @@ void main() {
expect(result[0].systemEvent!.type, SystemEventType.channelCreated); 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', () { test('huddle start and end events render as system rows', () {
final result = formatTimeline([ final result = formatTimeline([
_huddleEvent(id: 'h1', kind: EventKind.huddleStarted), _huddleEvent(id: 'h1', kind: EventKind.huddleStarted),

View File

@@ -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.dart';
import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/channels_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/material.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/theme/buzz_icons.dart';
import '../../helpers/widget_helpers.dart'; import '../../helpers/widget_helpers.dart';
@@ -1085,7 +1085,7 @@ void main() {
); );
expect(content.mentionNames, {agentPubkey: 'Helper Bot'}); expect(content.mentionNames, {agentPubkey: 'Helper Bot'});
expect(content.agentMentionPubkeys, contains(agentPubkey)); 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', ( testWidgets('does not label an unjoined channel as having zero members', (