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/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,

View File

@@ -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),
),

View File

@@ -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<MessageGalleryScrollNotification>(
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<void>(
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<void>(
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<void>(
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<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) {
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,
);
}

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_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),
),
),
),
),
),

View File

@@ -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;

View File

@@ -260,6 +260,10 @@ class _MessageCarouselViewport extends HookWidget {
child: NotificationListener<ScrollUpdateNotification>(
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(

View File

@@ -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,
],

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 '../../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),
),
],
);
}
}

View File

@@ -1,3 +1,4 @@
import 'huddle_timeline.dart';
import 'dart:async';
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.
final showsNewest = !loaded.any(
(message) =>
(threadRootId != null || !isCollapsedHuddleEnding(message, loaded)) &&
message.createdAt > bottom.createdAt &&
(threadRootId != null
? _threadRootOf(message) == threadRootId
@@ -106,9 +108,8 @@ MapEntry<String, int>? _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)

View File

@@ -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';

View File

@@ -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<MessageGalleryScrollNotification>(
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<void>(
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<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 {
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<dynamic, dynamic> 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();
}

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 '../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;