mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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 | | --- | --- | |  |  | --------- Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
22
mobile/lib/features/channels/huddle_timeline.dart
Normal file
22
mobile/lib/features/channels/huddle_timeline.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
75
mobile/lib/features/channels/message_media_avatar.dart
Normal file
75
mobile/lib/features/channels/message_media_avatar.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
27
mobile/lib/shared/widgets/buzz_agent_icon.dart
Normal file
27
mobile/lib/shared/widgets/buzz_agent_icon.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user