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