mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Polish mobile bubbles, mentions, galleries, and huddle cards (#8359)
Builds on the merged themes work in #8358 and inherits its appearance and chat-color settings. Polishes mobile conversations with uniform rounded bubbles, desktop-style text-only mentions and agent icon, aligned gallery counts, fading gallery avatars, and standard iOS scrolling. Huddles use rounded cards with attached reaction trays, without duplicate start/end notices; other activity rows stay centered with smaller text. Validation: mobile checks, the full mobile test suite, and the gallery regression test passed. Release build installed on iPhone. Full `just ci` could not finish: desktop compilation exhausted local disk space. ### Snapshots Flutter-rendered light and dark previews using seeded sample messages. | Light | Dark | | --- | --- | |  |  | --------- Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import 'huddle_timeline.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import '../../shared/widgets/ios_scroll_to_top.dart';
|
||||
import 'message_footer_layout.dart';
|
||||
@@ -67,6 +68,8 @@ import 'message_actions.dart';
|
||||
import 'message_action_backdrop_state.dart';
|
||||
import 'message_long_press_region.dart';
|
||||
import 'message_content.dart';
|
||||
import '../../shared/widgets/message_attachment_corners.dart';
|
||||
import 'message_media_avatar.dart';
|
||||
import 'message_skeleton_body.dart';
|
||||
import '../../shared/read_state/deferred_read_state_update.dart';
|
||||
import '../../shared/read_state/read_state_provider.dart';
|
||||
@@ -611,7 +614,12 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
currentPubkey: currentPubkey,
|
||||
);
|
||||
final timelineEntries = buildMainTimelineEntries(
|
||||
timelineMessages,
|
||||
// End events still update the original card through allMessages.
|
||||
timelineMessages
|
||||
.where(
|
||||
(message) => !isCollapsedHuddleEnding(message, timelineMessages),
|
||||
)
|
||||
.toList(),
|
||||
relaySummaries: ref
|
||||
.read(channelMessagesProvider(channel.id).notifier)
|
||||
.threadSummaries,
|
||||
|
||||
@@ -330,15 +330,13 @@ class _HuddleJoinSurface extends ConsumerWidget {
|
||||
};
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: messageAvatarSize + messageAvatarContentGap,
|
||||
top: Grid.xxs,
|
||||
),
|
||||
padding: const EdgeInsets.only(top: Grid.xxs),
|
||||
child: Container(
|
||||
key: ValueKey('huddle-card-${message.id}'),
|
||||
padding: const EdgeInsets.all(Grid.twelve),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.primary.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
borderRadius: conversationAttachmentCorners,
|
||||
border: Border.all(
|
||||
color: context.colors.primary.withValues(alpha: 0.18),
|
||||
),
|
||||
|
||||
@@ -34,6 +34,7 @@ class _MessageBubble extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final messageSnapshotKey = useMemoized(GlobalKey.new, const []);
|
||||
final mediaOffset = useMessageMediaOffset(message.content, message.tags);
|
||||
final hasLocalReplies = ref.watch(
|
||||
threadLocalRepliesProvider(
|
||||
ThreadRepliesArgs(
|
||||
@@ -124,271 +125,303 @@ class _MessageBubble extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
// The media carousel intentionally continues through the list's trailing
|
||||
// gutter. InkWell still clips its ink to [borderRadius], while leaving
|
||||
// overflowing message content visible.
|
||||
clipBehavior: Clip.none,
|
||||
child: MessageLongPressInkWell(
|
||||
key: ValueKey('message-row-${message.id}'),
|
||||
onLongPressDetails: openMessageActions,
|
||||
return NotificationListener<MessageGalleryScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
mediaOffset.value = notification.offset;
|
||||
return true;
|
||||
},
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
highlightColor: context.colors.primary.withValues(alpha: 0.1),
|
||||
snapshotKey: messageSnapshotKey,
|
||||
// Tap opens existing threads; long-press can start a new one.
|
||||
// MessageContent handles mention, channel-link, and media taps.
|
||||
onTap: (!hasReplies && !hasLocalReplies) || allMessages == null
|
||||
? null
|
||||
: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: message,
|
||||
allMessages: allMessages!,
|
||||
channelId: currentChannelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
// The media carousel intentionally continues through the list's trailing
|
||||
// gutter. InkWell still clips its ink to [borderRadius], while leaving
|
||||
// overflowing message content visible.
|
||||
clipBehavior: Clip.none,
|
||||
child: MessageLongPressInkWell(
|
||||
key: ValueKey('message-row-${message.id}'),
|
||||
onLongPressDetails: openMessageActions,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
highlightColor: context.colors.primary.withValues(alpha: 0.1),
|
||||
snapshotKey: messageSnapshotKey,
|
||||
// Tap opens existing threads; long-press can start a new one.
|
||||
// MessageContent handles mention, channel-link, and media taps.
|
||||
onTap: (!hasReplies && !hasLocalReplies) || allMessages == null
|
||||
? null
|
||||
: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: message,
|
||||
allMessages: allMessages!,
|
||||
channelId: currentChannelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: showAvatar ? Grid.quarter : Grid.quarter / 2,
|
||||
),
|
||||
child: MessageFooterLayout(
|
||||
animateSize: true,
|
||||
overlap: message.reactions.isNotEmpty || replyFooter != null
|
||||
? Grid.xs
|
||||
: 0,
|
||||
footer: message.reactions.isNotEmpty || replyFooter != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left:
|
||||
messageAvatarSize +
|
||||
messageAvatarContentGap +
|
||||
Grid.xxs,
|
||||
),
|
||||
child: Wrap(
|
||||
spacing: Grid.xxs,
|
||||
runSpacing: Grid.half,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
if (message.reactions.isNotEmpty)
|
||||
ReactionRow(
|
||||
combined: true,
|
||||
messageId: message.id,
|
||||
channelId: currentChannelId,
|
||||
reactions: message.reactions,
|
||||
onToggle: (emoji) =>
|
||||
toggleReaction(ref, message, emoji),
|
||||
showAddButton: isMember && !isArchived,
|
||||
onAddReaction: () => showAddReactionPicker(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: showAvatar ? Grid.quarter : Grid.quarter / 2,
|
||||
),
|
||||
child: MessageFooterLayout(
|
||||
animateSize: true,
|
||||
overlap: message.reactions.isNotEmpty || replyFooter != null
|
||||
? Grid.xs
|
||||
: 0,
|
||||
footer: message.reactions.isNotEmpty || replyFooter != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left:
|
||||
messageAvatarSize +
|
||||
messageAvatarContentGap +
|
||||
Grid.xxs,
|
||||
),
|
||||
child: Wrap(
|
||||
spacing: Grid.xxs,
|
||||
runSpacing: Grid.half,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
if (message.reactions.isNotEmpty)
|
||||
ReactionRow(
|
||||
combined: true,
|
||||
messageId: message.id,
|
||||
channelId: currentChannelId,
|
||||
reactions: message.reactions,
|
||||
onToggle: (emoji) =>
|
||||
toggleReaction(ref, message, emoji),
|
||||
showAddButton: isMember && !isArchived,
|
||||
onAddReaction: () => showAddReactionPicker(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (replyFooter != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: Grid.xxs),
|
||||
child: replyFooter,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
child: RepaintBoundary(
|
||||
key: messageSnapshotKey,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
const SizedBox(width: messageAvatarSize),
|
||||
const SizedBox(width: messageAvatarContentGap),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: showAuthor ? Grid.half : 0,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showAuthor)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: Grid.quarter,
|
||||
left: Grid.xs,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MessageAuthorMeta(
|
||||
showTimestamp: true,
|
||||
nameStyle: messageUsernameTextStyle
|
||||
.copyWith(
|
||||
fontSize: 13.1,
|
||||
height: 17 / 13.1,
|
||||
),
|
||||
metadataStyle:
|
||||
messageMetadataTextStyle
|
||||
.copyWith(
|
||||
fontSize: 13.1,
|
||||
height: 17 / 13.1,
|
||||
),
|
||||
timestampStyle:
|
||||
messageTimestampTextStyle
|
||||
.copyWith(
|
||||
fontSize: 11,
|
||||
height: 17 / 11,
|
||||
),
|
||||
displayName: displayName,
|
||||
username: messageUsernameLabel(
|
||||
profile,
|
||||
),
|
||||
timestamp: formatMessageTime(
|
||||
message.createdAt,
|
||||
),
|
||||
nameColor: context.colors.onSurface,
|
||||
metadataColor:
|
||||
context.colors.onSurfaceVariant,
|
||||
onAuthorTap: () =>
|
||||
showUserProfileSheet(
|
||||
context,
|
||||
message.pubkey,
|
||||
names:
|
||||
channelIdentityNamesProvider(
|
||||
currentChannelId,
|
||||
if (replyFooter != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: Grid.xxs,
|
||||
),
|
||||
child: replyFooter,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
child: RepaintBoundary(
|
||||
key: messageSnapshotKey,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
const SizedBox(width: messageAvatarSize),
|
||||
const SizedBox(width: messageAvatarContentGap),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: showAuthor ? Grid.half : 0,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showAuthor)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: Grid.quarter,
|
||||
left: Grid.xs,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MessageAuthorMeta(
|
||||
showTimestamp: true,
|
||||
nameStyle:
|
||||
messageUsernameTextStyle
|
||||
.copyWith(
|
||||
fontSize: 13.1,
|
||||
height: 17 / 13.1,
|
||||
),
|
||||
),
|
||||
displayNameKey: ValueKey(
|
||||
'message-author-${message.id}',
|
||||
),
|
||||
usernameKey: ValueKey(
|
||||
'message-username-${message.id}',
|
||||
),
|
||||
timestampKey: ValueKey(
|
||||
'message-timestamp-${message.id}',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (message.edited) ...[
|
||||
const SizedBox(width: Grid.half),
|
||||
Text(
|
||||
'(edited)',
|
||||
style: context.textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: context
|
||||
.colors
|
||||
.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
MessageContent(
|
||||
content: message.content,
|
||||
bubble: true,
|
||||
hasReactions:
|
||||
message.reactions.isNotEmpty ||
|
||||
replyFooter != null,
|
||||
bubbleEnd: showAvatar,
|
||||
bubbleStart: showAuthor,
|
||||
ownMessage:
|
||||
currentPubkey?.toLowerCase() == pk,
|
||||
mentionNames: resolvedMentionNames,
|
||||
mentionLabels: mentionLabels,
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
channelNames: channelNames,
|
||||
tags: message.tags,
|
||||
baseStyle: messageBodyTextStyle.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
scaleEmojiOnly: true,
|
||||
mediaCarouselTrailingOverflow: Grid.gutter,
|
||||
onMediaReply: allMessages == null
|
||||
? null
|
||||
: () {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: message,
|
||||
allMessages: allMessages!,
|
||||
channelId: currentChannelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
metadataStyle:
|
||||
messageMetadataTextStyle
|
||||
.copyWith(
|
||||
fontSize: 13.1,
|
||||
height: 17 / 13.1,
|
||||
),
|
||||
timestampStyle:
|
||||
messageTimestampTextStyle
|
||||
.copyWith(
|
||||
fontSize: 11,
|
||||
height: 17 / 11,
|
||||
),
|
||||
displayName: displayName,
|
||||
username: messageUsernameLabel(
|
||||
profile,
|
||||
),
|
||||
timestamp: formatMessageTime(
|
||||
message.createdAt,
|
||||
),
|
||||
nameColor:
|
||||
context.colors.onSurface,
|
||||
metadataColor: context
|
||||
.colors
|
||||
.onSurfaceVariant,
|
||||
onAuthorTap: () =>
|
||||
showUserProfileSheet(
|
||||
context,
|
||||
message.pubkey,
|
||||
names:
|
||||
channelIdentityNamesProvider(
|
||||
currentChannelId,
|
||||
),
|
||||
),
|
||||
displayNameKey: ValueKey(
|
||||
'message-author-${message.id}',
|
||||
),
|
||||
usernameKey: ValueKey(
|
||||
'message-username-${message.id}',
|
||||
),
|
||||
timestampKey: ValueKey(
|
||||
'message-timestamp-${message.id}',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onMediaMore: (viewerContext, imageUrl) =>
|
||||
showImageActions(
|
||||
context: viewerContext,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: currentChannelId,
|
||||
imageUrl: imageUrl,
|
||||
canManageMessage: canManageMessage,
|
||||
onDeleted: () {
|
||||
if (viewerContext.mounted) {
|
||||
Navigator.of(
|
||||
viewerContext,
|
||||
).maybePop();
|
||||
}
|
||||
},
|
||||
),
|
||||
if (message.edited) ...[
|
||||
const SizedBox(width: Grid.half),
|
||||
Text(
|
||||
'(edited)',
|
||||
style: context
|
||||
.textTheme
|
||||
.labelSmall
|
||||
?.copyWith(
|
||||
color: context
|
||||
.colors
|
||||
.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
onChannelTap: (channelId) {
|
||||
openChannelLink(
|
||||
context: context,
|
||||
ref: ref,
|
||||
channelId: channelId,
|
||||
currentChannelId: currentChannelId,
|
||||
);
|
||||
},
|
||||
onMentionTap: (pubkey) =>
|
||||
showUserProfileSheet(
|
||||
context,
|
||||
pubkey,
|
||||
names: channelIdentityNamesProvider(
|
||||
currentChannelId,
|
||||
),
|
||||
MessageContent(
|
||||
content: message.content,
|
||||
bubble: true,
|
||||
hasReactions:
|
||||
message.reactions.isNotEmpty ||
|
||||
replyFooter != null,
|
||||
bubbleEnd: showAvatar,
|
||||
bubbleStart: showAuthor,
|
||||
ownMessage:
|
||||
currentPubkey?.toLowerCase() == pk,
|
||||
mentionNames: resolvedMentionNames,
|
||||
mentionLabels: mentionLabels,
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
channelNames: channelNames,
|
||||
tags: message.tags,
|
||||
baseStyle: messageBodyTextStyle.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
scaleEmojiOnly: true,
|
||||
mediaCarouselTrailingOverflow: Grid.gutter,
|
||||
onMediaReply: allMessages == null
|
||||
? null
|
||||
: () {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) =>
|
||||
ThreadDetailPage(
|
||||
threadHead: message,
|
||||
allMessages: allMessages!,
|
||||
channelId:
|
||||
currentChannelId,
|
||||
currentPubkey:
|
||||
currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onMediaMore: (viewerContext, imageUrl) =>
|
||||
showImageActions(
|
||||
context: viewerContext,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: currentChannelId,
|
||||
imageUrl: imageUrl,
|
||||
canManageMessage: canManageMessage,
|
||||
onDeleted: () {
|
||||
if (viewerContext.mounted) {
|
||||
Navigator.of(
|
||||
viewerContext,
|
||||
).maybePop();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
onChannelTap: (channelId) {
|
||||
openChannelLink(
|
||||
context: context,
|
||||
ref: ref,
|
||||
channelId: channelId,
|
||||
currentChannelId: currentChannelId,
|
||||
);
|
||||
},
|
||||
onMentionTap: (pubkey) =>
|
||||
showUserProfileSheet(
|
||||
context,
|
||||
pubkey,
|
||||
names: channelIdentityNamesProvider(
|
||||
currentChannelId,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (showAvatar)
|
||||
Positioned(
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
child: GestureDetector(
|
||||
onTap: () => showUserProfileSheet(
|
||||
context,
|
||||
message.pubkey,
|
||||
names: channelIdentityNamesProvider(
|
||||
currentChannelId,
|
||||
),
|
||||
),
|
||||
child: _UserAvatar(
|
||||
profile: profile,
|
||||
pubkey: message.pubkey,
|
||||
isAgent: isAgent,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (showAvatar)
|
||||
Positioned(
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
child: MessageMediaAvatar(
|
||||
offset: mediaOffset,
|
||||
child: Semantics(
|
||||
container: true,
|
||||
label: 'Open $displayName profile',
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
onTap: () => showUserProfileSheet(
|
||||
context,
|
||||
message.pubkey,
|
||||
names: channelIdentityNamesProvider(
|
||||
currentChannelId,
|
||||
),
|
||||
),
|
||||
child: GestureDetector(
|
||||
onTap: () => showUserProfileSheet(
|
||||
context,
|
||||
message.pubkey,
|
||||
names: channelIdentityNamesProvider(
|
||||
currentChannelId,
|
||||
),
|
||||
),
|
||||
child: _UserAvatar(
|
||||
profile: profile,
|
||||
pubkey: message.pubkey,
|
||||
isAgent: isAgent,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -23,7 +23,9 @@ class _SystemMessageRow extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final spotlightKey = useMemoized(() => GlobalKey());
|
||||
final systemEvent = message.systemEvent;
|
||||
if (systemEvent == null) return const SizedBox.shrink();
|
||||
if (systemEvent == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final userCache = ref.watch(userCacheProvider);
|
||||
final sourceMessages = groupedMessages ?? [message];
|
||||
@@ -130,6 +132,46 @@ class _SystemMessageRow extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final isHuddle =
|
||||
systemEvent.type == SystemEventType.huddleStarted &&
|
||||
systemEvent.ephemeralChannelId != null;
|
||||
final actor = (systemEvent.actorPubkey ?? message.pubkey).toLowerCase();
|
||||
Widget withActor(Widget content) {
|
||||
if (!isHuddle) return content;
|
||||
return Row(
|
||||
key: ValueKey('huddle-activity-${message.id}'),
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Semantics(
|
||||
container: true,
|
||||
label: 'Open ${resolveLabel(actor)} profile',
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
onTap: () => showUserProfileSheet(
|
||||
context,
|
||||
actor,
|
||||
names: channelIdentityNamesProvider(channelId),
|
||||
),
|
||||
child: GestureDetector(
|
||||
excludeFromSemantics: true,
|
||||
onTap: () => showUserProfileSheet(
|
||||
context,
|
||||
actor,
|
||||
names: channelIdentityNamesProvider(channelId),
|
||||
),
|
||||
child: _UserAvatar(
|
||||
profile: userCache[actor],
|
||||
pubkey: actor,
|
||||
isAgent: userCache[actor]?.ownerPubkey != null,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: messageAvatarContentGap),
|
||||
Expanded(child: content),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
@@ -143,67 +185,105 @@ class _SystemMessageRow extends HookConsumerWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
crossAxisAlignment: isHuddle
|
||||
? CrossAxisAlignment.start
|
||||
: CrossAxisAlignment.center,
|
||||
children: [
|
||||
RepaintBoundary(
|
||||
key: spotlightKey,
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: Container(
|
||||
key: ValueKey('activity-card-${message.id}'),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.twelve,
|
||||
vertical: Grid.xxs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.onSurface.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(Radii.container),
|
||||
),
|
||||
child: IntrinsicWidth(
|
||||
child: groupedMembership != null
|
||||
? _MembershipSystemMessageContent(
|
||||
event: groupedMembership,
|
||||
channelId: channelId,
|
||||
createdAt: message.createdAt,
|
||||
resolveLabel: resolveLabel,
|
||||
userCache: userCache,
|
||||
)
|
||||
: messageStyleActor != null &&
|
||||
messageStyleActor.isNotEmpty &&
|
||||
messageStyleAction != null
|
||||
? _MessageStyleSystemMessageContent(
|
||||
displayPubkey: messageStyleActor,
|
||||
channelId: channelId,
|
||||
createdAt: message.createdAt,
|
||||
resolveLabel: resolveLabel,
|
||||
userCache: userCache,
|
||||
actionSpans: messageStyleAction,
|
||||
)
|
||||
: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: systemEvent.describe(resolveLabel),
|
||||
),
|
||||
],
|
||||
if (!isHuddle)
|
||||
RepaintBoundary(
|
||||
key: spotlightKey,
|
||||
child: Align(
|
||||
alignment: isHuddle
|
||||
? Alignment.centerLeft
|
||||
: Alignment.center,
|
||||
child: Container(
|
||||
key: ValueKey('activity-card-${message.id}'),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.twelve,
|
||||
vertical: Grid.xxs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.onSurface.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(Radii.container),
|
||||
),
|
||||
child: IntrinsicWidth(
|
||||
child: groupedMembership != null
|
||||
? _MembershipSystemMessageContent(
|
||||
event: groupedMembership,
|
||||
channelId: channelId,
|
||||
createdAt: message.createdAt,
|
||||
resolveLabel: resolveLabel,
|
||||
userCache: userCache,
|
||||
)
|
||||
: messageStyleActor != null &&
|
||||
messageStyleActor.isNotEmpty &&
|
||||
messageStyleAction != null
|
||||
? _MessageStyleSystemMessageContent(
|
||||
displayPubkey: messageStyleActor,
|
||||
channelId: channelId,
|
||||
createdAt: message.createdAt,
|
||||
resolveLabel: resolveLabel,
|
||||
userCache: userCache,
|
||||
actionSpans: messageStyleAction,
|
||||
)
|
||||
: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: systemEvent.describe(resolveLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
style: _systemActionTextStyle(context),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
style: _systemActionTextStyle(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (systemEvent.type == SystemEventType.huddleStarted &&
|
||||
systemEvent.ephemeralChannelId != null)
|
||||
_HuddleJoinSurface(
|
||||
message: message,
|
||||
allMessages: allMessages ?? const [],
|
||||
parentChannelId: channelId,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
MessageFooterLayout(
|
||||
animateSize: true,
|
||||
overlap: reactions.isNotEmpty ? Grid.xs : 0,
|
||||
footer: reactions.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left:
|
||||
messageAvatarSize +
|
||||
messageAvatarContentGap +
|
||||
Grid.xxs,
|
||||
),
|
||||
child: ReactionRow(
|
||||
combined: true,
|
||||
messageId: message.id,
|
||||
channelId: channelId,
|
||||
reactions: reactions,
|
||||
onToggle: (emoji) =>
|
||||
toggleReaction(ref, message, emoji),
|
||||
showAddButton: isMember && !isArchived,
|
||||
onAddReaction: () => showAddReactionPicker(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: withActor(
|
||||
RepaintBoundary(
|
||||
key: spotlightKey,
|
||||
child: _HuddleJoinSurface(
|
||||
message: message,
|
||||
allMessages: allMessages ?? const [],
|
||||
parentChannelId: channelId,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (reactions.isNotEmpty)
|
||||
if (!isHuddle && reactions.isNotEmpty)
|
||||
IntrinsicWidth(
|
||||
child: ReactionRow(
|
||||
messageId: message.id,
|
||||
@@ -415,7 +495,7 @@ class _MembershipSystemMessageContent extends StatelessWidget {
|
||||
}
|
||||
|
||||
TextStyle? _systemActionTextStyle(BuildContext context) {
|
||||
return context.textTheme.bodySmall?.copyWith(
|
||||
return bodyExtraSmallTextStyle.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
);
|
||||
}
|
||||
|
||||
22
mobile/lib/features/channels/huddle_timeline.dart
Normal file
22
mobile/lib/features/channels/huddle_timeline.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'timeline_message.dart';
|
||||
|
||||
/// Endings collapse only when a visible start card represents them and no
|
||||
/// reactions on the ending need their own interactive surface.
|
||||
bool isCollapsedHuddleEnding(
|
||||
TimelineMessage message,
|
||||
Iterable<TimelineMessage> loaded,
|
||||
) {
|
||||
final event = message.systemEvent;
|
||||
if (event?.type != SystemEventType.huddleEnded ||
|
||||
event?.ephemeralChannelId == null ||
|
||||
message.reactions.isNotEmpty) {
|
||||
return false;
|
||||
}
|
||||
return loaded.any(
|
||||
(start) =>
|
||||
start.parentId == null &&
|
||||
start.systemEvent?.type == SystemEventType.huddleStarted &&
|
||||
start.systemEvent?.ephemeralChannelId == event!.ephemeralChannelId &&
|
||||
start.createdAt <= message.createdAt,
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import 'message_content/bubble_segments.dart';
|
||||
import 'message_mention_pill.dart';
|
||||
import 'message_gallery.dart';
|
||||
import 'message_gallery_frame.dart';
|
||||
import 'message_media_avatar.dart';
|
||||
import 'message_media_geometry.dart';
|
||||
import 'voice_note_attachment.dart';
|
||||
|
||||
@@ -194,6 +195,12 @@ class MessageContent extends HookConsumerWidget {
|
||||
final markdownTheme = GptMarkdownTheme.of(context);
|
||||
final chatColors = ChatColors.of(context);
|
||||
final ownTextColor = chatColors.foreground;
|
||||
// Desktop uses blue links on neutral bubbles and the bubble ink on colors.
|
||||
final linkColor = ownMessage && ownTextColor == Colors.white
|
||||
? ownTextColor
|
||||
: context.colors.brightness == Brightness.dark
|
||||
? const Color(0xFF83C4FF)
|
||||
: const Color(0xFF0B5FA8);
|
||||
final baseTextStyle =
|
||||
(baseStyle ??
|
||||
context.textTheme.bodyMedium?.copyWith(
|
||||
@@ -410,13 +417,7 @@ class MessageContent extends HookConsumerWidget {
|
||||
color: ownMessage
|
||||
? chatColors.outgoing
|
||||
: context.colors.secondaryContainer,
|
||||
shape: _MessageBubbleShape(
|
||||
start: bubbleStart && index == 0,
|
||||
end:
|
||||
bubbleEnd &&
|
||||
trailingGallery == null &&
|
||||
index == segments.length - 1,
|
||||
),
|
||||
shape: const _MessageBubbleShape(),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
Grid.xs,
|
||||
@@ -426,38 +427,48 @@ class MessageContent extends HookConsumerWidget {
|
||||
? Grid.xs
|
||||
: Grid.xxs,
|
||||
),
|
||||
child: ownMessage
|
||||
? Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
colorScheme: context.colors.copyWith(
|
||||
primary: ownTextColor,
|
||||
),
|
||||
),
|
||||
child: GptMarkdownTheme(
|
||||
gptThemeData: markdownTheme.copyWith(
|
||||
h1: markdownTheme.h1?.copyWith(
|
||||
color: ownTextColor,
|
||||
),
|
||||
h2: markdownTheme.h2?.copyWith(
|
||||
color: ownTextColor,
|
||||
),
|
||||
h3: markdownTheme.h3?.copyWith(
|
||||
color: ownTextColor,
|
||||
),
|
||||
h4: markdownTheme.h4?.copyWith(
|
||||
color: ownTextColor,
|
||||
),
|
||||
h5: markdownTheme.h5?.copyWith(
|
||||
color: ownTextColor,
|
||||
),
|
||||
h6: markdownTheme.h6?.copyWith(
|
||||
color: ownTextColor,
|
||||
),
|
||||
),
|
||||
child: renderMarkdown(segment.text),
|
||||
),
|
||||
)
|
||||
: renderMarkdown(segment.text),
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
colorScheme: context.colors.copyWith(
|
||||
primary: linkColor,
|
||||
),
|
||||
),
|
||||
child: GptMarkdownTheme(
|
||||
gptThemeData: markdownTheme.copyWith(
|
||||
h1: markdownTheme.h1?.copyWith(
|
||||
color: ownMessage
|
||||
? ownTextColor
|
||||
: context.colors.onSurface,
|
||||
),
|
||||
h2: markdownTheme.h2?.copyWith(
|
||||
color: ownMessage
|
||||
? ownTextColor
|
||||
: context.colors.onSurface,
|
||||
),
|
||||
h3: markdownTheme.h3?.copyWith(
|
||||
color: ownMessage
|
||||
? ownTextColor
|
||||
: context.colors.onSurface,
|
||||
),
|
||||
h4: markdownTheme.h4?.copyWith(
|
||||
color: ownMessage
|
||||
? ownTextColor
|
||||
: context.colors.onSurface,
|
||||
),
|
||||
h5: markdownTheme.h5?.copyWith(
|
||||
color: ownMessage
|
||||
? ownTextColor
|
||||
: context.colors.onSurface,
|
||||
),
|
||||
h6: markdownTheme.h6?.copyWith(
|
||||
color: ownMessage
|
||||
? ownTextColor
|
||||
: context.colors.onSurface,
|
||||
),
|
||||
),
|
||||
child: renderMarkdown(segment.text),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,49 +1,12 @@
|
||||
part of '../message_content.dart';
|
||||
|
||||
/// Grouped message corners without a tail.
|
||||
class _MessageBubbleShape extends ShapeBorder {
|
||||
const _MessageBubbleShape({required this.start, required this.end});
|
||||
|
||||
final bool start;
|
||||
final bool end;
|
||||
|
||||
@override
|
||||
EdgeInsetsGeometry get dimensions => EdgeInsets.zero;
|
||||
|
||||
@override
|
||||
ShapeBorder scale(double t) => this;
|
||||
|
||||
@override
|
||||
Path getOuterPath(Rect rect, {TextDirection? textDirection}) {
|
||||
final round = Radius.circular(
|
||||
math.min(Radii.container, rect.shortestSide / 2),
|
||||
);
|
||||
const joined = Radius.circular(Radii.sm);
|
||||
final body = Path()
|
||||
..addRRect(
|
||||
RRect.fromRectAndCorners(
|
||||
rect,
|
||||
topLeft: start ? round : joined,
|
||||
topRight: round,
|
||||
bottomLeft: end ? round : joined,
|
||||
bottomRight: round,
|
||||
),
|
||||
/// Uniform, tail-free corners for every message in a sender run.
|
||||
class _MessageBubbleShape extends RoundedRectangleBorder {
|
||||
const _MessageBubbleShape()
|
||||
: super(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(Radii.container)),
|
||||
);
|
||||
return body;
|
||||
}
|
||||
|
||||
@override
|
||||
Path getInnerPath(Rect rect, {TextDirection? textDirection}) =>
|
||||
getOuterPath(rect, textDirection: textDirection);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) {}
|
||||
}
|
||||
|
||||
BorderRadius _attachmentCorners({required bool start, required bool end}) =>
|
||||
BorderRadius.only(
|
||||
topLeft: Radius.circular(start ? Radii.container : Radii.sm),
|
||||
topRight: const Radius.circular(Radii.container),
|
||||
bottomLeft: Radius.circular(end ? Radii.container : Radii.sm),
|
||||
bottomRight: const Radius.circular(Radii.container),
|
||||
);
|
||||
conversationAttachmentCorners;
|
||||
|
||||
@@ -260,6 +260,10 @@ class _MessageCarouselViewport extends HookWidget {
|
||||
child: NotificationListener<ScrollUpdateNotification>(
|
||||
onNotification: (notification) {
|
||||
if (notification.depth == 0 && notification.metrics is PageMetrics) {
|
||||
MessageGalleryScrollNotification(
|
||||
notification.metrics.pixels -
|
||||
notification.metrics.minScrollExtent,
|
||||
).dispatch(context);
|
||||
final index = (notification.metrics as PageMetrics).page!.round();
|
||||
if (index != currentIndex) onPageChanged(index);
|
||||
}
|
||||
@@ -268,7 +272,11 @@ class _MessageCarouselViewport extends HookWidget {
|
||||
child: CustomScrollView(
|
||||
controller: controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const _CarouselPagePhysics(),
|
||||
physics: _CarouselPagePhysics(
|
||||
parent: Theme.of(context).platform == TargetPlatform.iOS
|
||||
? const BouncingScrollPhysics()
|
||||
: const ClampingScrollPhysics(),
|
||||
),
|
||||
clipBehavior: Clip.none,
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
|
||||
@@ -39,18 +39,20 @@ class MessageGalleryFrame extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (loading)
|
||||
ExcludeSemantics(
|
||||
child: Stack(
|
||||
alignment: Alignment.centerLeft,
|
||||
children: [
|
||||
Opacity(opacity: 0, child: label),
|
||||
const SkeletonBar(width: 48, height: 10),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
label,
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: Grid.xs),
|
||||
child: loading
|
||||
? ExcludeSemantics(
|
||||
child: Stack(
|
||||
alignment: Alignment.centerLeft,
|
||||
children: [
|
||||
Opacity(opacity: 0, child: label),
|
||||
const SkeletonBar(width: 48, height: 10),
|
||||
],
|
||||
),
|
||||
)
|
||||
: label,
|
||||
),
|
||||
const SizedBox(height: Grid.half + Grid.quarter),
|
||||
child,
|
||||
],
|
||||
|
||||
75
mobile/lib/features/channels/message_media_avatar.dart
Normal file
75
mobile/lib/features/channels/message_media_avatar.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
import 'message_gallery.dart';
|
||||
import 'message_media.dart';
|
||||
import 'message_content/link_normalizer.dart';
|
||||
|
||||
import '../../shared/theme/message_typography.dart';
|
||||
|
||||
/// Retains gallery displacement across caption edits, resetting for new images.
|
||||
ValueNotifier<double> useMessageMediaOffset(
|
||||
String content,
|
||||
List<List<String>> tags,
|
||||
) {
|
||||
final gallery = extractTrailingImageGallery(normalizeBareLinks(content), {
|
||||
for (final entry in parseImetaTags(tags).entries)
|
||||
normalizeMarkdownDestination(entry.key): entry.value,
|
||||
});
|
||||
final signature = gallery?.items.map((item) => item.url).join('\u0000');
|
||||
final offset = useValueNotifier(0.0);
|
||||
useEffect(() {
|
||||
offset.value = 0;
|
||||
return null;
|
||||
}, [signature]);
|
||||
return offset;
|
||||
}
|
||||
|
||||
/// Reports gallery displacement so the author clears the overflowing images.
|
||||
class MessageGalleryScrollNotification extends Notification {
|
||||
/// Creates a displacement update relative to the first image.
|
||||
const MessageGalleryScrollNotification(this.offset);
|
||||
|
||||
/// Gallery displacement in logical pixels.
|
||||
final double offset;
|
||||
}
|
||||
|
||||
/// Fades the author away as the gallery moves into its gutter.
|
||||
/// Remains hidden on later images and returns when the first image returns.
|
||||
class MessageMediaAvatar extends StatelessWidget {
|
||||
/// Connects the author presentation to its message's gallery position.
|
||||
const MessageMediaAvatar({
|
||||
super.key,
|
||||
required this.offset,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
/// Gallery displacement relative to the first image.
|
||||
final ValueListenable<double> offset;
|
||||
|
||||
/// Author avatar and its profile action.
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ValueListenableBuilder<double>(
|
||||
valueListenable: offset,
|
||||
child: child,
|
||||
builder: (context, offset, child) {
|
||||
final visibility = 1 - (offset / messageAvatarSize).clamp(0.0, 1.0);
|
||||
return IgnorePointer(
|
||||
ignoring: visibility < 1,
|
||||
child: ExcludeSemantics(
|
||||
excluding: visibility < 1,
|
||||
child: Opacity(
|
||||
opacity: visibility,
|
||||
child: Transform.scale(
|
||||
scale: MediaQuery.disableAnimationsOf(context) ? 1 : visibility,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../shared/theme/buzz_icons.dart';
|
||||
import '../../shared/widgets/buzz_agent_icon.dart';
|
||||
|
||||
import '../../shared/theme/theme.dart';
|
||||
|
||||
/// The inline identity pill shared by chat mentions and channel actions.
|
||||
/// The inline identity label shared by chat mentions and channel actions.
|
||||
class MessageMentionPill extends StatelessWidget {
|
||||
/// Visible contextual identity name.
|
||||
final String label;
|
||||
@@ -11,7 +11,7 @@ class MessageMentionPill extends StatelessWidget {
|
||||
/// Full identity label for assistive technologies.
|
||||
final String? semanticsLabel;
|
||||
|
||||
/// Whether to use the bot icon instead of the @ prefix.
|
||||
/// Whether to use the desktop robot icon instead of the @ prefix.
|
||||
final bool isAgent;
|
||||
|
||||
/// Typography inherited from the surrounding message.
|
||||
@@ -41,38 +41,21 @@ class MessageMentionPill extends StatelessWidget {
|
||||
);
|
||||
final fontSize = style.fontSize ?? 16;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.half,
|
||||
Grid.quarter + 1,
|
||||
Grid.half,
|
||||
Grid.quarter,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(Radii.sm),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (isAgent) ...[
|
||||
Icon(
|
||||
BuzzIcons.bot,
|
||||
size: fontSize * 0.95,
|
||||
color: context.colors.primary,
|
||||
),
|
||||
const SizedBox(width: Grid.quarter + 1),
|
||||
] else
|
||||
Transform.translate(
|
||||
offset: const Offset(0, -Grid.quarter),
|
||||
child: Text('@', style: style),
|
||||
),
|
||||
Flexible(
|
||||
child: Text(label, style: style, semanticsLabel: semanticsLabel),
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (isAgent)
|
||||
BuzzAgentIcon(size: fontSize, color: context.colors.primary)
|
||||
else
|
||||
Transform.translate(
|
||||
offset: const Offset(0, -Grid.quarter),
|
||||
child: Text('@', style: style),
|
||||
),
|
||||
],
|
||||
),
|
||||
Flexible(
|
||||
child: Text(label, style: style, semanticsLabel: semanticsLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'huddle_timeline.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
@@ -99,6 +100,7 @@ MapEntry<String, int>? _catchUpMark({
|
||||
// the live bottom, for example after a jump into history.
|
||||
final showsNewest = !loaded.any(
|
||||
(message) =>
|
||||
(threadRootId != null || !isCollapsedHuddleEnding(message, loaded)) &&
|
||||
message.createdAt > bottom.createdAt &&
|
||||
(threadRootId != null
|
||||
? _threadRootOf(message) == threadRootId
|
||||
@@ -106,9 +108,8 @@ MapEntry<String, int>? _catchUpMark({
|
||||
);
|
||||
if (!showsNewest) return null;
|
||||
|
||||
// Cut at the bottom row. A newer reply's time would also read a top-level
|
||||
// message that arrives late with an earlier time. A clock ahead of now, on
|
||||
// this device or the sender's, must not read messages before they arrive.
|
||||
// Never read beyond the last rendered message: a message between that
|
||||
// row and a hidden lifecycle event may still arrive out of order.
|
||||
final cut = bottom.createdAt < now ? bottom.createdAt : now;
|
||||
final key = threadRootId != null
|
||||
? threadActivityContextKey(threadRootId)
|
||||
|
||||
@@ -52,6 +52,7 @@ import 'message_actions.dart';
|
||||
import 'message_action_backdrop_state.dart';
|
||||
import 'message_long_press_region.dart';
|
||||
import 'message_content.dart';
|
||||
import 'message_media_avatar.dart';
|
||||
import 'reaction_row.dart';
|
||||
import '../../shared/read_state/read_state_provider.dart';
|
||||
import 'send_message_provider.dart';
|
||||
|
||||
@@ -37,6 +37,7 @@ class _ThreadMessage extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final messageSnapshotKey = useMemoized(GlobalKey.new, const []);
|
||||
final mediaOffset = useMessageMediaOffset(message.content, message.tags);
|
||||
final pk = message.pubkey.toLowerCase();
|
||||
final profile =
|
||||
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
|
||||
@@ -127,249 +128,277 @@ class _ThreadMessage extends HookConsumerWidget {
|
||||
alpha: _landingHighlightOpacity * highlightProgress,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0),
|
||||
child: DecoratedBox(
|
||||
key: ValueKey('thread-message-${message.id}'),
|
||||
decoration: BoxDecoration(
|
||||
color: highlightColor,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
// The media carousel intentionally continues through the list's
|
||||
// trailing gutter. InkWell still clips its ink to [borderRadius],
|
||||
// while leaving overflowing message content visible.
|
||||
clipBehavior: Clip.none,
|
||||
child: MessageLongPressInkWell(
|
||||
key: ValueKey('thread-message-row-${message.id}'),
|
||||
onLongPressDetails: openMessageActions,
|
||||
return NotificationListener<MessageGalleryScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
mediaOffset.value = notification.offset;
|
||||
return true;
|
||||
},
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0),
|
||||
child: DecoratedBox(
|
||||
key: ValueKey('thread-message-${message.id}'),
|
||||
decoration: BoxDecoration(
|
||||
color: highlightColor,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
highlightColor: context.colors.primary.withValues(alpha: 0.1),
|
||||
snapshotKey: messageSnapshotKey,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: showAvatar ? Grid.quarter : Grid.quarter / 2,
|
||||
),
|
||||
child: MessageFooterLayout(
|
||||
animateSize: true,
|
||||
overlap: message.reactions.isNotEmpty ? Grid.xs : 0,
|
||||
footer: (isThreadHead || message.reactions.isNotEmpty)
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left:
|
||||
messageAvatarSize +
|
||||
messageAvatarContentGap +
|
||||
Grid.xxs,
|
||||
),
|
||||
child: ReactionRow(
|
||||
combined: true,
|
||||
messageId: message.id,
|
||||
channelId: channelId,
|
||||
reactions: message.reactions,
|
||||
onToggle: (emoji) =>
|
||||
toggleReaction(ref, message, emoji),
|
||||
showAddButton: isMember && !isArchived,
|
||||
onAddReaction: () => showAddReactionPicker(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
// The media carousel intentionally continues through the list's
|
||||
// trailing gutter. InkWell still clips its ink to [borderRadius],
|
||||
// while leaving overflowing message content visible.
|
||||
clipBehavior: Clip.none,
|
||||
child: MessageLongPressInkWell(
|
||||
key: ValueKey('thread-message-row-${message.id}'),
|
||||
onLongPressDetails: openMessageActions,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
highlightColor: context.colors.primary.withValues(alpha: 0.1),
|
||||
snapshotKey: messageSnapshotKey,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: showAvatar ? Grid.quarter : Grid.quarter / 2,
|
||||
),
|
||||
child: MessageFooterLayout(
|
||||
animateSize: true,
|
||||
overlap: message.reactions.isNotEmpty ? Grid.xs : 0,
|
||||
footer: (isThreadHead || message.reactions.isNotEmpty)
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left:
|
||||
messageAvatarSize +
|
||||
messageAvatarContentGap +
|
||||
Grid.xxs,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
child: RepaintBoundary(
|
||||
key: messageSnapshotKey,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
const SizedBox(width: messageAvatarSize),
|
||||
const SizedBox(width: messageAvatarContentGap),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: showAuthor ? Grid.half : 0,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showAuthor)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: Grid.quarter,
|
||||
left: Grid.xs,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MessageAuthorMeta(
|
||||
showTimestamp: true,
|
||||
nameStyle:
|
||||
messageUsernameTextStyle
|
||||
.copyWith(
|
||||
fontSize: 13.1,
|
||||
height: 17 / 13.1,
|
||||
),
|
||||
metadataStyle:
|
||||
messageMetadataTextStyle
|
||||
.copyWith(
|
||||
fontSize: 13.1,
|
||||
height: 17 / 13.1,
|
||||
),
|
||||
timestampStyle:
|
||||
messageTimestampTextStyle
|
||||
.copyWith(
|
||||
fontSize: 11,
|
||||
height: 17 / 11,
|
||||
),
|
||||
displayName: displayName,
|
||||
username: messageUsernameLabel(
|
||||
profile,
|
||||
),
|
||||
timestamp: formatMessageTime(
|
||||
message.createdAt,
|
||||
),
|
||||
nameColor:
|
||||
context.colors.onSurface,
|
||||
metadataColor: context
|
||||
.colors
|
||||
.onSurfaceVariant,
|
||||
onAuthorTap: () =>
|
||||
showUserProfileSheet(
|
||||
context,
|
||||
message.pubkey,
|
||||
names:
|
||||
channelIdentityNamesProvider(
|
||||
channelId,
|
||||
child: ReactionRow(
|
||||
combined: true,
|
||||
messageId: message.id,
|
||||
channelId: channelId,
|
||||
reactions: message.reactions,
|
||||
onToggle: (emoji) =>
|
||||
toggleReaction(ref, message, emoji),
|
||||
showAddButton: isMember && !isArchived,
|
||||
onAddReaction: () => showAddReactionPicker(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
child: RepaintBoundary(
|
||||
key: messageSnapshotKey,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
const SizedBox(width: messageAvatarSize),
|
||||
const SizedBox(width: messageAvatarContentGap),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: showAuthor ? Grid.half : 0,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showAuthor)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: Grid.quarter,
|
||||
left: Grid.xs,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MessageAuthorMeta(
|
||||
showTimestamp: true,
|
||||
nameStyle:
|
||||
messageUsernameTextStyle
|
||||
.copyWith(
|
||||
fontSize: 13.1,
|
||||
height: 17 / 13.1,
|
||||
),
|
||||
),
|
||||
displayNameKey: ValueKey(
|
||||
'thread-message-author-${message.id}',
|
||||
),
|
||||
usernameKey: ValueKey(
|
||||
'thread-message-username-${message.id}',
|
||||
),
|
||||
timestampKey: ValueKey(
|
||||
'thread-message-timestamp-${message.id}',
|
||||
metadataStyle:
|
||||
messageMetadataTextStyle
|
||||
.copyWith(
|
||||
fontSize: 13.1,
|
||||
height: 17 / 13.1,
|
||||
),
|
||||
timestampStyle:
|
||||
messageTimestampTextStyle
|
||||
.copyWith(
|
||||
fontSize: 11,
|
||||
height: 17 / 11,
|
||||
),
|
||||
displayName: displayName,
|
||||
username: messageUsernameLabel(
|
||||
profile,
|
||||
),
|
||||
timestamp: formatMessageTime(
|
||||
message.createdAt,
|
||||
),
|
||||
nameColor:
|
||||
context.colors.onSurface,
|
||||
metadataColor: context
|
||||
.colors
|
||||
.onSurfaceVariant,
|
||||
onAuthorTap: () =>
|
||||
showUserProfileSheet(
|
||||
context,
|
||||
message.pubkey,
|
||||
names:
|
||||
channelIdentityNamesProvider(
|
||||
channelId,
|
||||
),
|
||||
),
|
||||
displayNameKey: ValueKey(
|
||||
'thread-message-author-${message.id}',
|
||||
),
|
||||
usernameKey: ValueKey(
|
||||
'thread-message-username-${message.id}',
|
||||
),
|
||||
timestampKey: ValueKey(
|
||||
'thread-message-timestamp-${message.id}',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (message.edited) ...[
|
||||
const SizedBox(width: Grid.half),
|
||||
Text(
|
||||
'(edited)',
|
||||
style: context
|
||||
.textTheme
|
||||
.labelSmall
|
||||
?.copyWith(
|
||||
color: context
|
||||
.colors
|
||||
.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
MessageContent(
|
||||
content: message.content,
|
||||
bubble: true,
|
||||
hasReactions: message.reactions.isNotEmpty,
|
||||
bubbleEnd: showAvatar,
|
||||
bubbleStart: showAuthor,
|
||||
ownMessage:
|
||||
currentPubkey?.toLowerCase() == pk,
|
||||
mentionNames: resolvedMentionNames,
|
||||
mentionLabels: mentionLabels,
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
channelNames: channelNames,
|
||||
tags: message.tags,
|
||||
baseStyle: messageBodyTextStyle.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
scaleEmojiOnly: true,
|
||||
mediaCarouselTrailingOverflow: Grid.gutter,
|
||||
onMediaReply: allMessages == null
|
||||
? null
|
||||
: () {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) =>
|
||||
ThreadDetailPage(
|
||||
threadHead: message,
|
||||
allMessages: allMessages!,
|
||||
channelId: channelId,
|
||||
currentPubkey:
|
||||
currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
if (message.edited) ...[
|
||||
const SizedBox(width: Grid.half),
|
||||
Text(
|
||||
'(edited)',
|
||||
style: context
|
||||
.textTheme
|
||||
.labelSmall
|
||||
?.copyWith(
|
||||
color: context
|
||||
.colors
|
||||
.onSurfaceVariant,
|
||||
fontStyle:
|
||||
FontStyle.italic,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onMediaMore: (viewerContext, imageUrl) =>
|
||||
showImageActions(
|
||||
context: viewerContext,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
imageUrl: imageUrl,
|
||||
canManageMessage: canManageMessage,
|
||||
onDeleted: () {
|
||||
if (viewerContext.mounted) {
|
||||
Navigator.of(
|
||||
viewerContext,
|
||||
).maybePop();
|
||||
}
|
||||
},
|
||||
],
|
||||
],
|
||||
),
|
||||
onChannelTap: (targetChannelId) {
|
||||
openChannelLink(
|
||||
context: context,
|
||||
ref: ref,
|
||||
channelId: targetChannelId,
|
||||
currentChannelId: channelId,
|
||||
);
|
||||
},
|
||||
onMentionTap: (pubkey) =>
|
||||
showUserProfileSheet(
|
||||
context,
|
||||
pubkey,
|
||||
names: channelIdentityNamesProvider(
|
||||
channelId,
|
||||
),
|
||||
MessageContent(
|
||||
content: message.content,
|
||||
bubble: true,
|
||||
hasReactions:
|
||||
message.reactions.isNotEmpty,
|
||||
bubbleEnd: showAvatar,
|
||||
bubbleStart: showAuthor,
|
||||
ownMessage:
|
||||
currentPubkey?.toLowerCase() == pk,
|
||||
mentionNames: resolvedMentionNames,
|
||||
mentionLabels: mentionLabels,
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
channelNames: channelNames,
|
||||
tags: message.tags,
|
||||
baseStyle: messageBodyTextStyle.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
scaleEmojiOnly: true,
|
||||
mediaCarouselTrailingOverflow:
|
||||
Grid.gutter,
|
||||
onMediaReply: allMessages == null
|
||||
? null
|
||||
: () {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) =>
|
||||
ThreadDetailPage(
|
||||
threadHead: message,
|
||||
allMessages:
|
||||
allMessages!,
|
||||
channelId: channelId,
|
||||
currentPubkey:
|
||||
currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onMediaMore: (viewerContext, imageUrl) =>
|
||||
showImageActions(
|
||||
context: viewerContext,
|
||||
ref: ref,
|
||||
message: message,
|
||||
channelId: channelId,
|
||||
imageUrl: imageUrl,
|
||||
canManageMessage: canManageMessage,
|
||||
onDeleted: () {
|
||||
if (viewerContext.mounted) {
|
||||
Navigator.of(
|
||||
viewerContext,
|
||||
).maybePop();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
onChannelTap: (targetChannelId) {
|
||||
openChannelLink(
|
||||
context: context,
|
||||
ref: ref,
|
||||
channelId: targetChannelId,
|
||||
currentChannelId: channelId,
|
||||
);
|
||||
},
|
||||
onMentionTap: (pubkey) =>
|
||||
showUserProfileSheet(
|
||||
context,
|
||||
pubkey,
|
||||
names: channelIdentityNamesProvider(
|
||||
channelId,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (showAvatar)
|
||||
Positioned(
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
child: MessageMediaAvatar(
|
||||
offset: mediaOffset,
|
||||
child: Semantics(
|
||||
container: true,
|
||||
label: 'Open $displayName profile',
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
onTap: () => showUserProfileSheet(
|
||||
context,
|
||||
message.pubkey,
|
||||
names: channelIdentityNamesProvider(
|
||||
channelId,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: GestureDetector(
|
||||
onTap: () => showUserProfileSheet(
|
||||
context,
|
||||
message.pubkey,
|
||||
names: channelIdentityNamesProvider(
|
||||
channelId,
|
||||
),
|
||||
),
|
||||
child: _Avatar(
|
||||
profile: profile,
|
||||
pubkey: message.pubkey,
|
||||
isAgent: isAgent,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (showAvatar)
|
||||
Positioned(
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
child: GestureDetector(
|
||||
onTap: () => showUserProfileSheet(
|
||||
context,
|
||||
message.pubkey,
|
||||
names: channelIdentityNamesProvider(channelId),
|
||||
),
|
||||
child: _Avatar(
|
||||
profile: profile,
|
||||
pubkey: message.pubkey,
|
||||
isAgent: isAgent,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -89,7 +89,9 @@ class SystemEvent {
|
||||
try {
|
||||
final content = jsonDecode(event.content);
|
||||
if (content is Map) {
|
||||
ephemeralChannelId = _readString(content, 'ephemeral_channel_id');
|
||||
ephemeralChannelId = _canonicalHuddleRoomId(
|
||||
content['ephemeral_channel_id'],
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// The row still renders, but joining requires the validated room ID.
|
||||
@@ -787,3 +789,24 @@ String? _readString(Map<dynamic, dynamic> json, String key) {
|
||||
final value = json[key];
|
||||
return value is String ? value : null;
|
||||
}
|
||||
|
||||
// Match Rust Uuid::parse_str: simple, hyphenated, URN and braced forms.
|
||||
String? _canonicalHuddleRoomId(Object? value) {
|
||||
if (value is! String) return null;
|
||||
var text = value;
|
||||
if (text.length == 45 && text.startsWith('urn:uuid:')) {
|
||||
text = text.substring(9);
|
||||
} else if (text.length == 38 && text.startsWith('{') && text.endsWith('}')) {
|
||||
text = text.substring(1, 37);
|
||||
}
|
||||
if (RegExp(r'^[0-9a-fA-F]{32}$').hasMatch(text)) {
|
||||
text =
|
||||
'${text.substring(0, 8)}-${text.substring(8, 12)}-${text.substring(12, 16)}-${text.substring(16, 20)}-${text.substring(20)}';
|
||||
}
|
||||
if (!RegExp(
|
||||
r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$',
|
||||
).hasMatch(text)) {
|
||||
return null;
|
||||
}
|
||||
return text.toLowerCase();
|
||||
}
|
||||
|
||||
27
mobile/lib/shared/widgets/buzz_agent_icon.dart
Normal file
27
mobile/lib/shared/widgets/buzz_agent_icon.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
/// The desktop design system's soft, wide robot mark.
|
||||
class BuzzAgentIcon extends StatelessWidget {
|
||||
const BuzzAgentIcon({super.key, required this.size, required this.color});
|
||||
|
||||
final double size;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SvgPicture.string(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" '
|
||||
'fill="none" stroke="currentColor" stroke-width="2" '
|
||||
'stroke-linecap="round" stroke-linejoin="round">'
|
||||
'<rect x="4.75" y="7" width="14.5" height="13.25" rx="4.5"/>'
|
||||
'<path d="M12 7V2.5M3.25 11.5v4.25M20.75 11.5v4.25"/>'
|
||||
'<g fill="currentColor" stroke="none">'
|
||||
'<circle cx="9" cy="13.25" r="1.4"/>'
|
||||
'<circle cx="15" cy="13.25" r="1.4"/>'
|
||||
'</g></svg>',
|
||||
width: size,
|
||||
height: size,
|
||||
colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
|
||||
excludeFromSemantics: true,
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Shared conversation corners for media, voice notes, and huddle cards.
|
||||
const conversationAttachmentCorners = BorderRadius.all(
|
||||
Radius.circular(Radii.container),
|
||||
);
|
||||
|
||||
/// Shares conversation corner geometry with attachment surfaces.
|
||||
class MessageAttachmentCorners extends InheritedWidget {
|
||||
final BorderRadius borderRadius;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'package:flutter/semantics.dart';
|
||||
import 'package:buzz/features/channels/message_media_avatar.dart';
|
||||
import 'dart:async';
|
||||
import 'package:buzz/shared/push/push_bridge.dart';
|
||||
import 'package:buzz/features/channels/channel_actions_sheet.dart';
|
||||
@@ -88,6 +90,7 @@ part 'channel_detail_page_test/loading_review_tests.dart';
|
||||
part 'channel_detail_page_test/presence_tests.dart';
|
||||
part 'channel_detail_page_test/action_row_tests.dart';
|
||||
part 'channel_detail_page_test/bubble_tests.dart';
|
||||
part 'channel_detail_page_test/review_avatar_tests.dart';
|
||||
|
||||
const _channelId = '11111111-2222-4333-8444-555555555555';
|
||||
const _huddleChannelId = '8d764100-fd8f-44cf-9c98-6d8fbd739b8c';
|
||||
@@ -518,6 +521,7 @@ void main() {
|
||||
presenceTests();
|
||||
actionRowTests();
|
||||
bubbleLayoutTests();
|
||||
reviewAvatarTests();
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
_testPrefs = await SharedPreferences.getInstance();
|
||||
@@ -2162,6 +2166,60 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('hidden huddle end does not block bottom catch-up', (
|
||||
tester,
|
||||
) async {
|
||||
final readState = _SynchronousReadStateNotifier(
|
||||
const ReadStateState(
|
||||
isReady: true,
|
||||
pubkey: 'self',
|
||||
contexts: {},
|
||||
version: 0,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [
|
||||
_huddleMsg(
|
||||
id: 'represented-start',
|
||||
kind: EventKind.huddleStarted,
|
||||
createdAt: 900,
|
||||
),
|
||||
_textMsg(
|
||||
id: 'msg1',
|
||||
pubkey: 'alice',
|
||||
content: 'First',
|
||||
createdAt: 1100,
|
||||
),
|
||||
_textMsg(
|
||||
id: 'msg2',
|
||||
pubkey: 'alice',
|
||||
content: 'Latest',
|
||||
createdAt: 1200,
|
||||
),
|
||||
_huddleMsg(
|
||||
id: 'hidden-end',
|
||||
kind: EventKind.huddleEnded,
|
||||
createdAt: 1300,
|
||||
),
|
||||
],
|
||||
readStateNotifier: readState,
|
||||
),
|
||||
);
|
||||
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.pump();
|
||||
// Opening the channel does not read it; holding still at the bottom
|
||||
// does, without moving the channel mark.
|
||||
expect(readState.markedContexts, isEmpty);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(readState.markedContexts, {'activity:$_channelId': 1200});
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('reading the bottom ends a manual channel unread only', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -4132,9 +4190,55 @@ void main() {
|
||||
final carouselRect = tester.getRect(carousel);
|
||||
final imageCountRect = tester.getRect(imageCount);
|
||||
|
||||
expect(carouselRect.left, imageCountRect.left);
|
||||
expect(imageCountRect.left, carouselRect.left + Grid.xs);
|
||||
expect(
|
||||
imageCountRect.left,
|
||||
tester
|
||||
.getRect(find.byKey(const ValueKey('message-author-gallery')))
|
||||
.left,
|
||||
);
|
||||
expect(carouselRect.right, tester.view.physicalSize.width);
|
||||
expect(carouselRect.top - imageCountRect.bottom, Grid.half + 2);
|
||||
final galleryAvatar = find.descendant(
|
||||
of: find.byKey(const ValueKey('message-row-gallery')),
|
||||
matching: find.byType(MessageMediaAvatar),
|
||||
);
|
||||
double avatarOpacity() => tester
|
||||
.widget<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
|
||||
.ancestor(
|
||||
@@ -6216,7 +6320,7 @@ void main() {
|
||||
expect(createdAction, findsOneWidget);
|
||||
expect(find.byType(CircleAvatar), findsNothing);
|
||||
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(
|
||||
find.byKey(const ValueKey('system-message-username-alice')),
|
||||
@@ -6229,10 +6333,49 @@ void main() {
|
||||
final createdText = tester.widget<RichText>(createdAction);
|
||||
expect(
|
||||
effectiveFontSizeForText(createdText.text, 'created this channel'),
|
||||
14,
|
||||
12,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'huddle reactions attach to the card and open the shared tray',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [
|
||||
_huddleMsg(id: 'huddle-tray', kind: EventKind.huddleStarted),
|
||||
_reaction(id: 'huddle-reaction', targetId: 'huddle-tray'),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final tray = find.byKey(const ValueKey('combined-reaction-tray'));
|
||||
final card = find.byKey(const ValueKey('huddle-card-huddle-tray'));
|
||||
expect(tray, findsOneWidget);
|
||||
final avatar = find.descendant(
|
||||
of: find.byKey(const ValueKey('huddle-activity-huddle-tray')),
|
||||
matching: find.byType(AvatarImage),
|
||||
);
|
||||
expect(avatar, findsOneWidget);
|
||||
expect(
|
||||
tester.getRect(avatar).bottom,
|
||||
closeTo(tester.getRect(card).bottom, 0.01),
|
||||
);
|
||||
|
||||
expect(tester.getRect(tray).top, lessThan(tester.getRect(card).bottom));
|
||||
expect(
|
||||
tester.getRect(tray).left,
|
||||
greaterThan(tester.getRect(card).left),
|
||||
);
|
||||
await tester.tap(tray);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.byKey(const ValueKey('reaction-details-sheet')),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('renders a compact huddle activity row', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
@@ -6244,9 +6387,20 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Alice'), findsOneWidget);
|
||||
expect(findRichText('started a huddle'), findsOneWidget);
|
||||
expect(find.byType(CircleAvatar), findsNothing);
|
||||
expect(findRichText('started a huddle'), findsNothing);
|
||||
expect(find.text('Huddle ended'), findsOneWidget);
|
||||
final activity = find.byKey(const ValueKey('huddle-activity-huddle-1'));
|
||||
final avatar = find.descendant(
|
||||
of: activity,
|
||||
matching: find.byType(AvatarImage),
|
||||
);
|
||||
expect(avatar, findsOneWidget);
|
||||
expect(
|
||||
tester.getRect(avatar).right,
|
||||
lessThan(tester.getRect(find.text('Huddle ended')).left),
|
||||
);
|
||||
|
||||
expect(find.byType(CircleAvatar), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('system-message-timestamp-alice')),
|
||||
findsNothing,
|
||||
@@ -6305,7 +6459,7 @@ void main() {
|
||||
testWidgets('disables a different Huddle card during an active call', (
|
||||
tester,
|
||||
) async {
|
||||
const otherHuddleChannelId = 'other-huddle-channel';
|
||||
const otherHuddleChannelId = '9d764100-fd8f-44cf-9c98-6d8fbd739b8c';
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
@@ -9066,11 +9220,17 @@ void main() {
|
||||
(kind: EventKind.huddleEnded, action: 'ended the huddle'),
|
||||
]) {
|
||||
testWidgets(
|
||||
'${huddleEvent.action} retains its system layout beside bubble messages',
|
||||
'${huddleEvent.action} is represented by its card without a notice',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [
|
||||
if (huddleEvent.kind == EventKind.huddleEnded)
|
||||
_huddleMsg(
|
||||
id: 'represented-start',
|
||||
kind: EventKind.huddleStarted,
|
||||
createdAt: 900,
|
||||
),
|
||||
_textMsg(
|
||||
id: 'regular-message',
|
||||
pubkey: 'alice',
|
||||
@@ -9101,22 +9261,20 @@ void main() {
|
||||
final huddleRow = find.byKey(
|
||||
const ValueKey('system-message-row-huddle-message'),
|
||||
);
|
||||
final regularAvatar = tester.getRect(
|
||||
find
|
||||
.descendant(of: regularRow, matching: find.byType(CircleAvatar))
|
||||
.first,
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: huddleRow, matching: find.byType(CircleAvatar)),
|
||||
findsNothing,
|
||||
);
|
||||
final huddleAuthor = tester.getRect(
|
||||
find.byKey(const ValueKey('system-message-author-bob')),
|
||||
);
|
||||
final huddleBody = tester.getRect(findRichText(huddleEvent.action));
|
||||
expect(huddleAuthor.left, greaterThanOrEqualTo(huddleBody.left));
|
||||
expect(huddleBody.top, greaterThan(regularAvatar.bottom));
|
||||
expect(huddleAuthor.top, greaterThanOrEqualTo(huddleBody.top));
|
||||
expect(regularRow, findsOneWidget);
|
||||
expect(findRichText(huddleEvent.action), findsNothing);
|
||||
if (huddleEvent.kind == EventKind.huddleStarted) {
|
||||
expect(find.text('Huddle ended'), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: huddleRow,
|
||||
matching: find.byType(AvatarImage),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
} else {
|
||||
expect(huddleRow, findsNothing);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -9173,16 +9331,12 @@ void main() {
|
||||
'message-row-message-alice',
|
||||
'message-row-message-bob',
|
||||
'system-message-row-membership-carol',
|
||||
'system-message-row-huddle-dave',
|
||||
'message-row-message-erin',
|
||||
].map((key) => tester.getRect(find.byKey(ValueKey(key)))).toList();
|
||||
for (var index = 1; index < rows.length; index++) {
|
||||
expect(rows[index].top, greaterThanOrEqualTo(rows[index - 1].bottom));
|
||||
}
|
||||
for (final key in [
|
||||
'system-message-row-membership-carol',
|
||||
'system-message-row-huddle-dave',
|
||||
]) {
|
||||
for (final key in ['system-message-row-membership-carol']) {
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(ValueKey(key)),
|
||||
@@ -9449,32 +9603,78 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('opens a profile sheet from a huddle activity name', (
|
||||
testWidgets('malformed huddle start keeps its fallback activity row', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [
|
||||
const NostrEvent(
|
||||
id: 'malformed-huddle',
|
||||
pubkey: 'alice',
|
||||
createdAt: 1000,
|
||||
kind: EventKind.huddleStarted,
|
||||
tags: [
|
||||
['h', _channelId],
|
||||
],
|
||||
content: '{}',
|
||||
sig: '',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.byKey(const ValueKey('activity-card-malformed-huddle')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('huddle-activity-malformed-huddle')),
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('opens a profile sheet from a huddle card avatar', (
|
||||
tester,
|
||||
) async {
|
||||
final semantics = tester.ensureSemantics();
|
||||
final alice = 'a' * 64;
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
messages: [
|
||||
_huddleMsg(
|
||||
id: 'sys-huddle-avatar',
|
||||
kind: EventKind.huddleStarted,
|
||||
pubkey: 'alice',
|
||||
pubkey: alice,
|
||||
),
|
||||
],
|
||||
users: {
|
||||
'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
users: {alice: UserProfile(pubkey: alice, displayName: 'Alice')},
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('system-message-author-alice')),
|
||||
final action = tester.getSemantics(
|
||||
find.bySemanticsLabel('Open Alice profile'),
|
||||
);
|
||||
expect(action.getSemanticsData().flagsCollection.isButton, isTrue);
|
||||
expect(action.getSemanticsData().hasAction(SemanticsAction.tap), isTrue);
|
||||
tester.binding.renderViews.single.owner!.semanticsOwner!.performAction(
|
||||
action.id,
|
||||
SemanticsAction.tap,
|
||||
);
|
||||
semantics.dispose();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Copy public key'), findsOneWidget);
|
||||
expect(find.byType(UserProfileSheet), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(UserProfileSheet),
|
||||
matching: find.text('Alice'),
|
||||
),
|
||||
findsWidgets,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
@@ -9513,9 +9713,7 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('system-message-author-alice')),
|
||||
);
|
||||
await tester.tap(find.byType(AvatarImage).first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(UserProfileSheet), findsOneWidget);
|
||||
@@ -9659,7 +9857,7 @@ void main() {
|
||||
Navigator.of(tester.element(find.byType(UserProfileSheet))).pop();
|
||||
await tester.pumpAndSettle();
|
||||
final addedText = tester.widget<RichText>(addedAction);
|
||||
expect(effectiveFontSizeForText(addedText.text, 'added '), 14);
|
||||
expect(effectiveFontSizeForText(addedText.text, 'added '), 12);
|
||||
});
|
||||
|
||||
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]) {
|
||||
testWidgets('outgoing headings, mentions and links contrast in dark=$dark', (
|
||||
tester,
|
||||
@@ -52,7 +84,9 @@ void bubbleContentTests() {
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final foreground = dark ? Colors.black : Colors.white;
|
||||
final foreground = ChatColors.of(
|
||||
tester.element(find.byType(MessageContent)),
|
||||
).foreground;
|
||||
for (final heading in ['H1', 'H2', 'H3', 'H4', 'H5', 'H6']) {
|
||||
final text = tester.widget<RichText>(_findRich(heading).last);
|
||||
final colors = <Color?>[];
|
||||
@@ -71,16 +105,24 @@ void bubbleContentTests() {
|
||||
inspect(text.text, null);
|
||||
expect(colors, [foreground]);
|
||||
}
|
||||
final linkColor = foreground == Colors.white
|
||||
? foreground
|
||||
: dark
|
||||
? const Color(0xFF83C4FF)
|
||||
: const Color(0xFF0B5FA8);
|
||||
for (final label in ['Alice', 'Helper']) {
|
||||
expect(tester.widget<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>(
|
||||
find.byWidgetPredicate(
|
||||
(w) => w is Text && w.textSpan?.toPlainText() == 'Website',
|
||||
),
|
||||
);
|
||||
expect(link.textSpan?.style?.color, foreground);
|
||||
expect(link.textSpan?.style?.color, linkColor);
|
||||
await tester.tap(find.text('Alice'));
|
||||
expect(tapped, 'person');
|
||||
await tester.tap(find.text('Helper'));
|
||||
@@ -114,34 +156,37 @@ void bubbleContentTests() {
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('image corners follow the conversation group', (tester) async {
|
||||
for (final grouped in [false, true]) {
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
MessageContent(
|
||||
content: '',
|
||||
bubble: true,
|
||||
bubbleStart: !grouped,
|
||||
bubbleEnd: !grouped,
|
||||
testWidgets(
|
||||
'image corners remain uniform throughout the conversation group',
|
||||
(tester) async {
|
||||
for (final grouped in [false, true]) {
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
MessageContent(
|
||||
content: '',
|
||||
bubble: true,
|
||||
bubbleStart: !grouped,
|
||||
bubbleEnd: !grouped,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final frame = tester.widget<Container>(
|
||||
find.byKey(
|
||||
const ValueKey(
|
||||
'message-media-image-preview:https://example.com/photo.png',
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final frame = tester.widget<Container>(
|
||||
find.byKey(
|
||||
const ValueKey(
|
||||
'message-media-image-preview:https://example.com/photo.png',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final corners =
|
||||
(frame.decoration! as BoxDecoration).borderRadius! as BorderRadius;
|
||||
expect(corners.topLeft.x, grouped ? Radii.sm : Radii.container);
|
||||
expect(corners.bottomLeft.x, grouped ? Radii.sm : Radii.container);
|
||||
expect(corners.topRight.x, Radii.container);
|
||||
expect(corners.bottomRight.x, Radii.container);
|
||||
}
|
||||
});
|
||||
);
|
||||
final corners =
|
||||
(frame.decoration! as BoxDecoration).borderRadius! as BorderRadius;
|
||||
expect(corners.topLeft.x, Radii.container);
|
||||
expect(corners.bottomLeft.x, Radii.container);
|
||||
expect(corners.topRight.x, Radii.container);
|
||||
expect(corners.bottomRight.x, Radii.container);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('photo gallery stays outside its caption bubble', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:buzz/shared/widgets/buzz_agent_icon.dart';
|
||||
import 'package:buzz/features/channels/message_content.dart';
|
||||
import 'package:buzz/features/channels/media_viewer_page.dart';
|
||||
import 'package:buzz/shared/custom_emoji/custom_emoji.dart';
|
||||
@@ -9,7 +10,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gpt_markdown/gpt_markdown.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:buzz/shared/theme/buzz_icons.dart';
|
||||
|
||||
class _Fixture {
|
||||
final revision = ValueNotifier(0);
|
||||
@@ -197,7 +197,7 @@ void main() {
|
||||
fixture.agents.add('alice-key');
|
||||
await fixture.refresh(tester);
|
||||
expect(_components(tester), isNot(same(before)));
|
||||
expect(find.byIcon(BuzzIcons.bot), findsOneWidget);
|
||||
expect(find.byType(BuzzAgentIcon), findsOneWidget);
|
||||
before = _components(tester);
|
||||
fixture.mentionHandler = false;
|
||||
fixture.tappedId = null;
|
||||
@@ -210,7 +210,7 @@ void main() {
|
||||
await fixture.refresh(tester);
|
||||
await tester.tap(find.text('ALICE'));
|
||||
expect(fixture.tappedId, 'second-key');
|
||||
expect(find.byIcon(BuzzIcons.bot), findsNothing);
|
||||
expect(find.byType(BuzzAgentIcon), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:buzz/shared/widgets/buzz_agent_icon.dart';
|
||||
import 'package:buzz/features/channels/local_send_bubble_surface.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
@@ -42,6 +43,7 @@ Widget _testable(
|
||||
List<Override> overrides = const [],
|
||||
bool disableAnimations = false,
|
||||
bool dark = false,
|
||||
ThemeData? theme,
|
||||
VideoPreviewFrameLoader? videoPreviewFrameLoader,
|
||||
}) {
|
||||
return ProviderScope(
|
||||
@@ -52,7 +54,7 @@ Widget _testable(
|
||||
...overrides,
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: dark ? AppTheme.dark() : AppTheme.light(),
|
||||
theme: theme ?? (dark ? AppTheme.dark() : AppTheme.light()),
|
||||
// Use MaterialApp.builder so the MediaQuery override (including
|
||||
// disableAnimations) applies to every pushed route, not just the
|
||||
// home scaffold. Navigator-pushed routes (e.g. MediaVideoViewerPage)
|
||||
@@ -2818,7 +2820,7 @@ Photos
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byIcon(BuzzIcons.bot), findsOneWidget);
|
||||
expect(find.byType(BuzzAgentIcon), findsOneWidget);
|
||||
expect(find.text('@'), findsNothing);
|
||||
expect(find.text('Helper Bot'), findsOneWidget);
|
||||
});
|
||||
@@ -2836,7 +2838,7 @@ Photos
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byIcon(BuzzIcons.bot), findsOneWidget);
|
||||
expect(find.byType(BuzzAgentIcon), findsOneWidget);
|
||||
expect(find.text('Helper Bot'), findsOneWidget);
|
||||
expect(_allRichText(tester), isNot(contains('Bot Bot')));
|
||||
});
|
||||
|
||||
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() {
|
||||
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', () {
|
||||
test('the bottom writes one catch-up mark instead of message marks', () {
|
||||
final a = _msg('a', 100);
|
||||
|
||||
@@ -559,6 +559,23 @@ void main() {
|
||||
expect(result[0].systemEvent!.type, SystemEventType.channelCreated);
|
||||
});
|
||||
|
||||
test('huddle room IDs normalize canonical uppercase UUIDs', () {
|
||||
final event = NostrEvent(
|
||||
id: 'upper',
|
||||
pubkey: 'alice',
|
||||
createdAt: 1000,
|
||||
kind: EventKind.huddleStarted,
|
||||
tags: const [],
|
||||
content:
|
||||
'{"ephemeral_channel_id":"8D764100-FD8F-44CF-9C98-6D8FBD739B8C"}',
|
||||
sig: '',
|
||||
);
|
||||
expect(
|
||||
SystemEvent.fromHuddleEvent(event)!.ephemeralChannelId,
|
||||
'8d764100-fd8f-44cf-9c98-6d8fbd739b8c',
|
||||
);
|
||||
});
|
||||
|
||||
test('huddle start and end events render as system rows', () {
|
||||
final result = formatTimeline([
|
||||
_huddleEvent(id: 'h1', kind: EventKind.huddleStarted),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:buzz/shared/widgets/buzz_agent_icon.dart';
|
||||
import 'package:buzz/features/channels/channel.dart';
|
||||
import 'package:buzz/features/channels/channel_management_provider.dart';
|
||||
import 'package:buzz/features/channels/channels_provider.dart';
|
||||
@@ -15,7 +16,6 @@ import 'package:buzz/shared/widgets/frosted_app_bar.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:buzz/shared/theme/buzz_icons.dart';
|
||||
|
||||
import '../../helpers/widget_helpers.dart';
|
||||
|
||||
@@ -1085,7 +1085,7 @@ void main() {
|
||||
);
|
||||
expect(content.mentionNames, {agentPubkey: 'Helper Bot'});
|
||||
expect(content.agentMentionPubkeys, contains(agentPubkey));
|
||||
expect(find.byIcon(BuzzIcons.bot), findsOneWidget);
|
||||
expect(find.byType(BuzzAgentIcon), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('does not label an unjoined channel as having zero members', (
|
||||
|
||||
Reference in New Issue
Block a user