mirror of
https://github.com/block/buzz.git
synced 2026-11-14 05:12:27 +00:00
Prototype conversational message layout
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
use nostr::{Event, EventId, Keys, PublicKey};
|
||||
use nostr::{Event, EventId, Keys, PublicKey, Timestamp};
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
mod forum;
|
||||
@@ -489,6 +489,7 @@ pub async fn send_channel_message(
|
||||
sent_from_thread_tag: Option<Vec<String>>,
|
||||
mention_pubkeys: Option<Vec<String>>,
|
||||
kind: Option<u32>,
|
||||
created_at: Option<u64>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<SendChannelMessageResponse, String> {
|
||||
let channel_uuid = uuid::Uuid::parse_str(&channel_id)
|
||||
@@ -554,6 +555,11 @@ pub async fn send_channel_message(
|
||||
}
|
||||
};
|
||||
|
||||
let event_created_at = created_at.unwrap_or_else(|| Timestamp::now().as_secs());
|
||||
let response_created_at = i64::try_from(event_created_at)
|
||||
.map_err(|_| "message timestamp exceeds the supported range".to_string())?;
|
||||
let builder = builder.custom_created_at(Timestamp::from(event_created_at));
|
||||
|
||||
let result = submit_event(builder, &state).await?;
|
||||
|
||||
let depth = match (&parent_event_id, &resolved_root) {
|
||||
@@ -568,7 +574,7 @@ pub async fn send_channel_message(
|
||||
root_event_id: resolved_root,
|
||||
parent_event_id,
|
||||
depth,
|
||||
created_at: chrono::Utc::now().timestamp(),
|
||||
created_at: response_created_at,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -303,6 +303,11 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
mentionPubkeys: string[],
|
||||
mediaTags?: string[][],
|
||||
channelId?: string | null,
|
||||
threadContext?: {
|
||||
parentEventId: string | null;
|
||||
threadHeadId: string | null;
|
||||
} | null,
|
||||
createdAt?: number,
|
||||
) => {
|
||||
const shouldCompleteWelcomeBanner =
|
||||
isActiveWelcomeChannel &&
|
||||
@@ -310,7 +315,14 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
mentionsKnownAgent(mentionPubkeys, knownAgentPubkeys));
|
||||
|
||||
messageTimelineRef.current?.scrollToBottomOnNextUpdate();
|
||||
await onSendMessage(content, mentionPubkeys, mediaTags, channelId);
|
||||
await onSendMessage(
|
||||
content,
|
||||
mentionPubkeys,
|
||||
mediaTags,
|
||||
channelId,
|
||||
threadContext,
|
||||
createdAt,
|
||||
);
|
||||
|
||||
if (
|
||||
channelId &&
|
||||
|
||||
@@ -106,6 +106,11 @@ export type ChannelPaneProps = {
|
||||
mentionPubkeys: string[],
|
||||
mediaTags?: string[][],
|
||||
channelId?: string | null,
|
||||
threadContext?: {
|
||||
parentEventId: string | null;
|
||||
threadHeadId: string | null;
|
||||
} | null,
|
||||
createdAt?: number,
|
||||
) => Promise<void>;
|
||||
onSendToChannel: (
|
||||
message: TimelineMessage,
|
||||
@@ -128,6 +133,7 @@ export type ChannelPaneProps = {
|
||||
parentEventId: string | null;
|
||||
threadHeadId: string | null;
|
||||
} | null,
|
||||
createdAt?: number,
|
||||
) => Promise<void>;
|
||||
onTargetReached?: (messageId: string) => void;
|
||||
onToggleReaction?: (
|
||||
|
||||
@@ -284,12 +284,18 @@ export function useChannelPaneHandlers({
|
||||
mentionPubkeys: string[],
|
||||
mediaTags?: string[][],
|
||||
channelId?: string | null,
|
||||
_threadContext?: {
|
||||
parentEventId: string | null;
|
||||
threadHeadId: string | null;
|
||||
} | null,
|
||||
createdAt?: number,
|
||||
) => {
|
||||
await sendMutateRef.current({
|
||||
content,
|
||||
mentionPubkeys,
|
||||
mediaTags,
|
||||
channelId: channelId ?? undefined,
|
||||
createdAt,
|
||||
});
|
||||
},
|
||||
[],
|
||||
@@ -327,6 +333,7 @@ export function useChannelPaneHandlers({
|
||||
parentEventId: string | null;
|
||||
threadHeadId: string | null;
|
||||
} | null,
|
||||
createdAt?: number,
|
||||
) => {
|
||||
// Resolve target using captured submit-time context (race-free) or live
|
||||
// refs (legacy path). When threadContext is supplied, no live-ref reads
|
||||
@@ -359,6 +366,7 @@ export function useChannelPaneHandlers({
|
||||
parentEventId,
|
||||
mediaTags,
|
||||
channelId: channelId ?? undefined,
|
||||
createdAt,
|
||||
});
|
||||
|
||||
// Only update thread UI state if the user is still viewing the same
|
||||
|
||||
@@ -111,9 +111,8 @@ export function HomeView({
|
||||
homeInboxWidthPx < INBOX_SINGLE_COLUMN_BREAKPOINT_PX;
|
||||
const [filter, setFilter] = React.useState<InboxFilter>("all");
|
||||
const [unreadOnly, setUnreadOnly] = React.useState(false);
|
||||
// Explicit selections are mirrored to the URL (`?item=`), so back/forward
|
||||
// restores the detail pane each history entry was showing and reloads
|
||||
// restore it from the URL. Default/automatic selection stays local-only —
|
||||
// Explicit selections are mirrored to `?item=`, so navigation and reloads
|
||||
// restore the detail pane. Default/automatic selection stays local-only —
|
||||
// background data loads must never trigger navigations.
|
||||
const { applyPatch: applyInboxSearchPatch, values: inboxSearchValues } =
|
||||
useHistorySearchState(INBOX_SEARCH_KEYS);
|
||||
@@ -834,6 +833,7 @@ export function HomeView({
|
||||
mediaTags,
|
||||
mentionPubkeys,
|
||||
parentEventId,
|
||||
createdAt,
|
||||
}) => {
|
||||
const channelId = selectedItem?.item.channelId;
|
||||
if (!selectedItem || !channelId || !canReply) {
|
||||
@@ -857,6 +857,7 @@ export function HomeView({
|
||||
undefined,
|
||||
emojiTags,
|
||||
mentionTags,
|
||||
...([undefined, undefined, createdAt] as const),
|
||||
);
|
||||
const authorPubkey = currentPubkey ?? itemToReply.item.pubkey;
|
||||
const reply: InboxReply = {
|
||||
|
||||
@@ -122,6 +122,7 @@ type InboxDetailPaneProps = {
|
||||
mediaTags?: string[][];
|
||||
mentionPubkeys: string[];
|
||||
parentEventId: string | null;
|
||||
createdAt?: number;
|
||||
}) => Promise<void>;
|
||||
onToggleReaction?: (
|
||||
message: TimelineMessage,
|
||||
@@ -678,6 +679,7 @@ function InboxMessageDetailPane({
|
||||
agentPubkeys={agentPubkeys}
|
||||
canReply={canReply}
|
||||
channelId={item.item.channelId}
|
||||
currentPubkey={currentPubkey}
|
||||
isContinuation={isContinuation}
|
||||
isFollowedByContinuation={isFollowedByContinuation}
|
||||
isFirst={index === 0}
|
||||
@@ -769,9 +771,17 @@ function InboxMessageDetailPane({
|
||||
});
|
||||
onEditTargetChange(null);
|
||||
}}
|
||||
onSend={(content, mentionPubkeys, mediaTags) =>
|
||||
onSend={(
|
||||
content,
|
||||
mentionPubkeys,
|
||||
mediaTags,
|
||||
_channelId,
|
||||
_threadContext,
|
||||
createdAt,
|
||||
) =>
|
||||
onSendReply({
|
||||
content,
|
||||
createdAt,
|
||||
mediaTags,
|
||||
mentionPubkeys,
|
||||
parentEventId: composerParentEventId,
|
||||
|
||||
@@ -8,6 +8,10 @@ import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAuthPubkey";
|
||||
import { MessageActionBar } from "@/features/messages/ui/MessageActionBar";
|
||||
import { MessageAgentOwner } from "@/features/messages/ui/MessageAgentOwner";
|
||||
import {
|
||||
MessageBubbleStatusMetadata,
|
||||
MessageHoverTimestamp,
|
||||
} from "@/features/messages/ui/MessageBubbleMetadata";
|
||||
import {
|
||||
MessageAuthorText,
|
||||
MessageHeaderRow,
|
||||
@@ -17,6 +21,7 @@ import {
|
||||
getMessageBubbleBottomPaddingClass,
|
||||
getMessageBubbleLayout,
|
||||
MESSAGE_BUBBLE_CONTENT_PADDING_CLASSES,
|
||||
MESSAGE_BUBBLE_MAX_WIDTH_CLASSES,
|
||||
} from "@/features/messages/ui/messageBubbleLayout";
|
||||
import { UnreadDivider } from "@/features/messages/ui/UnreadDivider";
|
||||
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
|
||||
@@ -24,6 +29,7 @@ import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji";
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { useMessageStyle } from "@/shared/lib/messageStylePreference";
|
||||
import { useOwnMessageAlignment } from "@/shared/lib/ownMessageAlignmentPreference";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { hasLinkPreviewSuppression } from "@/features/messages/lib/formatTimelineMessages";
|
||||
@@ -38,6 +44,7 @@ type InboxMessageRowProps = {
|
||||
canReply: boolean;
|
||||
/** Channel UUID for "Copy link" — passed straight through to MessageActionBar. */
|
||||
channelId?: string | null;
|
||||
currentPubkey?: string;
|
||||
isContinuation?: boolean;
|
||||
isFollowedByContinuation?: boolean;
|
||||
isFirst?: boolean;
|
||||
@@ -57,6 +64,7 @@ export function InboxMessageRow({
|
||||
agentPubkeys,
|
||||
canReply,
|
||||
channelId = null,
|
||||
currentPubkey,
|
||||
isContinuation = false,
|
||||
isFollowedByContinuation = false,
|
||||
isFirst = false,
|
||||
@@ -68,6 +76,14 @@ export function InboxMessageRow({
|
||||
showUnreadBoundary = false,
|
||||
}: InboxMessageRowProps) {
|
||||
const showMessageBubbles = useMessageStyle() === "bubbles";
|
||||
const ownMessageAlignment = useOwnMessageAlignment();
|
||||
const useGroupedBubbleLayout = showMessageBubbles;
|
||||
const isOwnMessage = Boolean(
|
||||
currentPubkey &&
|
||||
normalizePubkey(message.authorPubkey) === normalizePubkey(currentPubkey),
|
||||
);
|
||||
const alignOwnMessageRight =
|
||||
useGroupedBubbleLayout && ownMessageAlignment === "right" && isOwnMessage;
|
||||
const timelineMessage = React.useMemo(
|
||||
() => toTimelineMessage(message),
|
||||
[message],
|
||||
@@ -108,21 +124,31 @@ export function InboxMessageRow({
|
||||
const { radiusClass, rowSpacingClass } = getMessageBubbleLayout(
|
||||
isContinuation,
|
||||
isFollowedByContinuation,
|
||||
alignOwnMessageRight ? "right" : "left",
|
||||
);
|
||||
const hoverTimestampNode = useGroupedBubbleLayout ? (
|
||||
<MessageHoverTimestamp
|
||||
createdAt={timelineMessage.createdAt}
|
||||
time={timelineMessage.time}
|
||||
/>
|
||||
) : null;
|
||||
const actionBarNode =
|
||||
canReply || canToggleReactions || onEdit ? (
|
||||
canReply || canToggleReactions || onEdit || useGroupedBubbleLayout ? (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-1 z-10",
|
||||
showMessageBubbles ? "right-0" : "right-2",
|
||||
"absolute z-10 flex items-center gap-2",
|
||||
showMessageBubbles && "text-foreground",
|
||||
showMessageBubbles
|
||||
? isFirst
|
||||
? "sm:top-3"
|
||||
: "sm:top-2 sm:-translate-y-1/2"
|
||||
: !isFirst && "sm:top-0 sm:-translate-y-1/2",
|
||||
? cn(
|
||||
"top-1/2 -translate-y-1/2",
|
||||
alignOwnMessageRight ? "right-full mr-3" : "left-full ml-3",
|
||||
)
|
||||
: cn("right-2 top-1", !isFirst && "sm:top-0 sm:-translate-y-1/2"),
|
||||
)}
|
||||
>
|
||||
{alignOwnMessageRight ? hoverTimestampNode : null}
|
||||
<MessageActionBar
|
||||
actionAlign={alignOwnMessageRight ? "end" : "start"}
|
||||
channelId={channelId}
|
||||
message={timelineMessage}
|
||||
onEdit={onEdit ? () => onEdit(message) : undefined}
|
||||
@@ -135,8 +161,37 @@ export function InboxMessageRow({
|
||||
onReply={canReply ? () => onSelectReplyTarget(message) : undefined}
|
||||
reactionErrorMessage={reactionErrorMessage}
|
||||
reactions={reactions}
|
||||
presentation="menu"
|
||||
presentation={showMessageBubbles ? "inline" : "menu"}
|
||||
/>
|
||||
{alignOwnMessageRight ? null : hoverTimestampNode}
|
||||
</div>
|
||||
) : null;
|
||||
const profileAvatarNode = (
|
||||
<UserProfilePopover
|
||||
botIdenticonValue={message.authorLabel}
|
||||
pubkey={message.authorPubkey}
|
||||
role={profileRole}
|
||||
triggerElement="span"
|
||||
>
|
||||
<span className="inline-flex shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<UserAvatar
|
||||
avatarUrl={message.avatarUrl}
|
||||
className="h-9 w-9 shrink-0"
|
||||
displayName={message.authorLabel}
|
||||
size="md"
|
||||
/>
|
||||
</span>
|
||||
</UserProfilePopover>
|
||||
);
|
||||
const bubbleAvatarNode =
|
||||
useGroupedBubbleLayout &&
|
||||
!alignOwnMessageRight &&
|
||||
!isFollowedByContinuation ? (
|
||||
<div
|
||||
className="absolute bottom-0 right-full mr-2.5"
|
||||
data-testid="inbox-message-bubble-avatar-anchor"
|
||||
>
|
||||
{profileAvatarNode}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
@@ -161,13 +216,15 @@ export function InboxMessageRow({
|
||||
) : null}
|
||||
<article
|
||||
className={cn(
|
||||
"group/message relative z-10 mx-1 flex gap-2.5 rounded-2xl px-2 transition-colors",
|
||||
"group/message message-hover-region relative z-10 mx-1 flex gap-2.5 rounded-2xl px-2 transition-colors",
|
||||
showMessageBubbles
|
||||
? rowSpacingClass
|
||||
: "py-conversation-row hover:bg-muted/50 focus-within:bg-muted/50",
|
||||
alignOwnMessageRight && "flex-row-reverse",
|
||||
isContinuation ? "items-center" : "items-start",
|
||||
)}
|
||||
data-message-id={message.id}
|
||||
data-own-message={isOwnMessage ? "true" : undefined}
|
||||
data-testid={
|
||||
message.isSelected
|
||||
? "home-inbox-selected-message"
|
||||
@@ -176,9 +233,11 @@ export function InboxMessageRow({
|
||||
>
|
||||
{showMessageBubbles ? null : actionBarNode}
|
||||
|
||||
{isContinuation ? (
|
||||
{alignOwnMessageRight ? null : useGroupedBubbleLayout ? (
|
||||
<div aria-hidden className="w-9 shrink-0" />
|
||||
) : isContinuation ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
aria-hidden={isContinuation}
|
||||
className="flex w-9 shrink-0 self-stretch items-start justify-end pt-0.5"
|
||||
title={message.fullTimestampLabel}
|
||||
>
|
||||
@@ -190,73 +249,91 @@ export function InboxMessageRow({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative shrink-0">
|
||||
<UserProfilePopover
|
||||
botIdenticonValue={message.authorLabel}
|
||||
pubkey={message.authorPubkey}
|
||||
role={profileRole}
|
||||
triggerElement="span"
|
||||
>
|
||||
<span className="inline-flex shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<UserAvatar
|
||||
avatarUrl={message.avatarUrl}
|
||||
className="h-9 w-9 shrink-0"
|
||||
displayName={message.authorLabel}
|
||||
size="md"
|
||||
/>
|
||||
</span>
|
||||
</UserProfilePopover>
|
||||
</div>
|
||||
<div className="relative shrink-0">{profileAvatarNode}</div>
|
||||
)}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
{isContinuation ? null : (
|
||||
<MessageHeaderRow className="gap-x-2">
|
||||
<UserProfilePopover
|
||||
botIdenticonValue={message.authorLabel}
|
||||
pubkey={message.authorPubkey}
|
||||
role={profileRole}
|
||||
triggerElement="span"
|
||||
>
|
||||
<MessageAuthorText className="block max-w-full">
|
||||
{message.authorLabel}
|
||||
</MessageAuthorText>
|
||||
</UserProfilePopover>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-col gap-0.5",
|
||||
alignOwnMessageRight && "items-end",
|
||||
)}
|
||||
>
|
||||
{isContinuation ||
|
||||
(useGroupedBubbleLayout && alignOwnMessageRight) ? null : (
|
||||
<MessageHeaderRow
|
||||
className={cn(
|
||||
"gap-x-2",
|
||||
alignOwnMessageRight && "justify-end text-right",
|
||||
)}
|
||||
>
|
||||
{alignOwnMessageRight ? null : (
|
||||
<UserProfilePopover
|
||||
botIdenticonValue={message.authorLabel}
|
||||
pubkey={message.authorPubkey}
|
||||
role={profileRole}
|
||||
triggerElement="span"
|
||||
>
|
||||
<MessageAuthorText className="block max-w-full">
|
||||
{message.authorLabel}
|
||||
</MessageAuthorText>
|
||||
</UserProfilePopover>
|
||||
)}
|
||||
{message.isAgent ? (
|
||||
<MessageAgentOwner
|
||||
ownerLabel={message.ownerLabel}
|
||||
ownerPubkey={message.ownerPubkey}
|
||||
/>
|
||||
) : null}
|
||||
<p
|
||||
className="shrink-0 text-message-timestamp font-normal tabular-nums text-muted-foreground/55"
|
||||
data-testid="inbox-message-timestamp"
|
||||
>
|
||||
{message.fullTimestampLabel}
|
||||
</p>
|
||||
{useGroupedBubbleLayout ? null : (
|
||||
<p
|
||||
className="shrink-0 text-message-timestamp font-normal tabular-nums text-muted-foreground/55"
|
||||
data-testid="inbox-message-timestamp"
|
||||
>
|
||||
{message.fullTimestampLabel}
|
||||
</p>
|
||||
)}
|
||||
</MessageHeaderRow>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
isContinuation ? "mt-0" : "mt-conversation-body",
|
||||
showMessageBubbles && "w-fit max-w-full",
|
||||
isContinuation || useGroupedBubbleLayout
|
||||
? "mt-0"
|
||||
: "mt-conversation-body",
|
||||
showMessageBubbles && "w-full max-w-full",
|
||||
alignOwnMessageRight && "ml-auto",
|
||||
)}
|
||||
>
|
||||
{showMessageBubbles ? (
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-fit max-w-full bg-muted/50",
|
||||
"relative w-fit",
|
||||
MESSAGE_BUBBLE_MAX_WIDTH_CLASSES,
|
||||
alignOwnMessageRight
|
||||
? "ml-auto bg-primary text-primary-foreground"
|
||||
: "bg-muted/50",
|
||||
MESSAGE_BUBBLE_CONTENT_PADDING_CLASSES,
|
||||
radiusClass,
|
||||
getMessageBubbleBottomPaddingClass(reactions.length > 0),
|
||||
)}
|
||||
data-testid="message-body-surface"
|
||||
>
|
||||
{bubbleAvatarNode}
|
||||
{actionBarNode}
|
||||
{useGroupedBubbleLayout &&
|
||||
(timelineMessage.pending || timelineMessage.edited) ? (
|
||||
<MessageBubbleStatusMetadata
|
||||
edited={timelineMessage.edited}
|
||||
isOwn={alignOwnMessageRight}
|
||||
pending={timelineMessage.pending}
|
||||
/>
|
||||
) : null}
|
||||
<Markdown
|
||||
className={cn(
|
||||
"message-bubble-markdown max-w-full text-left text-message text-foreground",
|
||||
"message-bubble-markdown max-w-full text-left text-message",
|
||||
alignOwnMessageRight
|
||||
? "message-bubble-own text-primary-foreground"
|
||||
: "text-foreground",
|
||||
emojiOnly &&
|
||||
"text-4xl leading-tight [&_p]:leading-tight [&_img[data-custom-emoji]]:h-[1.45em] [&_img[data-custom-emoji]]:align-middle [&_button:has(img[data-custom-emoji])]:align-middle",
|
||||
)}
|
||||
@@ -297,6 +374,7 @@ export function InboxMessageRow({
|
||||
)}
|
||||
<MessageReactions
|
||||
canToggle={canToggleReactions}
|
||||
className={alignOwnMessageRight ? "justify-end" : undefined}
|
||||
messageId={message.id}
|
||||
onSelect={(emoji) => {
|
||||
void handleReactionSelect(emoji);
|
||||
@@ -312,7 +390,12 @@ export function InboxMessageRow({
|
||||
reactions={reactions}
|
||||
/>
|
||||
{reactionErrorMessage ? (
|
||||
<p className="mt-1.5 text-xs text-destructive">
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1.5 text-xs text-destructive",
|
||||
alignOwnMessageRight && "text-right",
|
||||
)}
|
||||
>
|
||||
{reactionErrorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -95,6 +95,7 @@ export function createOptimisticMessage(
|
||||
mediaTags: string[][] = [],
|
||||
sentFromThreadRootId: string | null = null,
|
||||
sentFromThreadRootExcerpt: string | null = null,
|
||||
createdAt?: number,
|
||||
): RelayEvent {
|
||||
const localKey = `optimistic-${crypto.randomUUID()}`;
|
||||
const tags: string[][] = [];
|
||||
@@ -133,7 +134,7 @@ export function createOptimisticMessage(
|
||||
id: localKey,
|
||||
localKey,
|
||||
pubkey: identity.pubkey,
|
||||
created_at: Math.floor(Date.now() / 1_000),
|
||||
created_at: createdAt ?? Math.floor(Date.now() / 1_000),
|
||||
kind: KIND_STREAM_MESSAGE,
|
||||
tags,
|
||||
content,
|
||||
@@ -452,6 +453,7 @@ export function useSendMessageMutation(
|
||||
mediaTags?: string[][];
|
||||
sentFromThreadRootId?: string | null;
|
||||
sentFromThreadRootExcerpt?: string | null;
|
||||
createdAt?: number;
|
||||
},
|
||||
MessageQueryContext | undefined
|
||||
>({
|
||||
@@ -464,6 +466,7 @@ export function useSendMessageMutation(
|
||||
mediaTags,
|
||||
sentFromThreadRootId,
|
||||
sentFromThreadRootExcerpt,
|
||||
createdAt,
|
||||
}) => {
|
||||
// Prefer a channel captured by the caller at compose time. Otherwise,
|
||||
// resolve a captured id from the shared channel cache so navigation
|
||||
@@ -543,6 +546,7 @@ export function useSendMessageMutation(
|
||||
mentionTags,
|
||||
linkPreviewTags,
|
||||
sentFromThreadTag,
|
||||
createdAt,
|
||||
);
|
||||
|
||||
// Build tags matching relay-emitted shape: h, author p, mention ps, reply es, imeta, emoji.
|
||||
@@ -604,6 +608,7 @@ export function useSendMessageMutation(
|
||||
mediaTags,
|
||||
sentFromThreadRootId,
|
||||
sentFromThreadRootExcerpt,
|
||||
createdAt,
|
||||
}) => {
|
||||
// Mirror mutationFn's target resolution so the optimistic message lands
|
||||
// in the cache for the same channel as the real send. A caller-supplied
|
||||
@@ -641,6 +646,7 @@ export function useSendMessageMutation(
|
||||
mediaTags ?? [],
|
||||
sentFromThreadRootId ?? null,
|
||||
sentFromThreadRootExcerpt ?? null,
|
||||
createdAt,
|
||||
);
|
||||
|
||||
const nextWindow = mergeLiveChannelWindowEvent(
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildOutgoingMessageParts } from "./outgoingMessageParts.ts";
|
||||
|
||||
const image = {
|
||||
filename: "photo.png",
|
||||
sha256: "a".repeat(64),
|
||||
size: 123,
|
||||
type: "image/png",
|
||||
uploaded: 1,
|
||||
url: "https://relay.example/media/photo.png",
|
||||
};
|
||||
|
||||
test("mixed submissions put one media event before the text event", () => {
|
||||
const emojiTag = ["emoji", "ship", "https://relay.example/ship.png"];
|
||||
const parts = buildOutgoingMessageParts({
|
||||
body: "hello :ship:",
|
||||
media: [image, { ...image, filename: "second.png", url: `${image.url}?2` }],
|
||||
nowSeconds: 100,
|
||||
textTags: [emojiTag],
|
||||
});
|
||||
|
||||
assert.equal(parts.length, 2);
|
||||
assert.equal(parts[0].kind, "media");
|
||||
assert.equal(parts[0].createdAt, 99);
|
||||
assert.match(parts[0].content, /photo\.png/);
|
||||
assert.match(parts[0].content, /photo\.png\?2/);
|
||||
assert.equal(parts[0].outgoingTags?.length, 2);
|
||||
assert.deepEqual(parts[1], {
|
||||
content: "hello :ship:",
|
||||
kind: "text",
|
||||
outgoingTags: [emojiTag],
|
||||
});
|
||||
});
|
||||
|
||||
test("attachment-only submissions remain one media event", () => {
|
||||
const parts = buildOutgoingMessageParts({
|
||||
body: "",
|
||||
media: [image],
|
||||
nowSeconds: 100,
|
||||
});
|
||||
|
||||
assert.equal(parts.length, 1);
|
||||
assert.equal(parts[0].kind, "media");
|
||||
assert.equal(parts[0].createdAt, undefined);
|
||||
});
|
||||
|
||||
test("text-only submissions remain one text event", () => {
|
||||
assert.deepEqual(buildOutgoingMessageParts({ body: "hello", media: [] }), [
|
||||
{ content: "hello", kind: "text", outgoingTags: undefined },
|
||||
]);
|
||||
});
|
||||
52
desktop/src/features/messages/lib/outgoingMessageParts.ts
Normal file
52
desktop/src/features/messages/lib/outgoingMessageParts.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { buildOutgoingMessage, type ImetaMedia } from "./imetaMediaMarkdown";
|
||||
|
||||
export type OutgoingMessagePart = {
|
||||
content: string;
|
||||
createdAt?: number;
|
||||
kind: "media" | "text";
|
||||
outgoingTags: string[][] | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the ordered events for one composer submission. Attachments remain
|
||||
* together in a media-only event and precede any authored text event.
|
||||
*/
|
||||
export function buildOutgoingMessageParts({
|
||||
body,
|
||||
media,
|
||||
nowSeconds = Math.floor(Date.now() / 1_000),
|
||||
spoileredMediaUrls = new Set(),
|
||||
textTags = [],
|
||||
}: {
|
||||
body: string;
|
||||
media: ReadonlyArray<ImetaMedia>;
|
||||
nowSeconds?: number;
|
||||
spoileredMediaUrls?: ReadonlySet<string>;
|
||||
textTags?: string[][];
|
||||
}): OutgoingMessagePart[] {
|
||||
const parts: OutgoingMessagePart[] = [];
|
||||
const hasText = body.trim().length > 0;
|
||||
|
||||
if (media.length > 0) {
|
||||
const mediaMessage = buildOutgoingMessage("", media, spoileredMediaUrls);
|
||||
parts.push({
|
||||
content: mediaMessage.content,
|
||||
// Relay events sort by whole-second timestamps and then event id. Give
|
||||
// the media half of a mixed submission the immediately preceding second
|
||||
// so every client renders it first without delaying either network send.
|
||||
createdAt: hasText ? Math.max(0, nowSeconds - 1) : undefined,
|
||||
kind: "media",
|
||||
outgoingTags: mediaMessage.mediaTags,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasText) {
|
||||
parts.push({
|
||||
content: body,
|
||||
kind: "text",
|
||||
outgoingTags: textTags.length > 0 ? textTags : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
@@ -74,9 +74,11 @@ type MoreActionsMenuProps = {
|
||||
side?: "bottom" | "top";
|
||||
isFollowingThread?: boolean;
|
||||
isUnread?: boolean;
|
||||
align?: "end" | "start";
|
||||
};
|
||||
|
||||
function MoreActionsMenu({
|
||||
align = "end",
|
||||
channelId,
|
||||
compact = false,
|
||||
message,
|
||||
@@ -146,7 +148,7 @@ function MoreActionsMenu({
|
||||
<TooltipContent>More actions</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
align={align}
|
||||
side={side}
|
||||
sideOffset={6}
|
||||
onCloseAutoFocus={(event) => {
|
||||
@@ -415,6 +417,7 @@ function isCustomEmojiShortcode(emoji: string) {
|
||||
}
|
||||
|
||||
export const MessageActionBar = React.memo(function MessageActionBar({
|
||||
actionAlign = "end",
|
||||
channelId,
|
||||
message,
|
||||
onDelete,
|
||||
@@ -435,6 +438,7 @@ export const MessageActionBar = React.memo(function MessageActionBar({
|
||||
presentation = "tray",
|
||||
actions = "all",
|
||||
}: {
|
||||
actionAlign?: "end" | "start";
|
||||
/** Channel UUID — required for the "Copy link" action; when omitted the
|
||||
* action is hidden (callers like the home inbox that lack the context). */
|
||||
channelId?: string | null;
|
||||
@@ -549,7 +553,7 @@ export const MessageActionBar = React.memo(function MessageActionBar({
|
||||
<TooltipContent>React</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
align={actionAlign}
|
||||
className="w-auto overflow-hidden rounded-2xl border-0 bg-transparent p-0 shadow-none"
|
||||
side="top"
|
||||
sideOffset={10}
|
||||
@@ -593,6 +597,7 @@ export const MessageActionBar = React.memo(function MessageActionBar({
|
||||
className={cn(
|
||||
"transition-opacity duration-150 ease-out",
|
||||
presentation === "tray" && "-m-1 p-1",
|
||||
presentation === "inline" && "p-1",
|
||||
"opacity-100 sm:pointer-events-none sm:opacity-0",
|
||||
"sm:group-hover/message:pointer-events-auto sm:group-hover/message:opacity-100",
|
||||
"sm:group-focus-within/message:pointer-events-auto sm:group-focus-within/message:opacity-100",
|
||||
@@ -614,7 +619,9 @@ export const MessageActionBar = React.memo(function MessageActionBar({
|
||||
"flex items-center gap-0.5",
|
||||
presentation === "tray"
|
||||
? "p-1"
|
||||
: "gap-0 bg-transparent [&_button]:h-6 [&_button]:w-6",
|
||||
: presentation === "inline"
|
||||
? "gap-1 bg-transparent [&_button]:h-7 [&_button]:w-7"
|
||||
: "gap-0 bg-transparent [&_button]:h-6 [&_button]:w-6",
|
||||
)}
|
||||
>
|
||||
{presentation === "menu" ? (
|
||||
@@ -683,6 +690,7 @@ export const MessageActionBar = React.memo(function MessageActionBar({
|
||||
|
||||
{hasMoreMenuActions ? (
|
||||
<MoreActionsMenu
|
||||
align={actionAlign}
|
||||
channelId={channelId}
|
||||
message={message}
|
||||
onDelete={onDelete}
|
||||
|
||||
171
desktop/src/features/messages/ui/MessageBubbleMetadata.tsx
Normal file
171
desktop/src/features/messages/ui/MessageBubbleMetadata.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { MessageTimestamp } from "./MessageTimestamp";
|
||||
|
||||
export function MessageAvatarProfile({
|
||||
author,
|
||||
children,
|
||||
pubkey,
|
||||
role,
|
||||
}: {
|
||||
author: string;
|
||||
children: ReactNode;
|
||||
pubkey?: string;
|
||||
role?: string;
|
||||
}) {
|
||||
if (!pubkey) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<UserProfilePopover botIdenticonValue={author} pubkey={pubkey} role={role}>
|
||||
<button
|
||||
className="flex shrink-0 items-start rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageAvatarPosition({
|
||||
anchorToBubble = false,
|
||||
children,
|
||||
}: {
|
||||
anchorToBubble?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
anchorToBubble
|
||||
? "absolute bottom-0 right-full mr-2.5"
|
||||
: "flex shrink-0 items-start"
|
||||
}
|
||||
data-testid={
|
||||
anchorToBubble
|
||||
? "message-bubble-avatar-anchor"
|
||||
: "message-avatar-gutter"
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageHoverTimestampGutter({
|
||||
createdAt,
|
||||
isThreadReplyLayout,
|
||||
time,
|
||||
}: {
|
||||
createdAt: number;
|
||||
isThreadReplyLayout: boolean;
|
||||
time: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"relative flex w-9 shrink-0 items-start justify-end pt-0.5",
|
||||
isThreadReplyLayout ? "self-start" : "self-stretch",
|
||||
)}
|
||||
>
|
||||
<MessageTimestamp
|
||||
className="opacity-0 transition-opacity group-hover/message:opacity-100 group-focus-within/message:opacity-100"
|
||||
createdAt={createdAt}
|
||||
hideDayPeriod
|
||||
time={time}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageHoverTimestamp({
|
||||
createdAt,
|
||||
time,
|
||||
}: {
|
||||
createdAt: number;
|
||||
time: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="message-hover-timestamp pointer-events-none flex shrink-0 items-center"
|
||||
data-testid="message-hover-timestamp"
|
||||
>
|
||||
<MessageTimestamp createdAt={createdAt} hideDayPeriod time={time} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageStatusMetadata({
|
||||
className,
|
||||
edited = false,
|
||||
pending = false,
|
||||
}: {
|
||||
className?: string;
|
||||
edited?: boolean;
|
||||
pending?: boolean;
|
||||
}) {
|
||||
if (!pending && !edited) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{pending ? (
|
||||
<p
|
||||
className={cn("font-normal text-muted-foreground/70", className)}
|
||||
data-testid="message-send-status"
|
||||
>
|
||||
Sending…
|
||||
</p>
|
||||
) : null}
|
||||
{edited ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className={cn("text-muted-foreground/70", className)}>
|
||||
(edited)
|
||||
</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>This message has been edited</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageBubbleStatusMetadata({
|
||||
edited = false,
|
||||
isOwn = false,
|
||||
pending = false,
|
||||
}: {
|
||||
edited?: boolean;
|
||||
isOwn?: boolean;
|
||||
pending?: boolean;
|
||||
}) {
|
||||
const ownMetadataClass = isOwn ? "text-primary-foreground/70!" : undefined;
|
||||
|
||||
if (!pending && !edited) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mb-1 flex min-h-4 min-w-0 items-baseline gap-1.5 text-xs leading-message-author",
|
||||
isOwn && "justify-end",
|
||||
)}
|
||||
data-testid="message-bubble-status"
|
||||
>
|
||||
<MessageStatusMetadata
|
||||
className={ownMetadataClass}
|
||||
edited={edited}
|
||||
pending={pending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -91,6 +91,7 @@ export type MessageComposerProps = {
|
||||
parentEventId: string | null;
|
||||
threadHeadId: string | null;
|
||||
} | null,
|
||||
createdAt?: number,
|
||||
) => Promise<void>;
|
||||
placeholder?: string;
|
||||
profiles?: UserProfileLookup;
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAuthPubkey";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { useMessageStyle } from "@/shared/lib/messageStylePreference";
|
||||
import { useOwnMessageAlignment } from "@/shared/lib/ownMessageAlignmentPreference";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
|
||||
@@ -51,6 +52,13 @@ import { hasLinkPreviewSuppression } from "@/features/messages/lib/formatTimelin
|
||||
import { toast } from "sonner";
|
||||
import { MessageAgentOwner } from "./MessageAgentOwner";
|
||||
import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader";
|
||||
import {
|
||||
MessageAvatarPosition,
|
||||
MessageAvatarProfile,
|
||||
MessageBubbleStatusMetadata,
|
||||
MessageHoverTimestampGutter,
|
||||
MessageStatusMetadata,
|
||||
} from "./MessageBubbleMetadata";
|
||||
import { MessageTimestamp } from "./MessageTimestamp";
|
||||
import { MessageRowActions } from "./MessageRowActions";
|
||||
import type {
|
||||
@@ -61,15 +69,16 @@ import {
|
||||
getMessageBubbleBottomPaddingClass,
|
||||
getMessageBubbleLayout,
|
||||
MESSAGE_BUBBLE_CONTENT_PADDING_CLASSES,
|
||||
MESSAGE_BUBBLE_MAX_WIDTH_CLASSES,
|
||||
} from "./messageBubbleLayout";
|
||||
import { SentFromThreadLine } from "./SentFromThreadLine";
|
||||
import { WaveMessageAttachment } from "./WaveMessageAttachment";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
|
||||
const DiffMessage = React.lazy(() => import("./DiffMessage"));
|
||||
const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded"));
|
||||
const MESSAGE_BODY_SURFACE_CLASSES = cn(
|
||||
"w-fit max-w-full bg-muted/50",
|
||||
"w-fit bg-muted/50",
|
||||
MESSAGE_BUBBLE_MAX_WIDTH_CLASSES,
|
||||
MESSAGE_BUBBLE_CONTENT_PADDING_CLASSES,
|
||||
);
|
||||
|
||||
@@ -121,6 +130,7 @@ export const MessageRow = React.memo(
|
||||
// it into a grouped message row with no header.
|
||||
const isDisplayedAsContinuation = isContinuation && !message.pending;
|
||||
const showMessageBubbles = useMessageStyle() === "bubbles";
|
||||
const ownMessageAlignment = useOwnMessageAlignment();
|
||||
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
@@ -258,6 +268,12 @@ export const MessageRow = React.memo(
|
||||
message.kind === KIND_HUDDLE_STARTED ||
|
||||
message.kind === KIND_STREAM_MESSAGE_DIFF ||
|
||||
waveMessage !== null;
|
||||
const useGroupedBubbleLayout =
|
||||
showMessageBubbles && !hasStandaloneCardSurface;
|
||||
const alignOwnMessageRight =
|
||||
useGroupedBubbleLayout &&
|
||||
ownMessageAlignment === "right" &&
|
||||
message.accent;
|
||||
const bodyOffsetClass = emojiOnly ? "mt-1" : "mt-conversation-body";
|
||||
|
||||
const { nonDmChannelNames: channelNames } = useChannelNavigation();
|
||||
@@ -378,8 +394,9 @@ export const MessageRow = React.memo(
|
||||
<Markdown
|
||||
channelNames={channelNames}
|
||||
className={cn(
|
||||
"max-w-full text-message",
|
||||
"max-w-full text-left text-message",
|
||||
showMessageBubbles && "message-bubble-markdown",
|
||||
alignOwnMessageRight && "message-bubble-own",
|
||||
emojiOnly &&
|
||||
"text-4xl leading-tight [&_p]:leading-tight [&_img[data-custom-emoji]]:h-[1.45em] [&_img[data-custom-emoji]]:align-middle [&_button:has(img[data-custom-emoji])]:align-middle",
|
||||
)}
|
||||
@@ -429,8 +446,6 @@ export const MessageRow = React.memo(
|
||||
|
||||
const isThreadReplyLayout = layoutVariant === "thread-reply";
|
||||
const guideBleedRem = isThreadReplyLayout ? 0.25 : 0;
|
||||
const avatarButtonRadiusClass = "rounded-full";
|
||||
|
||||
const showRespondToIndicator =
|
||||
message.respondTo === "anyone" || message.respondTo === "allowlist";
|
||||
|
||||
@@ -475,45 +490,38 @@ export const MessageRow = React.memo(
|
||||
</div>
|
||||
);
|
||||
|
||||
const continuationTimestampGutter = (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"relative flex w-9 shrink-0 justify-end items-start pt-0.5",
|
||||
isThreadReplyLayout ? "self-start" : "self-stretch",
|
||||
)}
|
||||
>
|
||||
<MessageTimestamp
|
||||
className="opacity-0 transition-opacity group-hover/message:opacity-100 group-focus-within/message:opacity-100"
|
||||
createdAt={message.createdAt}
|
||||
hideDayPeriod
|
||||
time={message.time}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const avatarGutterNode = isDisplayedAsContinuation ? (
|
||||
continuationTimestampGutter
|
||||
) : message.pubkey ? (
|
||||
<UserProfilePopover
|
||||
const profileAvatarNode = (
|
||||
<MessageAvatarProfile
|
||||
author={message.author}
|
||||
pubkey={message.pubkey}
|
||||
role={profilePopoverRole}
|
||||
botIdenticonValue={message.author}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
"flex shrink-0 items-start focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
|
||||
avatarButtonRadiusClass,
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
{avatarNode}
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
) : (
|
||||
<div className="flex shrink-0 items-start">{avatarNode}</div>
|
||||
{avatarNode}
|
||||
</MessageAvatarProfile>
|
||||
);
|
||||
|
||||
const visibleAvatarGutterNode = (
|
||||
<MessageAvatarPosition>{profileAvatarNode}</MessageAvatarPosition>
|
||||
);
|
||||
const bubbleAvatarNode =
|
||||
useGroupedBubbleLayout &&
|
||||
!alignOwnMessageRight &&
|
||||
!isFollowedByContinuation ? (
|
||||
<MessageAvatarPosition anchorToBubble>
|
||||
{profileAvatarNode}
|
||||
</MessageAvatarPosition>
|
||||
) : null;
|
||||
const avatarGutterNode =
|
||||
alignOwnMessageRight ? null : useGroupedBubbleLayout ? (
|
||||
<div aria-hidden className="w-9 shrink-0" />
|
||||
) : isDisplayedAsContinuation ? (
|
||||
<MessageHoverTimestampGutter
|
||||
createdAt={message.createdAt}
|
||||
isThreadReplyLayout={isThreadReplyLayout}
|
||||
time={message.time}
|
||||
/>
|
||||
) : (
|
||||
visibleAvatarGutterNode
|
||||
);
|
||||
const authorNode = message.pubkey ? (
|
||||
<MessageAuthorText hoverUnderline>{message.author}</MessageAuthorText>
|
||||
) : (
|
||||
@@ -525,13 +533,13 @@ export const MessageRow = React.memo(
|
||||
ownerPubkey={message.ownerPubkey}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const anchorActionBarToBubble =
|
||||
showMessageBubbles && !hasStandaloneCardSurface;
|
||||
|
||||
const actionBarNode = (
|
||||
<MessageRowActions
|
||||
anchorToBubble={anchorActionBarToBubble}
|
||||
bubbleAlignment={alignOwnMessageRight ? "right" : "left"}
|
||||
channelId={channelId}
|
||||
isFollowingThread={isFollowingThread}
|
||||
isUnread={isUnread}
|
||||
@@ -556,82 +564,70 @@ export const MessageRow = React.memo(
|
||||
reactionErrorMessage={reactionErrorMessage}
|
||||
reactions={reactions}
|
||||
showMessageBubbles={showMessageBubbles}
|
||||
showHoverTimestamp={useGroupedBubbleLayout}
|
||||
/>
|
||||
);
|
||||
const hasStatusMetadata = message.pending || message.edited;
|
||||
const statusMetadataNode = (
|
||||
<MessageStatusMetadata
|
||||
edited={message.edited}
|
||||
pending={message.pending}
|
||||
/>
|
||||
);
|
||||
|
||||
const statusMetadataNode =
|
||||
message.pending || message.edited ? (
|
||||
<>
|
||||
{message.pending ? (
|
||||
<p
|
||||
className="font-normal text-muted-foreground/70"
|
||||
data-testid="message-send-status"
|
||||
>
|
||||
Sending…
|
||||
</p>
|
||||
) : null}
|
||||
{message.edited ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<p className="text-muted-foreground/70">(edited)</p>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>This message has been edited</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</>
|
||||
) : null;
|
||||
|
||||
const inlineMetadataNode = (
|
||||
<div className="flex shrink-0 items-baseline gap-2 text-xs">
|
||||
<MessageTimestamp createdAt={message.createdAt} time={message.time} />
|
||||
{statusMetadataNode}
|
||||
</div>
|
||||
);
|
||||
|
||||
const continuationMetadataNode =
|
||||
isDisplayedAsContinuation && statusMetadataNode ? (
|
||||
!useGroupedBubbleLayout &&
|
||||
(isDisplayedAsContinuation || alignOwnMessageRight) &&
|
||||
hasStatusMetadata ? (
|
||||
<div className="mt-0.5 flex items-baseline gap-2 text-xs">
|
||||
{statusMetadataNode}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const headerNode = isDisplayedAsContinuation ? null : (
|
||||
<MessageHeaderRow>
|
||||
{message.pubkey ? (
|
||||
<UserProfilePopover
|
||||
pubkey={message.pubkey}
|
||||
role={profilePopoverRole}
|
||||
botIdenticonValue={message.author}
|
||||
>
|
||||
<button
|
||||
className="truncate rounded leading-message-author focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
const headerNode =
|
||||
isDisplayedAsContinuation ||
|
||||
(useGroupedBubbleLayout && alignOwnMessageRight) ? null : (
|
||||
<MessageHeaderRow>
|
||||
{message.pubkey ? (
|
||||
<UserProfilePopover
|
||||
pubkey={message.pubkey}
|
||||
role={profilePopoverRole}
|
||||
botIdenticonValue={message.author}
|
||||
>
|
||||
{authorNode}
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
) : (
|
||||
authorNode
|
||||
)}
|
||||
{agentOwnerNode}
|
||||
{inlineMetadataNode}
|
||||
{message.personaDisplayName &&
|
||||
message.personaDisplayName !== message.author ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{message.personaDisplayName}
|
||||
</span>
|
||||
) : null}
|
||||
</MessageHeaderRow>
|
||||
);
|
||||
const bodyContainerClass = isDisplayedAsContinuation
|
||||
? "mt-0"
|
||||
: bodyOffsetClass;
|
||||
<button
|
||||
className="truncate rounded leading-message-author focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
{authorNode}
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
) : (
|
||||
authorNode
|
||||
)}
|
||||
{agentOwnerNode}
|
||||
{message.personaDisplayName &&
|
||||
message.personaDisplayName !== message.author ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{message.personaDisplayName}
|
||||
</span>
|
||||
) : null}
|
||||
{useGroupedBubbleLayout ? null : inlineMetadataNode}
|
||||
</MessageHeaderRow>
|
||||
);
|
||||
const bodyContainerClass =
|
||||
isDisplayedAsContinuation || useGroupedBubbleLayout
|
||||
? "mt-0"
|
||||
: bodyOffsetClass;
|
||||
const { radiusClass, rowSpacingClass: bubbleRowSpacingClass } =
|
||||
getMessageBubbleLayout(
|
||||
isDisplayedAsContinuation,
|
||||
isFollowedByContinuation,
|
||||
alignOwnMessageRight ? "right" : "left",
|
||||
);
|
||||
|
||||
const coreMessageBodyNode = (
|
||||
<>
|
||||
<SentFromThreadLine channelId={channelId} tags={message.tags} />
|
||||
@@ -639,6 +635,14 @@ export const MessageRow = React.memo(
|
||||
{continuationMetadataNode}
|
||||
</>
|
||||
);
|
||||
const bubbleStatusNode =
|
||||
useGroupedBubbleLayout && hasStatusMetadata ? (
|
||||
<MessageBubbleStatusMetadata
|
||||
edited={message.edited}
|
||||
isOwn={alignOwnMessageRight}
|
||||
pending={message.pending}
|
||||
/>
|
||||
) : null;
|
||||
const expandedDiffNode =
|
||||
expandedDiffId === message.id ? (
|
||||
<React.Suspense
|
||||
@@ -690,7 +694,12 @@ export const MessageRow = React.memo(
|
||||
</div>
|
||||
);
|
||||
const bubbleMessageBodyNode = (
|
||||
<div className={cn(bodyContainerClass, "w-fit max-w-full")}>
|
||||
<div
|
||||
className={cn(
|
||||
bodyContainerClass,
|
||||
hasStandaloneCardSurface ? "w-fit max-w-full" : "w-full max-w-full",
|
||||
)}
|
||||
>
|
||||
{hasStandaloneCardSurface ? (
|
||||
<div data-testid="message-standalone-card-content">
|
||||
{coreMessageBodyNode}
|
||||
@@ -707,7 +716,9 @@ export const MessageRow = React.memo(
|
||||
)}
|
||||
data-testid="message-body-surface"
|
||||
>
|
||||
{bubbleAvatarNode}
|
||||
{anchorActionBarToBubble ? actionBarNode : null}
|
||||
{bubbleStatusNode}
|
||||
{coreMessageBodyNode}
|
||||
{expandedDiffNode}
|
||||
{bodyFooter}
|
||||
@@ -881,7 +892,7 @@ export const MessageRow = React.memo(
|
||||
|
||||
<article
|
||||
className={cn(
|
||||
"group/message relative z-10 rounded-2xl transition-colors",
|
||||
"group/message message-hover-region relative z-10 rounded-2xl transition-colors",
|
||||
playEntrance && "motion-enter-conversation",
|
||||
showMessageBubbles ? bubbleRowSpacingClass : "py-conversation-row",
|
||||
showMessageBubbles
|
||||
@@ -894,6 +905,7 @@ export const MessageRow = React.memo(
|
||||
? "mx-1 px-2"
|
||||
: "px-2",
|
||||
"flex gap-2.5",
|
||||
alignOwnMessageRight && "own-message-row flex-row-reverse",
|
||||
isDisplayedAsContinuation ? "items-center" : "items-start",
|
||||
hasActiveReminder ? "bg-blue-500/10" : "",
|
||||
highlighted
|
||||
|
||||
@@ -4,9 +4,11 @@ import type {
|
||||
} from "@/features/messages/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { MessageActionBar } from "./MessageActionBar";
|
||||
import { MessageHoverTimestamp } from "./MessageBubbleMetadata";
|
||||
|
||||
type MessageRowActionsProps = {
|
||||
anchorToBubble: boolean;
|
||||
bubbleAlignment: "left" | "right";
|
||||
channelId?: string | null;
|
||||
isFollowingThread?: boolean;
|
||||
isUnread?: boolean;
|
||||
@@ -25,10 +27,12 @@ type MessageRowActionsProps = {
|
||||
reactionErrorMessage?: string | null;
|
||||
reactions: TimelineReaction[];
|
||||
showMessageBubbles: boolean;
|
||||
showHoverTimestamp?: boolean;
|
||||
};
|
||||
|
||||
export function MessageRowActions({
|
||||
anchorToBubble,
|
||||
bubbleAlignment,
|
||||
channelId,
|
||||
isFollowingThread,
|
||||
isUnread,
|
||||
@@ -47,19 +51,33 @@ export function MessageRowActions({
|
||||
reactionErrorMessage,
|
||||
reactions,
|
||||
showMessageBubbles,
|
||||
showHoverTimestamp = false,
|
||||
}: MessageRowActionsProps) {
|
||||
const showInlineBubbleActions = showMessageBubbles && anchorToBubble;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute z-10",
|
||||
showMessageBubbles
|
||||
? anchorToBubble
|
||||
? "-right-2 -top-2"
|
||||
: "right-1 top-1"
|
||||
"absolute z-10 flex items-center gap-2",
|
||||
showInlineBubbleActions && "text-foreground",
|
||||
showInlineBubbleActions
|
||||
? cn(
|
||||
"top-1/2 -translate-y-1/2",
|
||||
bubbleAlignment === "right"
|
||||
? "right-full mr-3"
|
||||
: "left-full ml-3",
|
||||
)
|
||||
: "right-1 top-1",
|
||||
)}
|
||||
>
|
||||
{showHoverTimestamp && bubbleAlignment === "right" ? (
|
||||
<MessageHoverTimestamp
|
||||
createdAt={message.createdAt}
|
||||
time={message.time}
|
||||
/>
|
||||
) : null}
|
||||
<MessageActionBar
|
||||
actionAlign={bubbleAlignment === "left" ? "start" : "end"}
|
||||
channelId={channelId}
|
||||
isFollowingThread={isFollowingThread}
|
||||
isUnread={isUnread}
|
||||
@@ -75,10 +93,16 @@ export function MessageRowActions({
|
||||
onReply={onReply}
|
||||
onSendToChannel={onSendToChannel}
|
||||
onUnfollowThread={onUnfollowThread}
|
||||
presentation="menu"
|
||||
presentation={showInlineBubbleActions ? "inline" : "menu"}
|
||||
reactionErrorMessage={reactionErrorMessage}
|
||||
reactions={reactions}
|
||||
/>
|
||||
{showHoverTimestamp && bubbleAlignment === "left" ? (
|
||||
<MessageHoverTimestamp
|
||||
createdAt={message.createdAt}
|
||||
time={message.time}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & {
|
||||
parentEventId: string | null;
|
||||
threadHeadId: string | null;
|
||||
} | null,
|
||||
createdAt?: number,
|
||||
) => Promise<void>;
|
||||
onSendToChannel?: (
|
||||
message: TimelineMessage,
|
||||
|
||||
@@ -253,7 +253,10 @@ export function MessageThreadSummaryRow({
|
||||
</div>
|
||||
<div className="relative z-10 min-w-0">
|
||||
<div>
|
||||
<span className="font-medium transition-colors group-hover:text-foreground">
|
||||
<span
|
||||
className="font-medium transition-colors group-hover:text-foreground"
|
||||
data-testid="message-thread-summary-reply-count"
|
||||
>
|
||||
{summary.replyCount} {replyLabel}
|
||||
</span>
|
||||
{unreadCount != null && unreadCount > 0 ? (
|
||||
@@ -263,7 +266,10 @@ export function MessageThreadSummaryRow({
|
||||
) : null}
|
||||
{summary.lastReplyAt ? (
|
||||
<>
|
||||
<span className="mx-1 font-normal text-muted-foreground/50">
|
||||
<span
|
||||
className="mx-1 font-normal text-muted-foreground/50"
|
||||
data-testid="message-thread-summary-separator"
|
||||
>
|
||||
·
|
||||
</span>
|
||||
<span className="inline-grid font-normal text-muted-foreground/70">
|
||||
|
||||
@@ -245,6 +245,11 @@ export function NewMessageScreen() {
|
||||
mentionPubkeys: string[],
|
||||
mediaTags?: string[][],
|
||||
targetChannelId?: string | null,
|
||||
_threadContext?: {
|
||||
parentEventId: string | null;
|
||||
threadHeadId: string | null;
|
||||
} | null,
|
||||
createdAt?: number,
|
||||
) => {
|
||||
const preparedDirectMessage = preparedDirectMessageRef.current;
|
||||
const directMessage =
|
||||
@@ -263,6 +268,7 @@ export function NewMessageScreen() {
|
||||
content,
|
||||
mentionPubkeys,
|
||||
mediaTags,
|
||||
createdAt,
|
||||
});
|
||||
} catch (error) {
|
||||
preparedDirectMessageRef.current = null;
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { ChannelType } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { useMessageStyle } from "@/shared/lib/messageStylePreference";
|
||||
import { channelChrome } from "@/shared/layout/chromeLayout";
|
||||
import { DayDivider } from "./DayDivider";
|
||||
import { MessageRowItem, SystemRow } from "./TimelineMessageRow";
|
||||
@@ -168,6 +169,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
onVirtualizerRangeChanged,
|
||||
onVirtualizerScrollerChange,
|
||||
}: TimelineMessageListProps) {
|
||||
const showMessageBubbles = useMessageStyle() === "bubbles";
|
||||
const entries = React.useMemo(
|
||||
() =>
|
||||
mainEntries ??
|
||||
@@ -329,6 +331,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
onVirtualizerRangeChanged={onVirtualizerRangeChanged}
|
||||
onVirtualizerScrollerChange={onVirtualizerScrollerChange}
|
||||
renderItem={renderItem}
|
||||
showMessageBubbles={showMessageBubbles}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -341,6 +344,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
|
||||
"relative flex flex-col",
|
||||
!hideDayDividers &&
|
||||
group.headingTimestamp !== null &&
|
||||
!showMessageBubbles &&
|
||||
"before:absolute before:inset-x-0 before:top-1/2 before:h-px before:-translate-y-1/2 before:bg-border/35 before:content-['']",
|
||||
)}
|
||||
data-day-label={
|
||||
@@ -385,6 +389,7 @@ type VirtualizedTimelineRowsProps = {
|
||||
onVirtualizerRangeChanged?: () => void;
|
||||
onVirtualizerScrollerChange?: (element: HTMLDivElement | null) => void;
|
||||
renderItem: (item: TimelineNonDayItem) => React.ReactNode;
|
||||
showMessageBubbles: boolean;
|
||||
};
|
||||
|
||||
type VirtualizedTimelineItemShellProps = {
|
||||
@@ -425,6 +430,7 @@ function VirtualizedTimelineRows({
|
||||
onVirtualizerRangeChanged,
|
||||
onVirtualizerScrollerChange,
|
||||
renderItem,
|
||||
showMessageBubbles,
|
||||
}: VirtualizedTimelineRowsProps) {
|
||||
const listRef = React.useRef<VListHandle>(null);
|
||||
const hostRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -765,7 +771,11 @@ function VirtualizedTimelineRows({
|
||||
const dayLabel = formatDayHeading(item.headingTimestamp);
|
||||
return (
|
||||
<div
|
||||
className="relative flex flex-col before:absolute before:inset-x-0 before:top-1/2 before:h-px before:-translate-y-1/2 before:bg-border/35 before:content-['']"
|
||||
className={cn(
|
||||
"relative flex flex-col",
|
||||
!showMessageBubbles &&
|
||||
"before:absolute before:inset-x-0 before:top-1/2 before:h-px before:-translate-y-1/2 before:bg-border/35 before:content-['']",
|
||||
)}
|
||||
data-day-label={dayLabel}
|
||||
data-testid="message-timeline-day-group"
|
||||
key={virtualizedItemKey(item)}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
const layout = await import("./messageBubbleLayout.ts");
|
||||
|
||||
test("lets long bubbles use more of wide conversations", () => {
|
||||
assert.equal(
|
||||
layout.MESSAGE_BUBBLE_MAX_WIDTH_CLASSES,
|
||||
"max-w-[min(72%,48rem)]",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses full radii for a standalone left-aligned bubble", () => {
|
||||
assert.equal(
|
||||
layout.getMessageBubbleLayout(false, false).radiusClass,
|
||||
"rounded-r-2xl rounded-tl-2xl rounded-bl-2xl",
|
||||
);
|
||||
});
|
||||
|
||||
test("joins grouped left-aligned bubbles on their left edge", () => {
|
||||
assert.equal(
|
||||
layout.getMessageBubbleLayout(false, true).radiusClass,
|
||||
"rounded-r-2xl rounded-tl-2xl rounded-bl-md",
|
||||
);
|
||||
assert.equal(
|
||||
layout.getMessageBubbleLayout(true, false).radiusClass,
|
||||
"rounded-r-2xl rounded-tl-md rounded-bl-2xl",
|
||||
);
|
||||
});
|
||||
|
||||
test("mirrors grouped bubble corners when own messages align right", () => {
|
||||
assert.equal(
|
||||
layout.getMessageBubbleLayout(false, true, "right").radiusClass,
|
||||
"rounded-l-2xl rounded-tr-2xl rounded-br-md",
|
||||
);
|
||||
assert.equal(
|
||||
layout.getMessageBubbleLayout(true, false, "right").radiusClass,
|
||||
"rounded-l-2xl rounded-tr-md rounded-br-2xl",
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
export const MESSAGE_BUBBLE_CONTENT_PADDING_CLASSES = "px-3.5 pt-2.5";
|
||||
export const MESSAGE_BUBBLE_MAX_WIDTH_CLASSES = "max-w-[min(72%,48rem)]";
|
||||
|
||||
export function getMessageBubbleBottomPaddingClass(
|
||||
hasReactions: boolean,
|
||||
@@ -9,10 +10,16 @@ export function getMessageBubbleBottomPaddingClass(
|
||||
export function getMessageBubbleLayout(
|
||||
isContinuation: boolean,
|
||||
isFollowedByContinuation: boolean,
|
||||
alignment: "left" | "right" = "left",
|
||||
) {
|
||||
const radiusClass = `rounded-r-2xl ${
|
||||
isContinuation ? "rounded-tl-md" : "rounded-tl-2xl"
|
||||
} ${isFollowedByContinuation ? "rounded-bl-md" : "rounded-bl-2xl"}`;
|
||||
const radiusClass =
|
||||
alignment === "right"
|
||||
? `rounded-l-2xl ${
|
||||
isContinuation ? "rounded-tr-md" : "rounded-tr-2xl"
|
||||
} ${isFollowedByContinuation ? "rounded-br-md" : "rounded-br-2xl"}`
|
||||
: `rounded-r-2xl ${
|
||||
isContinuation ? "rounded-tl-md" : "rounded-tl-2xl"
|
||||
} ${isFollowedByContinuation ? "rounded-bl-md" : "rounded-bl-2xl"}`;
|
||||
const rowSpacingClass = isContinuation
|
||||
? isFollowedByContinuation
|
||||
? "py-0.5"
|
||||
|
||||
@@ -24,11 +24,8 @@ import {
|
||||
} from "@/features/messages/lib/backgroundMediaUploadStore";
|
||||
import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks";
|
||||
import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete";
|
||||
import {
|
||||
buildOutgoingMessage,
|
||||
type ImetaMedia,
|
||||
mergeOutgoingTags,
|
||||
} from "@/features/messages/lib/imetaMediaMarkdown";
|
||||
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
|
||||
import { buildOutgoingMessageParts } from "@/features/messages/lib/outgoingMessageParts";
|
||||
import type { UseMentionsResult } from "@/features/messages/lib/useMentions";
|
||||
import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor";
|
||||
import type { UseDraftsResult } from "@/features/messages/lib/useDrafts";
|
||||
@@ -69,6 +66,7 @@ type UseMentionSendFlowOptions = {
|
||||
parentEventId: string | null;
|
||||
threadHeadId: string | null;
|
||||
} | null,
|
||||
createdAt?: number,
|
||||
) => Promise<void>
|
||||
>;
|
||||
richText: Pick<
|
||||
@@ -488,7 +486,8 @@ export function useMentionSendFlow({
|
||||
);
|
||||
|
||||
const send = onSendRef.current;
|
||||
const persistCanceledDraft = () => {
|
||||
let mediaMessageSent = false;
|
||||
const persistCanceledDraft = (withoutMedia = false) => {
|
||||
if (!draft.recoveryDraftKey) return;
|
||||
const existing = drafts.loadDraft(draft.recoveryDraftKey);
|
||||
if (
|
||||
@@ -507,13 +506,13 @@ export function useMentionSendFlow({
|
||||
draft.recoveryDraftKey,
|
||||
draft.savedContent,
|
||||
draft.capturedChannelId ?? draft.recoveryDraftKey,
|
||||
draft.savedImeta,
|
||||
[...draft.savedSpoileredAttachmentUrls],
|
||||
withoutMedia ? [] : draft.savedImeta,
|
||||
withoutMedia ? [] : [...draft.savedSpoileredAttachmentUrls],
|
||||
draft.savedMentionRefs,
|
||||
);
|
||||
};
|
||||
const restoreComposerAfterFailure = () => {
|
||||
persistCanceledDraft();
|
||||
const restoreComposerAfterFailure = (withoutMedia = false) => {
|
||||
persistCanceledDraft(withoutMedia);
|
||||
const canRestoreCurrentComposer =
|
||||
isMountedRef.current &&
|
||||
(draft.capturedChannelId === channelIdRef.current ||
|
||||
@@ -523,7 +522,7 @@ export function useMentionSendFlow({
|
||||
if (!canRestoreCurrentComposer && draft.recoveryDraftKey) {
|
||||
saveQueuedAttachmentsForDraft(
|
||||
draft.recoveryDraftKey,
|
||||
draft.queuedAttachments,
|
||||
withoutMedia ? [] : draft.queuedAttachments,
|
||||
);
|
||||
}
|
||||
if (!canRestoreCurrentComposer) {
|
||||
@@ -532,21 +531,23 @@ export function useMentionSendFlow({
|
||||
setContent(draft.savedContent);
|
||||
contentRef.current = draft.savedContent;
|
||||
richText.setContent(draft.savedContent);
|
||||
setPendingImeta(draft.savedImeta);
|
||||
restoreQueuedAttachments(draft.queuedAttachments);
|
||||
setPendingImeta(withoutMedia ? [] : draft.savedImeta);
|
||||
restoreQueuedAttachments(withoutMedia ? [] : draft.queuedAttachments);
|
||||
mentions.restoreDraftMentionRefs(draft.savedMentionRefs);
|
||||
setSpoileredAttachmentUrls?.(
|
||||
new Set(draft.savedSpoileredAttachmentUrls),
|
||||
withoutMedia
|
||||
? new Set()
|
||||
: new Set(draft.savedSpoileredAttachmentUrls),
|
||||
);
|
||||
};
|
||||
const finishSend = async (
|
||||
uploaded: ImetaMedia[],
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
const { content: finalContent, mediaTags } = buildOutgoingMessage(
|
||||
draft.trimmed,
|
||||
[...draft.savedImeta, ...uploaded],
|
||||
new Set([
|
||||
const parts = buildOutgoingMessageParts({
|
||||
body: draft.trimmed,
|
||||
media: [...draft.savedImeta, ...uploaded],
|
||||
spoileredMediaUrls: new Set([
|
||||
...draft.savedSpoileredAttachmentUrls,
|
||||
...draft.queuedAttachments.flatMap((attachment, index) =>
|
||||
attachment.spoilered && uploaded[index]
|
||||
@@ -554,24 +555,25 @@ export function useMentionSendFlow({
|
||||
: [],
|
||||
),
|
||||
]),
|
||||
);
|
||||
const finalOutgoingTags = mergeOutgoingTags(
|
||||
mediaTags,
|
||||
outgoingTags ?? [],
|
||||
);
|
||||
textTags: outgoingTags,
|
||||
});
|
||||
if (signal?.aborted) return;
|
||||
await send(
|
||||
finalContent,
|
||||
mentionPubkeys,
|
||||
finalOutgoingTags,
|
||||
sendChannelId,
|
||||
draft.capturedThreadContext,
|
||||
);
|
||||
for (const part of parts) {
|
||||
await send(
|
||||
part.content,
|
||||
part.kind === "text" ? mentionPubkeys : [],
|
||||
part.outgoingTags,
|
||||
sendChannelId,
|
||||
draft.capturedThreadContext,
|
||||
part.createdAt,
|
||||
);
|
||||
if (part.kind === "media") mediaMessageSent = true;
|
||||
if (signal?.aborted) return;
|
||||
}
|
||||
if (signal?.aborted) return;
|
||||
if (effectiveExplicitAgentPubkeys.length > 0) {
|
||||
// Promote only explicitly authored agents that remained effective
|
||||
// for this successful send. "Send without inviting" removes its
|
||||
// excluded recipients here as well as from event routing.
|
||||
// Promote only explicitly authored agents that remained effective.
|
||||
// "Send without inviting" removes excluded recipients here too.
|
||||
onSuccessfulExplicitAgentAudience?.({
|
||||
channelId: sendChannelId ?? draft.capturedChannelId ?? "",
|
||||
expectedGeneration: draft.audienceGeneration,
|
||||
@@ -595,7 +597,7 @@ export function useMentionSendFlow({
|
||||
try {
|
||||
await finishSend(uploaded, signal);
|
||||
} catch {
|
||||
restoreComposerAfterFailure();
|
||||
restoreComposerAfterFailure(mediaMessageSent);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -605,7 +607,7 @@ export function useMentionSendFlow({
|
||||
);
|
||||
},
|
||||
onCancel: () => {
|
||||
restoreComposerAfterFailure();
|
||||
restoreComposerAfterFailure(mediaMessageSent);
|
||||
},
|
||||
});
|
||||
if (!uploadStarted) {
|
||||
@@ -629,7 +631,7 @@ export function useMentionSendFlow({
|
||||
try {
|
||||
await finishSend([]);
|
||||
} catch {
|
||||
restoreComposerAfterFailure();
|
||||
restoreComposerAfterFailure(mediaMessageSent);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { useCommunities } from "@/features/communities/useCommunities";
|
||||
import { AvatarFramingSlider } from "@/features/profile/ui/AnimatedAvatarControls";
|
||||
import { contrastColorForBackground } from "@/features/profile/ui/ProfileAvatarEditor.utils";
|
||||
import { MESSAGE_BUBBLE_MAX_WIDTH_CLASSES } from "@/features/messages/ui/messageBubbleLayout";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
previewLinkPreviewStyle,
|
||||
@@ -39,6 +40,13 @@ import {
|
||||
useSavedMessageStyle,
|
||||
type MessageStyle,
|
||||
} from "@/shared/lib/messageStylePreference";
|
||||
import {
|
||||
previewOwnMessageAlignment,
|
||||
setOwnMessageAlignment,
|
||||
useOwnMessageAlignment,
|
||||
useSavedOwnMessageAlignment,
|
||||
type OwnMessageAlignment,
|
||||
} from "@/shared/lib/ownMessageAlignmentPreference";
|
||||
import {
|
||||
ACCENT_COLORS,
|
||||
DEFAULT_GLASS_OPACITY,
|
||||
@@ -152,42 +160,106 @@ const MESSAGE_STYLE_OPTIONS: readonly {
|
||||
},
|
||||
];
|
||||
|
||||
const OWN_MESSAGE_ALIGNMENT_OPTIONS: readonly {
|
||||
value: OwnMessageAlignment;
|
||||
label: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "left",
|
||||
label: "Left",
|
||||
},
|
||||
{
|
||||
value: "right",
|
||||
label: "Right",
|
||||
},
|
||||
];
|
||||
|
||||
function ConversationDensityPreviewMessage({
|
||||
avatar,
|
||||
author,
|
||||
children,
|
||||
isOwn = false,
|
||||
timestamp,
|
||||
}: {
|
||||
avatar: string;
|
||||
author: string;
|
||||
children: ReactNode;
|
||||
isOwn?: boolean;
|
||||
timestamp: string;
|
||||
}) {
|
||||
const messageStyle = useMessageStyle();
|
||||
const ownMessageAlignment = useOwnMessageAlignment();
|
||||
const showBubble = messageStyle === "bubbles";
|
||||
const useGroupedBubbleLayout = showBubble;
|
||||
const alignRight =
|
||||
useGroupedBubbleLayout && ownMessageAlignment === "right" && isOwn;
|
||||
|
||||
return (
|
||||
<article className="flex gap-2.5 py-conversation-row">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
|
||||
{avatar}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-baseline gap-x-1.5 leading-message-author">
|
||||
<span className="text-message font-semibold leading-message-author tracking-normal text-foreground">
|
||||
{author}
|
||||
</span>
|
||||
<span className="text-message-timestamp font-normal text-muted-foreground/65">
|
||||
{timestamp}
|
||||
</span>
|
||||
<article
|
||||
className={cn(
|
||||
"group/preview-message message-hover-region flex gap-2.5 py-conversation-row",
|
||||
useGroupedBubbleLayout && !alignRight && "items-end",
|
||||
alignRight && "flex-row-reverse",
|
||||
)}
|
||||
>
|
||||
{alignRight ? null : (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
|
||||
{avatar}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-conversation-body w-fit max-w-full text-message font-normal tracking-normal text-foreground",
|
||||
showBubble && "rounded-2xl bg-muted/50 px-3.5 py-2.5",
|
||||
)}
|
||||
data-testid="conversation-preview-message-surface"
|
||||
>
|
||||
{children}
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 flex-1",
|
||||
alignRight && "flex flex-col items-end",
|
||||
)}
|
||||
>
|
||||
{useGroupedBubbleLayout && alignRight ? null : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-wrap items-baseline gap-x-1.5 leading-message-author",
|
||||
useGroupedBubbleLayout && "mb-0.5",
|
||||
)}
|
||||
data-testid="conversation-preview-message-header"
|
||||
>
|
||||
<span className="text-message font-semibold leading-message-author tracking-normal text-foreground">
|
||||
{author}
|
||||
</span>
|
||||
{useGroupedBubbleLayout ? null : (
|
||||
<span className="text-message-timestamp font-normal text-muted-foreground/65">
|
||||
{timestamp}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative w-fit max-w-full">
|
||||
{useGroupedBubbleLayout ? (
|
||||
<span
|
||||
className={cn(
|
||||
"message-hover-timestamp pointer-events-none absolute top-1/2 -translate-y-1/2 shrink-0 whitespace-nowrap text-message-timestamp font-normal tabular-nums text-muted-foreground/55",
|
||||
alignRight ? "right-full mr-2" : "left-full ml-2",
|
||||
)}
|
||||
data-testid="conversation-preview-hover-timestamp"
|
||||
>
|
||||
{timestamp}
|
||||
</span>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"w-fit max-w-full text-left text-message font-normal tracking-normal text-foreground",
|
||||
useGroupedBubbleLayout ? "mt-0" : "mt-conversation-body",
|
||||
showBubble &&
|
||||
cn(
|
||||
MESSAGE_BUBBLE_MAX_WIDTH_CLASSES,
|
||||
"rounded-2xl px-3.5 py-2.5",
|
||||
),
|
||||
showBubble && !alignRight && "bg-muted/50",
|
||||
alignRight && "bg-primary text-primary-foreground",
|
||||
)}
|
||||
data-testid="conversation-preview-message-surface"
|
||||
data-own-message={isOwn ? "true" : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -215,7 +287,8 @@ function ConversationPreview() {
|
||||
</ConversationDensityPreviewMessage>
|
||||
<ConversationDensityPreviewMessage
|
||||
avatar="T"
|
||||
author="Theo"
|
||||
author="You"
|
||||
isOwn
|
||||
timestamp="9:43"
|
||||
>
|
||||
<p>
|
||||
@@ -237,6 +310,7 @@ export function ConversationDisplaySettings() {
|
||||
const density = useConversationDensity();
|
||||
const fontSize = useFontSize();
|
||||
const messageStyle = useSavedMessageStyle();
|
||||
const ownMessageAlignment = useSavedOwnMessageAlignment();
|
||||
const activeMessageStyle =
|
||||
MESSAGE_STYLE_OPTIONS.find((option) => option.value === messageStyle) ??
|
||||
MESSAGE_STYLE_OPTIONS[0];
|
||||
@@ -306,6 +380,31 @@ export function ConversationDisplaySettings() {
|
||||
value={messageStyle}
|
||||
/>
|
||||
</SettingsOptionRow>
|
||||
{messageStyle === "bubbles" ? (
|
||||
<SettingsOptionRow data-testid="own-message-alignment-row">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">My messages</p>
|
||||
<p
|
||||
className="text-sm font-normal text-muted-foreground/70"
|
||||
data-settings-subcopy
|
||||
>
|
||||
{ownMessageAlignment === "right"
|
||||
? "Show your messages on the right."
|
||||
: "Keep your messages aligned with the conversation."}
|
||||
</p>
|
||||
</div>
|
||||
<SettingsSegmentedControl
|
||||
className="w-72"
|
||||
legend="My messages"
|
||||
onPreviewChange={previewOwnMessageAlignment}
|
||||
onValueChange={setOwnMessageAlignment}
|
||||
optionTestIdPrefix="own-message-alignment"
|
||||
options={OWN_MESSAGE_ALIGNMENT_OPTIONS}
|
||||
testId="own-message-alignment-control"
|
||||
value={ownMessageAlignment}
|
||||
/>
|
||||
</SettingsOptionRow>
|
||||
) : null}
|
||||
<ConversationPreview />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -53,3 +53,13 @@ export function resolveChannelDisplayLabel(
|
||||
)
|
||||
: channel.name;
|
||||
}
|
||||
|
||||
export function resolveChannelSidebarSubtitle(channel: Channel): string {
|
||||
const description = channel.description.trim();
|
||||
if (description) return description;
|
||||
|
||||
const typeLabel = channel.channelType === "forum" ? "forum" : "channel";
|
||||
return channel.visibility === "private"
|
||||
? `Private ${typeLabel}`
|
||||
: `Open ${typeLabel}`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { resolveChannelSidebarSubtitle } from "../lib/channelLabels.ts";
|
||||
import { formatWorkingTooltip } from "./SidebarSection.tsx";
|
||||
|
||||
function summary(agentNames, agentCount = agentNames.length) {
|
||||
@@ -50,3 +51,30 @@ describe("formatWorkingTooltip", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveChannelSidebarSubtitle", () => {
|
||||
const channel = {
|
||||
channelType: "stream",
|
||||
description: "Design and engineering updates",
|
||||
visibility: "open",
|
||||
};
|
||||
|
||||
it("uses the channel description", () => {
|
||||
assert.equal(
|
||||
resolveChannelSidebarSubtitle(channel),
|
||||
"Design and engineering updates",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the channel type and visibility", () => {
|
||||
assert.equal(
|
||||
resolveChannelSidebarSubtitle({
|
||||
...channel,
|
||||
channelType: "forum",
|
||||
description: " ",
|
||||
visibility: "private",
|
||||
}),
|
||||
"Private forum",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@/shared/ui/context-menu";
|
||||
|
||||
import { ChannelContextMenuItems } from "@/features/sidebar/ui/ChannelContextMenu";
|
||||
import { resolveChannelSidebarSubtitle } from "@/features/sidebar/lib/channelLabels";
|
||||
import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurnsStore";
|
||||
import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
|
||||
import { getEphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel";
|
||||
@@ -273,6 +274,8 @@ export function ChannelMenuButton({
|
||||
onSelectChannel: (channelId: string) => void;
|
||||
}) {
|
||||
const resolvedLabel = label ?? channel.name;
|
||||
const isDm = channel.channelType === "dm";
|
||||
const subtitle = isDm ? null : resolveChannelSidebarSubtitle(channel);
|
||||
const ephemeralDisplay = getEphemeralChannelDisplay(channel);
|
||||
const {
|
||||
hasSidebarUnreadProjections,
|
||||
@@ -303,6 +306,7 @@ export function ChannelMenuButton({
|
||||
<SidebarMenuButton
|
||||
className={cn(
|
||||
"data-[active=true]:font-normal",
|
||||
!isDm && "h-12 gap-2 p-1 pr-2 group-data-[collapsible=icon]:!p-0",
|
||||
isActive
|
||||
? "group-hover/menu-item:bg-sidebar-active group-hover/menu-item:text-sidebar-active-foreground"
|
||||
: "group-hover/menu-item:bg-sidebar-accent group-hover/menu-item:text-sidebar-foreground",
|
||||
@@ -313,23 +317,62 @@ export function ChannelMenuButton({
|
||||
data-testid={`channel-${channel.name}`}
|
||||
isActive={isActive}
|
||||
onClick={() => onSelectChannel(channel.id)}
|
||||
size={isDm ? "default" : "lg"}
|
||||
tooltip={resolvedLabel}
|
||||
type="button"
|
||||
>
|
||||
<SidebarChannelIcon
|
||||
channel={channel}
|
||||
className={
|
||||
channel.channelType === "dm" ? undefined : inactiveContentOpacity
|
||||
}
|
||||
dmParticipants={dmParticipants}
|
||||
presenceStatus={presenceStatus}
|
||||
/>
|
||||
<span
|
||||
className={cn("min-w-0 flex-1 truncate", inactiveContentOpacity)}
|
||||
data-sidebar-row-label
|
||||
>
|
||||
{resolvedLabel}
|
||||
</span>
|
||||
{isDm ? (
|
||||
<SidebarChannelIcon
|
||||
channel={channel}
|
||||
dmParticipants={dmParticipants}
|
||||
presenceStatus={presenceStatus}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"flex size-10 shrink-0 items-center justify-center rounded-xl bg-sidebar-border/55 text-sidebar-foreground/65 transition-colors group-data-[collapsible=icon]:size-8 group-data-[collapsible=icon]:rounded-md group-data-[collapsible=icon]:bg-transparent",
|
||||
isActive &&
|
||||
"bg-sidebar-active-foreground/10 text-sidebar-active-foreground",
|
||||
inactiveContentOpacity,
|
||||
)}
|
||||
data-testid={`channel-icon-container-${channel.name}`}
|
||||
>
|
||||
<SidebarChannelIcon channel={channel} className="size-6" />
|
||||
</span>
|
||||
)}
|
||||
{isDm ? (
|
||||
<span
|
||||
className={cn("min-w-0 flex-1 truncate", inactiveContentOpacity)}
|
||||
data-sidebar-row-label
|
||||
>
|
||||
{resolvedLabel}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 flex-1 group-data-[collapsible=icon]:hidden",
|
||||
inactiveContentOpacity,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"block truncate text-sm leading-tight",
|
||||
hasTopLevelUnread ? "font-bold" : "font-medium",
|
||||
)}
|
||||
data-sidebar-row-label
|
||||
>
|
||||
{resolvedLabel}
|
||||
</span>
|
||||
<span
|
||||
className="mt-0.5 block truncate text-xs font-normal leading-tight text-sidebar-foreground/55"
|
||||
data-testid={`channel-subtitle-${channel.name}`}
|
||||
title={subtitle ?? undefined}
|
||||
>
|
||||
{subtitle}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{ephemeralDisplay ? (
|
||||
<EphemeralChannelBadge
|
||||
display={ephemeralDisplay}
|
||||
|
||||
@@ -227,14 +227,26 @@ function SidebarLoadingRow({
|
||||
}) {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<div className="flex h-8 items-center gap-2 rounded-md px-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md",
|
||||
avatar ? "h-8 px-2" : "h-12 p-1 pr-2",
|
||||
)}
|
||||
>
|
||||
<Skeleton
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
avatar ? "h-5 w-5 rounded-full" : "h-4 w-4 rounded-sm",
|
||||
avatar ? "h-5 w-5 rounded-full" : "size-10 rounded-xl",
|
||||
)}
|
||||
/>
|
||||
<Skeleton className={cn("h-4 min-w-0", widthClass)} />
|
||||
{avatar ? (
|
||||
<Skeleton className={cn("h-4 min-w-0", widthClass)} />
|
||||
) : (
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Skeleton className={cn("h-3.5 min-w-0", widthClass)} />
|
||||
<Skeleton className="h-3 w-full max-w-32" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ import { startLocalStorageSweep } from "@/shared/lib/localStorageSweep";
|
||||
import { initializeConversationDensityPreference } from "@/shared/lib/conversationDensityPreference";
|
||||
import { initializeFontSizePreference } from "@/shared/lib/fontSizePreference";
|
||||
import { initializeMessageStylePreference } from "@/shared/lib/messageStylePreference";
|
||||
import { initializeOwnMessageAlignmentPreference } from "@/shared/lib/ownMessageAlignmentPreference";
|
||||
|
||||
type E2eWindow = Window & {
|
||||
__BUZZ_E2E__?: unknown;
|
||||
@@ -130,6 +131,7 @@ async function bootstrap() {
|
||||
initializeConversationDensityPreference();
|
||||
initializeFontSizePreference();
|
||||
initializeMessageStylePreference();
|
||||
initializeOwnMessageAlignmentPreference();
|
||||
startLocalStorageSweep();
|
||||
await installE2eBridgeIfConfigured();
|
||||
await migrateLegacyCommunityStorageBeforeRender();
|
||||
|
||||
@@ -544,6 +544,7 @@ export async function sendChannelMessage(
|
||||
mentionTags?: string[][],
|
||||
linkPreviewTags?: string[][],
|
||||
sentFromThreadTag?: string[],
|
||||
createdAt?: number,
|
||||
): Promise<SendChannelMessageResult> {
|
||||
const response = await invokeTauri<RawSendChannelMessageResult>(
|
||||
"send_channel_message",
|
||||
@@ -558,6 +559,7 @@ export async function sendChannelMessage(
|
||||
sentFromThreadTag: sentFromThreadTag ?? null,
|
||||
mentionPubkeys: mentionPubkeys ?? null,
|
||||
kind: kind ?? null,
|
||||
createdAt: createdAt ?? null,
|
||||
},
|
||||
);
|
||||
return {
|
||||
@@ -580,7 +582,6 @@ export type BlobDescriptor = {
|
||||
thumb?: string;
|
||||
duration?: number;
|
||||
image?: string;
|
||||
/** Original filename captured client-side. */
|
||||
filename?: string;
|
||||
};
|
||||
|
||||
@@ -601,7 +602,6 @@ export async function pickAndUploadMedia(
|
||||
export async function uploadMediaBytes(
|
||||
data: number[],
|
||||
filename?: string,
|
||||
/** Correlation id for `media-upload-progress` events from the Rust side. */
|
||||
progressId?: string,
|
||||
): Promise<BlobDescriptor> {
|
||||
return invokeTauri<BlobDescriptor>("upload_media_bytes", {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
const values = new Map();
|
||||
const attributes = new Map();
|
||||
|
||||
globalThis.localStorage = {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => values.set(key, String(value)),
|
||||
};
|
||||
globalThis.document = {
|
||||
documentElement: {
|
||||
setAttribute: (name, value) => attributes.set(name, value),
|
||||
},
|
||||
};
|
||||
|
||||
const preference = await import("./ownMessageAlignmentPreference.ts");
|
||||
|
||||
test("defaults invalid and missing own-message alignments to left", () => {
|
||||
assert.equal(preference.parseOwnMessageAlignment(null), "left");
|
||||
assert.equal(preference.parseOwnMessageAlignment("center"), "left");
|
||||
assert.equal(preference.parseOwnMessageAlignment("left"), "left");
|
||||
assert.equal(preference.parseOwnMessageAlignment("right"), "right");
|
||||
});
|
||||
|
||||
test("persists and applies the selected own-message alignment", () => {
|
||||
preference.setOwnMessageAlignment("right");
|
||||
assert.equal(preference.getOwnMessageAlignment(), "right");
|
||||
assert.equal(preference.getSavedOwnMessageAlignment(), "right");
|
||||
assert.equal(
|
||||
values.get(preference.OWN_MESSAGE_ALIGNMENT_STORAGE_KEY),
|
||||
"right",
|
||||
);
|
||||
assert.equal(attributes.get("data-own-message-alignment"), "right");
|
||||
});
|
||||
|
||||
test("previews an alignment without changing the saved preference", () => {
|
||||
preference.setOwnMessageAlignment("left");
|
||||
preference.previewOwnMessageAlignment("right");
|
||||
assert.equal(preference.getOwnMessageAlignment(), "right");
|
||||
assert.equal(preference.getSavedOwnMessageAlignment(), "left");
|
||||
assert.equal(
|
||||
values.get(preference.OWN_MESSAGE_ALIGNMENT_STORAGE_KEY),
|
||||
"left",
|
||||
);
|
||||
assert.equal(attributes.get("data-own-message-alignment"), "right");
|
||||
|
||||
preference.previewOwnMessageAlignment(null);
|
||||
assert.equal(preference.getOwnMessageAlignment(), "left");
|
||||
assert.equal(attributes.get("data-own-message-alignment"), "left");
|
||||
});
|
||||
|
||||
test("initializes from the persisted own-message alignment", () => {
|
||||
values.set(preference.OWN_MESSAGE_ALIGNMENT_STORAGE_KEY, "right");
|
||||
preference.initializeOwnMessageAlignmentPreference();
|
||||
assert.equal(preference.getOwnMessageAlignment(), "right");
|
||||
assert.equal(preference.getSavedOwnMessageAlignment(), "right");
|
||||
});
|
||||
121
desktop/src/shared/lib/ownMessageAlignmentPreference.ts
Normal file
121
desktop/src/shared/lib/ownMessageAlignmentPreference.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import * as React from "react";
|
||||
|
||||
/** Horizontal placement for messages authored by the signed-in account. */
|
||||
export type OwnMessageAlignment = "left" | "right";
|
||||
|
||||
export const OWN_MESSAGE_ALIGNMENT_STORAGE_KEY =
|
||||
"buzz.appearance.ownMessageAlignment";
|
||||
export const DEFAULT_OWN_MESSAGE_ALIGNMENT: OwnMessageAlignment = "left";
|
||||
|
||||
const savedListeners = new Set<() => void>();
|
||||
const appliedListeners = new Set<() => void>();
|
||||
let savedOwnMessageAlignment: OwnMessageAlignment =
|
||||
DEFAULT_OWN_MESSAGE_ALIGNMENT;
|
||||
let appliedOwnMessageAlignment: OwnMessageAlignment =
|
||||
DEFAULT_OWN_MESSAGE_ALIGNMENT;
|
||||
|
||||
export function parseOwnMessageAlignment(
|
||||
value: string | null | undefined,
|
||||
): OwnMessageAlignment {
|
||||
return value === "left" || value === "right"
|
||||
? value
|
||||
: DEFAULT_OWN_MESSAGE_ALIGNMENT;
|
||||
}
|
||||
|
||||
function readStoredOwnMessageAlignment(): OwnMessageAlignment {
|
||||
try {
|
||||
return parseOwnMessageAlignment(
|
||||
globalThis.localStorage?.getItem(OWN_MESSAGE_ALIGNMENT_STORAGE_KEY),
|
||||
);
|
||||
} catch {
|
||||
return DEFAULT_OWN_MESSAGE_ALIGNMENT;
|
||||
}
|
||||
}
|
||||
|
||||
function applyOwnMessageAlignment(alignment: OwnMessageAlignment): void {
|
||||
globalThis.document?.documentElement?.setAttribute(
|
||||
"data-own-message-alignment",
|
||||
alignment,
|
||||
);
|
||||
}
|
||||
|
||||
function notify(listeners: Set<() => void>): void {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
/** Apply the persisted preference before React renders to avoid a layout jump. */
|
||||
export function initializeOwnMessageAlignmentPreference(): void {
|
||||
const nextAlignment = readStoredOwnMessageAlignment();
|
||||
const savedChanged = nextAlignment !== savedOwnMessageAlignment;
|
||||
const appliedChanged = nextAlignment !== appliedOwnMessageAlignment;
|
||||
savedOwnMessageAlignment = nextAlignment;
|
||||
appliedOwnMessageAlignment = nextAlignment;
|
||||
applyOwnMessageAlignment(nextAlignment);
|
||||
if (savedChanged) notify(savedListeners);
|
||||
if (appliedChanged) notify(appliedListeners);
|
||||
}
|
||||
|
||||
function subscribeSaved(listener: () => void): () => void {
|
||||
savedListeners.add(listener);
|
||||
return () => savedListeners.delete(listener);
|
||||
}
|
||||
|
||||
function subscribeApplied(listener: () => void): () => void {
|
||||
appliedListeners.add(listener);
|
||||
return () => appliedListeners.delete(listener);
|
||||
}
|
||||
|
||||
export function getOwnMessageAlignment(): OwnMessageAlignment {
|
||||
return appliedOwnMessageAlignment;
|
||||
}
|
||||
|
||||
export function getSavedOwnMessageAlignment(): OwnMessageAlignment {
|
||||
return savedOwnMessageAlignment;
|
||||
}
|
||||
|
||||
export function setOwnMessageAlignment(alignment: OwnMessageAlignment): void {
|
||||
const savedChanged = alignment !== savedOwnMessageAlignment;
|
||||
const appliedChanged = alignment !== appliedOwnMessageAlignment;
|
||||
savedOwnMessageAlignment = alignment;
|
||||
appliedOwnMessageAlignment = alignment;
|
||||
applyOwnMessageAlignment(alignment);
|
||||
try {
|
||||
globalThis.localStorage?.setItem(
|
||||
OWN_MESSAGE_ALIGNMENT_STORAGE_KEY,
|
||||
alignment,
|
||||
);
|
||||
} catch {
|
||||
// Persistence is best-effort; the live preference still applies.
|
||||
}
|
||||
if (savedChanged) notify(savedListeners);
|
||||
if (appliedChanged) notify(appliedListeners);
|
||||
}
|
||||
|
||||
/** Temporarily apply an alignment without changing the saved preference. */
|
||||
export function previewOwnMessageAlignment(
|
||||
alignment: OwnMessageAlignment | null,
|
||||
): void {
|
||||
const nextAlignment = alignment ?? savedOwnMessageAlignment;
|
||||
if (nextAlignment === appliedOwnMessageAlignment) return;
|
||||
appliedOwnMessageAlignment = nextAlignment;
|
||||
applyOwnMessageAlignment(nextAlignment);
|
||||
notify(appliedListeners);
|
||||
}
|
||||
|
||||
/** The currently applied alignment, including a temporary settings preview. */
|
||||
export function useOwnMessageAlignment(): OwnMessageAlignment {
|
||||
return React.useSyncExternalStore(
|
||||
subscribeApplied,
|
||||
getOwnMessageAlignment,
|
||||
() => DEFAULT_OWN_MESSAGE_ALIGNMENT,
|
||||
);
|
||||
}
|
||||
|
||||
/** The persisted alignment used to mark the selected settings option. */
|
||||
export function useSavedOwnMessageAlignment(): OwnMessageAlignment {
|
||||
return React.useSyncExternalStore(
|
||||
subscribeSaved,
|
||||
getSavedOwnMessageAlignment,
|
||||
() => DEFAULT_OWN_MESSAGE_ALIGNMENT,
|
||||
);
|
||||
}
|
||||
@@ -206,6 +206,38 @@
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* The signed-in account's right-aligned bubble uses the live primary token.
|
||||
Keep inline affordances legible without nesting opaque chips in that fill. */
|
||||
.message-markdown.message-bubble-own {
|
||||
color: hsl(var(--primary-foreground));
|
||||
}
|
||||
|
||||
.message-markdown.message-bubble-own .mention-chip,
|
||||
.message-markdown.message-bubble-own .mention-chip-hover:hover,
|
||||
.message-markdown.message-bubble-own a:not(.linear-link) {
|
||||
color: hsl(var(--primary-foreground));
|
||||
}
|
||||
|
||||
.message-markdown.message-bubble-own .inline-code-chip,
|
||||
.message-markdown.message-bubble-own :not(pre) > code {
|
||||
background: hsl(var(--primary-foreground) / 0.16);
|
||||
color: hsl(var(--primary-foreground));
|
||||
}
|
||||
|
||||
.message-markdown.message-bubble-own blockquote {
|
||||
border-color: hsl(var(--primary-foreground) / 0.35);
|
||||
color: hsl(var(--primary-foreground) / 0.82);
|
||||
}
|
||||
|
||||
.message-markdown.message-bubble-own :is(ol, ul) ::marker,
|
||||
.message-markdown.message-bubble-own h6 {
|
||||
color: hsl(var(--primary-foreground) / 0.82);
|
||||
}
|
||||
|
||||
.message-markdown.message-bubble-own hr {
|
||||
border-color: hsl(var(--primary-foreground) / 0.35);
|
||||
}
|
||||
|
||||
.message-markdown .agent-mention-at-hidden {
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
|
||||
@@ -22,6 +22,77 @@
|
||||
content-visibility: visible;
|
||||
}
|
||||
|
||||
.own-message-row [data-testid="message-content-column"] {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.own-message-row [data-testid="message-body-surface"] {
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.own-message-row [data-testid="message-reactions"] {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.own-message-row [data-testid="message-thread-summary"] {
|
||||
color: hsl(var(--primary-foreground) / 0.78);
|
||||
}
|
||||
|
||||
.own-message-row [data-testid="message-thread-summary-separator"] {
|
||||
color: hsl(var(--primary-foreground) / 0.42);
|
||||
}
|
||||
|
||||
.own-message-row [data-testid="message-thread-summary-last-reply"] {
|
||||
color: hsl(var(--primary-foreground) / 0.64);
|
||||
}
|
||||
|
||||
.own-message-row [data-testid="message-thread-summary-hover-action"] {
|
||||
color: hsl(var(--primary-foreground));
|
||||
}
|
||||
|
||||
.own-message-row
|
||||
[data-testid="message-thread-summary"]:is(:hover, :focus-visible) {
|
||||
color: hsl(var(--primary-foreground));
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.own-message-row
|
||||
[data-testid="message-thread-summary"]:is(:hover, :focus-visible)
|
||||
[data-testid="message-thread-summary-reply-count"] {
|
||||
color: hsl(var(--primary-foreground));
|
||||
}
|
||||
|
||||
.own-message-row
|
||||
[data-testid="message-thread-summary"]:is(:hover, :focus-visible)
|
||||
[data-testid="message-thread-summary-surface"] {
|
||||
--tw-ring-color: hsl(var(--primary-foreground) / 0.28);
|
||||
|
||||
background: hsl(var(--primary-foreground) / 0.12);
|
||||
}
|
||||
|
||||
.message-hover-timestamp {
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease-out;
|
||||
}
|
||||
|
||||
.message-hover-region:focus-within .message-hover-timestamp {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.message-hover-region:hover .message-hover-timestamp {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.message-hover-timestamp {
|
||||
transition-duration: 0ms;
|
||||
}
|
||||
}
|
||||
|
||||
.content-visibility-auto-interactive {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 200px;
|
||||
@@ -31,10 +102,10 @@
|
||||
content-visibility: visible;
|
||||
}
|
||||
|
||||
/* Compact variant for short single-line rows (sidebar channels, etc). */
|
||||
/* Compact variant for sidebar channel rows. */
|
||||
.content-visibility-auto-row {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 2rem;
|
||||
contain-intrinsic-size: auto 3rem;
|
||||
}
|
||||
|
||||
.buzz-huddle-tooltip {
|
||||
|
||||
@@ -5690,7 +5690,12 @@ async function relayQuery(
|
||||
|
||||
async function submitSignedEvent(
|
||||
config: E2eConfig | undefined,
|
||||
template: { kind: number; content: string; tags: string[][] },
|
||||
template: {
|
||||
kind: number;
|
||||
content: string;
|
||||
createdAt?: number;
|
||||
tags: string[][];
|
||||
},
|
||||
): Promise<{ event_id: string; accepted: boolean; message: string }> {
|
||||
const identity = getRelayIdentity(config);
|
||||
const signed = await signWithIdentity(identity, template);
|
||||
@@ -9067,6 +9072,7 @@ async function handleSendChannelMessage(
|
||||
linkPreviewTags?: string[][] | null;
|
||||
sentFromThreadTag?: string[] | null;
|
||||
suppressLinkPreviews?: boolean;
|
||||
createdAt?: number | null;
|
||||
},
|
||||
config: E2eConfig | undefined,
|
||||
): Promise<RawSendChannelMessageResponse> {
|
||||
@@ -9077,6 +9083,7 @@ async function handleSendChannelMessage(
|
||||
window.setTimeout(resolve, sendMessageDelayMs),
|
||||
);
|
||||
}
|
||||
const createdAt = args.createdAt ?? Math.floor(Date.now() / 1000);
|
||||
|
||||
// NIP-92 imeta attachments. The real relay echoes these back on the stored
|
||||
// event; mirror that here so attachment renderers (FileCard, images, video)
|
||||
@@ -9130,18 +9137,23 @@ async function handleSendChannelMessage(
|
||||
];
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
const createdAt = Math.floor(Date.now() / 1000);
|
||||
const mockPubkey = getMockMemberPubkey(config);
|
||||
|
||||
if (!args.parentEventId) {
|
||||
const event = createMockEvent(kind, args.content, [
|
||||
...buildTopLevelMessageTags(
|
||||
args.channelId,
|
||||
args.mentionPubkeys,
|
||||
mockPubkey,
|
||||
),
|
||||
...extraTags,
|
||||
]);
|
||||
const event = createMockEvent(
|
||||
kind,
|
||||
args.content,
|
||||
[
|
||||
...buildTopLevelMessageTags(
|
||||
args.channelId,
|
||||
args.mentionPubkeys,
|
||||
mockPubkey,
|
||||
),
|
||||
...extraTags,
|
||||
],
|
||||
mockPubkey,
|
||||
createdAt,
|
||||
);
|
||||
recordMockMessage(args.channelId, event);
|
||||
emitOrDeferMockSendMessageLiveEcho(args.channelId, event, config);
|
||||
|
||||
@@ -9235,6 +9247,7 @@ async function handleSendChannelMessage(
|
||||
const result = await submitSignedEvent(config, {
|
||||
kind,
|
||||
content: args.content.trim(),
|
||||
createdAt,
|
||||
tags: [...tags, ...extraTags],
|
||||
});
|
||||
|
||||
@@ -9243,7 +9256,7 @@ async function handleSendChannelMessage(
|
||||
parent_event_id: args.parentEventId ?? null,
|
||||
root_event_id: args.parentEventId ?? null,
|
||||
depth: args.parentEventId ? 1 : 0,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
created_at: createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -522,6 +522,27 @@ test("sidebar shows all channel types", async ({ page }) => {
|
||||
await expect(dmList).toContainText("bob-tyler");
|
||||
});
|
||||
|
||||
test("channel sidebar rows use a large icon tile and description", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
const iconContainer = page.getByTestId("channel-icon-container-general");
|
||||
await expect(iconContainer).toBeVisible();
|
||||
await expect(iconContainer).toHaveCSS("width", "40px");
|
||||
await expect(iconContainer).toHaveCSS("height", "40px");
|
||||
await expect(iconContainer.locator("svg")).toHaveCSS("width", "24px");
|
||||
await expect(iconContainer.locator("svg")).toHaveCSS("height", "24px");
|
||||
await expect(page.getByTestId("channel-subtitle-general")).toHaveText(
|
||||
"General discussion for everyone",
|
||||
);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("channel-alice-tyler")
|
||||
.locator('[data-testid^="channel-icon-container-"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("shows cached profile labels while relay profiles revalidate", {
|
||||
tag: CACHED_PROFILE_LABELS_TAG,
|
||||
}, async ({ page }) => {
|
||||
|
||||
@@ -237,6 +237,87 @@ test("upload a file and see a FileCard in the timeline", async ({ page }) => {
|
||||
.toContain("download_file");
|
||||
});
|
||||
|
||||
test("sends uploaded media before accompanying text as separate messages", async ({
|
||||
page,
|
||||
}) => {
|
||||
const text = `Notes for the attachment ${Date.now()}`;
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await choosePhoto(page);
|
||||
await expect(page.getByTestId("send-message")).toBeEnabled({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await page.getByTestId("message-input").fill(text);
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
const rows = page.getByTestId("message-row");
|
||||
const mediaRow = rows.filter({ has: page.getByTestId("file-card") }).last();
|
||||
const textRow = rows.filter({ hasText: text });
|
||||
await expect(mediaRow).toBeVisible();
|
||||
await expect(textRow).toBeVisible();
|
||||
await expect(mediaRow).not.toContainText(text);
|
||||
await expect(textRow.getByTestId("file-card")).toHaveCount(0);
|
||||
|
||||
const [mediaBox, textBox] = await Promise.all([
|
||||
mediaRow.boundingBox(),
|
||||
textRow.boundingBox(),
|
||||
]);
|
||||
if (!mediaBox || !textBox) {
|
||||
throw new Error("Expected separate media and text message geometry.");
|
||||
}
|
||||
expect(mediaBox.y).toBeLessThan(textBox.y);
|
||||
|
||||
const mediaSend = await page.evaluate(() =>
|
||||
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).find(
|
||||
(entry) => entry.command === "send_channel_message",
|
||||
),
|
||||
);
|
||||
const payload = mediaSend?.payload as
|
||||
| { content?: string; mediaTags?: string[][]; mentionPubkeys?: string[] }
|
||||
| undefined;
|
||||
expect(payload?.content).not.toContain(text);
|
||||
expect(payload?.mediaTags).toHaveLength(1);
|
||||
expect(payload?.mentionPubkeys).toEqual([]);
|
||||
});
|
||||
|
||||
test("does not restore already-sent media when the text send fails", async ({
|
||||
page,
|
||||
}) => {
|
||||
const text = `Retry only these notes ${Date.now()}`;
|
||||
await page.goto("/");
|
||||
await page.evaluate(() => {
|
||||
if (window.__BUZZ_E2E__?.mock) {
|
||||
window.__BUZZ_E2E__.mock.sendMessageErrors = [
|
||||
"Mock accompanying text send failed.",
|
||||
];
|
||||
}
|
||||
});
|
||||
await page.getByTestId("channel-general").click();
|
||||
await choosePhoto(page);
|
||||
await expect(page.getByTestId("send-message")).toBeEnabled({
|
||||
timeout: 5_000,
|
||||
});
|
||||
const input = page.getByTestId("message-input");
|
||||
await input.fill(text);
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
await expect(page.getByTestId("file-card")).toHaveCount(1);
|
||||
await expect(input).toContainText(text);
|
||||
await expect(page.getByTestId("message-composer")).not.toContainText(
|
||||
"quarterly-report.pdf",
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId("message-row").filter({ hasText: text }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await expect(page.getByTestId("send-message")).toBeEnabled();
|
||||
await input.press("Enter");
|
||||
await expect(
|
||||
page.getByTestId("message-row").filter({ hasText: text }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("file-card")).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("sends immediately and keeps upload progress across channels", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
||||
import { selectMessageQuickReaction } from "../helpers/messageActions";
|
||||
import { openMessageReactionPicker } from "../helpers/messageActions";
|
||||
import type { RelayEvent } from "../../src/shared/api/types";
|
||||
|
||||
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
|
||||
@@ -118,7 +118,7 @@ test("inbox reaction on a thread-reply mention persists after refetch", async ({
|
||||
},
|
||||
);
|
||||
|
||||
// Open the inbox item and react via the hover action bar's quick reaction.
|
||||
// Open the inbox item and react via the hover action bar.
|
||||
const item = page.getByTestId(`home-inbox-item-${replyEvent.id}`);
|
||||
await item.click();
|
||||
const detail = page.getByTestId("home-inbox-detail");
|
||||
@@ -145,35 +145,33 @@ test("inbox reaction on a thread-reply mention persists after refetch", async ({
|
||||
|
||||
await selectedMessage.hover();
|
||||
const actionBar = page.getByTestId(`message-action-bar-${replyEvent.id}`);
|
||||
const [actionBarBox, actionBarAnchorBox, bubbleBox, selectedMessageBox] =
|
||||
await Promise.all([
|
||||
actionBar.boundingBox(),
|
||||
actionBar.locator("..").boundingBox(),
|
||||
selectedMessage.getByTestId("message-body-surface").boundingBox(),
|
||||
selectedMessage.boundingBox(),
|
||||
]);
|
||||
const [actionBarBox, bubbleBox, selectedMessageBox] = await Promise.all([
|
||||
actionBar.boundingBox(),
|
||||
selectedMessage.getByTestId("message-body-surface").boundingBox(),
|
||||
selectedMessage.boundingBox(),
|
||||
]);
|
||||
expect(actionBarBox).not.toBeNull();
|
||||
expect(actionBarAnchorBox).not.toBeNull();
|
||||
expect(bubbleBox).not.toBeNull();
|
||||
expect(selectedMessageBox).not.toBeNull();
|
||||
if (
|
||||
!actionBarBox ||
|
||||
!actionBarAnchorBox ||
|
||||
!bubbleBox ||
|
||||
!selectedMessageBox
|
||||
) {
|
||||
if (!actionBarBox || !bubbleBox || !selectedMessageBox) {
|
||||
throw new Error("Inbox message action bar bounds were unavailable.");
|
||||
}
|
||||
expect(actionBarBox.y).toBeGreaterThanOrEqual(selectedMessageBox.y);
|
||||
expect(actionBarBox.x).toBeGreaterThanOrEqual(
|
||||
bubbleBox.x + bubbleBox.width + 7,
|
||||
);
|
||||
expect(
|
||||
Math.abs(
|
||||
actionBarAnchorBox.x +
|
||||
actionBarAnchorBox.width -
|
||||
(bubbleBox.x + bubbleBox.width),
|
||||
actionBarBox.y +
|
||||
actionBarBox.height / 2 -
|
||||
(bubbleBox.y + bubbleBox.height / 2),
|
||||
),
|
||||
).toBeLessThanOrEqual(1);
|
||||
|
||||
await selectMessageQuickReaction(selectedMessage, "React with :+1:");
|
||||
await openMessageReactionPicker(selectedMessage);
|
||||
const picker = page.locator("em-emoji-picker");
|
||||
await picker.locator("input[type='search']").fill("thumbs up");
|
||||
await picker.locator("button[aria-label='👍']").first().click();
|
||||
|
||||
// The pill must appear AND persist: the post-toggle refetch replaces the
|
||||
// optimistic state with fetched reaction events. Give the refetch time to
|
||||
|
||||
@@ -34,6 +34,15 @@ async function seedMessageBubbles(page: import("@playwright/test").Page) {
|
||||
});
|
||||
}
|
||||
|
||||
async function seedRightAlignedOwnBubbles(
|
||||
page: import("@playwright/test").Page,
|
||||
) {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("buzz.appearance.messageStyle", "bubbles");
|
||||
window.localStorage.setItem("buzz.appearance.ownMessageAlignment", "right");
|
||||
});
|
||||
}
|
||||
|
||||
test("pending continuation keeps Sending next to its timestamp", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -120,6 +129,72 @@ test("profile hover uses the channel hover surface", async ({ page }) => {
|
||||
.screenshot({ path: `${SHOTS}/profile-hover.png` });
|
||||
});
|
||||
|
||||
test("appearance previews right-aligned own-message bubbles", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
await page.getByTestId("open-settings").click();
|
||||
await page.getByTestId("profile-popover-settings").click();
|
||||
await page.getByTestId("settings-nav-appearance").click();
|
||||
|
||||
const displaySettings = page.getByTestId("conversation-display-group");
|
||||
await expect(displaySettings).toBeVisible();
|
||||
await expect(
|
||||
displaySettings.getByTestId("own-message-alignment-row"),
|
||||
).toHaveCount(0);
|
||||
|
||||
await displaySettings.getByTestId("message-style-bubbles").click();
|
||||
const alignmentRow = displaySettings.getByTestId("own-message-alignment-row");
|
||||
await expect(alignmentRow).toBeVisible();
|
||||
await alignmentRow.getByTestId("own-message-alignment-right").click();
|
||||
|
||||
const preview = displaySettings.getByTestId("conversation-preview");
|
||||
const otherMessage = preview
|
||||
.getByTestId("conversation-preview-message-surface")
|
||||
.first();
|
||||
const ownMessage = preview.locator('[data-own-message="true"]');
|
||||
const [otherBox, ownBox] = await Promise.all([
|
||||
otherMessage.boundingBox(),
|
||||
ownMessage.boundingBox(),
|
||||
]);
|
||||
if (!otherBox || !ownBox) {
|
||||
throw new Error("Expected both settings preview messages.");
|
||||
}
|
||||
expect(ownBox.x).toBeGreaterThan(otherBox.x);
|
||||
await expect(
|
||||
preview.getByTestId("conversation-preview-message-header"),
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
otherMessage.getByTestId("conversation-preview-message-header"),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
ownMessage.getByTestId("conversation-preview-message-header"),
|
||||
).toHaveCount(0);
|
||||
const otherPreviewSurfaceWrapper = otherMessage.locator("..");
|
||||
const otherPreviewTimestamp = otherPreviewSurfaceWrapper.getByTestId(
|
||||
"conversation-preview-hover-timestamp",
|
||||
);
|
||||
const ownPreviewSurfaceWrapper = ownMessage.locator("..");
|
||||
const ownPreviewTimestamp = ownPreviewSurfaceWrapper.getByTestId(
|
||||
"conversation-preview-hover-timestamp",
|
||||
);
|
||||
await expect(otherPreviewTimestamp).toHaveCSS("opacity", "0");
|
||||
await expect(ownPreviewTimestamp).toHaveCSS("opacity", "0");
|
||||
await otherPreviewSurfaceWrapper.hover();
|
||||
await expect(otherPreviewTimestamp).toHaveCSS("opacity", "1");
|
||||
await ownPreviewSurfaceWrapper.hover();
|
||||
await expect(ownPreviewTimestamp).toHaveCSS("opacity", "1");
|
||||
await expect(
|
||||
alignmentRow.getByText("Show your messages on the right."),
|
||||
).toBeVisible();
|
||||
|
||||
await waitForAnimations(page);
|
||||
await displaySettings.screenshot({
|
||||
path: `${SHOTS}/right-aligned-own-bubbles-setting.png`,
|
||||
});
|
||||
});
|
||||
|
||||
test("open messages use the compact ellipsis actions menu", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -244,9 +319,37 @@ test("left-aligned continuation bubbles join on the left edge", async ({
|
||||
page.getByTestId("message-row").filter({ hasText: content });
|
||||
const firstRow = rowFor(messages.first);
|
||||
const middleRow = rowFor(messages.middle);
|
||||
const lastRow = rowFor(messages.last);
|
||||
const isolatedRow = rowFor(messages.isolated);
|
||||
await expect(isolatedRow).toBeVisible();
|
||||
|
||||
const firstBubble = firstRow.getByTestId("message-body-surface");
|
||||
const lastBubble = lastRow.getByTestId("message-body-surface");
|
||||
await expect(firstRow.getByTestId("message-author")).toHaveCount(1);
|
||||
await expect(firstBubble.getByTestId("message-author")).toHaveCount(0);
|
||||
await expect(middleRow.getByTestId("message-author")).toHaveCount(0);
|
||||
await expect(lastRow.getByTestId("message-author")).toHaveCount(0);
|
||||
await expect(
|
||||
firstRow.locator('[data-testid^="message-avatar-"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
middleRow.locator('[data-testid^="message-avatar-"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
lastRow.getByTestId("message-bubble-avatar-anchor"),
|
||||
).toBeVisible();
|
||||
|
||||
const hoverTimestamp = lastRow.getByTestId("message-hover-timestamp");
|
||||
await expect(hoverTimestamp).toHaveCSS("opacity", "0");
|
||||
|
||||
const dayGroup = page.getByTestId("message-timeline-day-group").first();
|
||||
await expect(dayGroup).toBeVisible();
|
||||
expect(
|
||||
await dayGroup.evaluate(
|
||||
(element) => getComputedStyle(element, "::before").content,
|
||||
),
|
||||
).toBe("none");
|
||||
|
||||
const radii = async (content: string) =>
|
||||
rowFor(content)
|
||||
.getByTestId("message-body-surface")
|
||||
@@ -306,53 +409,60 @@ test("left-aligned continuation bubbles join on the left edge", async ({
|
||||
expect(mentionStyle.paddingLeft).toBe(0);
|
||||
expect(mentionStyle.paddingRight).toBe(0);
|
||||
|
||||
await middleRow.hover();
|
||||
const actionBar = middleRow.locator('[data-testid^="message-action-bar-"]');
|
||||
await lastRow.hover();
|
||||
await expect(hoverTimestamp).toHaveCSS("opacity", "1");
|
||||
const actionBar = lastRow.locator('[data-testid^="message-action-bar-"]');
|
||||
await expect(actionBar).toBeVisible();
|
||||
await expect(actionBar).toHaveAttribute("data-presentation", "menu");
|
||||
const [hoverBubble, actionBarBox, rootFontSize] = await Promise.all([
|
||||
middleRow.getByTestId("message-body-surface").boundingBox(),
|
||||
await expect(actionBar).toHaveAttribute("data-presentation", "inline");
|
||||
await expect(
|
||||
actionBar.getByRole("button", { name: "Open reactions" }),
|
||||
).toBeVisible();
|
||||
await expect(actionBar.getByRole("button", { name: "Reply" })).toBeVisible();
|
||||
await expect(
|
||||
actionBar.getByRole("button", { name: "More actions" }),
|
||||
).toBeVisible();
|
||||
await expect(actionBar.locator('[aria-label^="React with"]')).toHaveCount(0);
|
||||
const actionButtons = actionBar.getByRole("button");
|
||||
const [firstActionBox, secondActionBox] = await Promise.all([
|
||||
actionButtons.nth(0).boundingBox(),
|
||||
actionButtons.nth(1).boundingBox(),
|
||||
]);
|
||||
if (!firstActionBox || !secondActionBox) {
|
||||
throw new Error("Expected spaced inline action buttons.");
|
||||
}
|
||||
expect(firstActionBox.width).toBeGreaterThanOrEqual(28);
|
||||
expect(secondActionBox.x - (firstActionBox.x + firstActionBox.width)).toBe(4);
|
||||
const [hoverBubble, actionBarBox] = await Promise.all([
|
||||
lastBubble.boundingBox(),
|
||||
actionBar.boundingBox(),
|
||||
page.evaluate(() =>
|
||||
Number.parseFloat(getComputedStyle(document.documentElement).fontSize),
|
||||
),
|
||||
]);
|
||||
if (!hoverBubble || !actionBarBox) {
|
||||
throw new Error("Expected bubble and top-right action geometry.");
|
||||
throw new Error("Expected bubble and inline action geometry.");
|
||||
}
|
||||
expect(actionBarBox.x).toBeGreaterThanOrEqual(
|
||||
hoverBubble.x + hoverBubble.width + 7,
|
||||
);
|
||||
expect(
|
||||
Math.abs(
|
||||
actionBarBox.x +
|
||||
actionBarBox.width -
|
||||
hoverBubble.x -
|
||||
hoverBubble.width -
|
||||
rootFontSize * 0.5,
|
||||
actionBarBox.y +
|
||||
actionBarBox.height / 2 -
|
||||
(hoverBubble.y + hoverBubble.height / 2),
|
||||
),
|
||||
).toBeLessThanOrEqual(1);
|
||||
expect(
|
||||
Math.abs(actionBarBox.y - hoverBubble.y + rootFontSize * 0.5),
|
||||
).toBeLessThanOrEqual(1);
|
||||
|
||||
await middleRow.getByRole("button", { name: "More actions" }).click();
|
||||
await expect(page.getByRole("menu")).toBeVisible();
|
||||
const combinedTray = page.locator(
|
||||
'[data-testid^="message-quick-reactions-"]',
|
||||
);
|
||||
await expect(combinedTray).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/combined-message-actions.png` });
|
||||
await combinedTray.getByRole("button", { name: "Open reactions" }).click();
|
||||
await expect(page.locator("em-emoji-picker")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.locator("em-emoji-picker")).toHaveCount(0);
|
||||
await expect(page.getByRole("menu")).toBeVisible();
|
||||
await combinedTray.getByRole("button", { name: "React with :+1:" }).click();
|
||||
const reactionBar = middleRow.getByTestId("message-reactions");
|
||||
await page.screenshot({ path: `${SHOTS}/inline-message-actions.png` });
|
||||
await actionBar.getByRole("button", { name: "Open reactions" }).click();
|
||||
const picker = page.locator("em-emoji-picker");
|
||||
await expect(picker).toBeVisible();
|
||||
await picker.locator("input[type='search']").fill("thumbs up");
|
||||
await picker.locator("button[aria-label='👍']").first().click();
|
||||
const reactionBar = lastRow.getByTestId("message-reactions");
|
||||
await expect(reactionBar).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [bubble, reactions, firstPill] = await Promise.all([
|
||||
middleRow.getByTestId("message-body-surface").boundingBox(),
|
||||
lastBubble.boundingBox(),
|
||||
reactionBar.boundingBox(),
|
||||
reactionBar.getByRole("button").first().boundingBox(),
|
||||
]);
|
||||
@@ -378,6 +488,33 @@ test("left-aligned continuation bubbles join on the left edge", async ({
|
||||
expect(reactionPillStyle.backgroundColor).not.toBe("rgba(0, 0, 0, 0)");
|
||||
expect(reactionPillStyle.color).not.toContain("/");
|
||||
|
||||
const [avatarBox, lastBubbleBox, reactionBarBox] = await Promise.all([
|
||||
lastRow.locator('[data-testid^="message-avatar-"]').boundingBox(),
|
||||
lastBubble.boundingBox(),
|
||||
reactionBar.boundingBox(),
|
||||
]);
|
||||
if (!avatarBox || !lastBubbleBox || !reactionBarBox) {
|
||||
throw new Error("Expected final avatar, bubble, and reaction geometry.");
|
||||
}
|
||||
expect(
|
||||
Math.abs(
|
||||
avatarBox.y + avatarBox.height - (lastBubbleBox.y + lastBubbleBox.height),
|
||||
),
|
||||
).toBeLessThanOrEqual(1);
|
||||
expect(reactionBarBox.y + reactionBarBox.height).toBeGreaterThan(
|
||||
avatarBox.y + avatarBox.height,
|
||||
);
|
||||
|
||||
await actionBar.getByRole("button", { name: "More actions" }).click();
|
||||
await expect(page.getByRole("menu")).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-testid^="message-quick-reactions-"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Reply", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.waitForTimeout(1_200);
|
||||
await page.mouse.move(0, 0);
|
||||
await waitForAnimations(page);
|
||||
@@ -469,6 +606,248 @@ test("direct messages use the same grouped message bubbles", async ({
|
||||
await page.screenshot({ path: `${SHOTS}/direct-message-bubbles.png` });
|
||||
});
|
||||
|
||||
test("own-message bubbles align right and use the primary token", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedRightAlignedOwnBubbles(page);
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await waitForMockLiveSubscription(page, "general");
|
||||
|
||||
const suffix = Date.now();
|
||||
const otherContent = `Left-aligned message ${suffix}. ${"This longer message should use the wider conversation canvas without becoming difficult to scan. ".repeat(18)}`;
|
||||
const ownFirstContent = `My first right-aligned message ${suffix}`;
|
||||
const ownSecondContent = `My continued right-aligned message ${suffix}`;
|
||||
await page.evaluate(
|
||||
({ alicePubkey, other, ownFirst, ownSecond, timestamp }) => {
|
||||
const emit = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__;
|
||||
emit?.({
|
||||
channelName: "general",
|
||||
content: other,
|
||||
createdAt: timestamp,
|
||||
pubkey: alicePubkey,
|
||||
});
|
||||
const ownRoot = emit?.({
|
||||
channelName: "general",
|
||||
content: ownFirst,
|
||||
createdAt: timestamp + 1,
|
||||
});
|
||||
if (ownRoot) {
|
||||
emit?.({
|
||||
channelName: "general",
|
||||
content: `A reply to ${ownFirst}`,
|
||||
createdAt: timestamp + 2,
|
||||
parentEventId: ownRoot.id,
|
||||
pubkey: alicePubkey,
|
||||
});
|
||||
}
|
||||
emit?.({
|
||||
channelName: "general",
|
||||
content: ownSecond,
|
||||
createdAt: timestamp + 3,
|
||||
});
|
||||
},
|
||||
{
|
||||
alicePubkey: TEST_IDENTITIES.alice.pubkey,
|
||||
other: otherContent,
|
||||
ownFirst: ownFirstContent,
|
||||
ownSecond: ownSecondContent,
|
||||
timestamp: Math.floor(Date.now() / 1_000),
|
||||
},
|
||||
);
|
||||
|
||||
const rowFor = (content: string) =>
|
||||
page.getByTestId("message-row").filter({ hasText: content });
|
||||
const otherRow = rowFor(otherContent);
|
||||
const ownFirstRow = rowFor(ownFirstContent);
|
||||
const ownSecondRow = rowFor(ownSecondContent);
|
||||
await expect(ownSecondRow).toBeVisible();
|
||||
await expect(ownFirstRow).toHaveClass(/own-message-row/);
|
||||
await expect(otherRow).not.toHaveClass(/own-message-row/);
|
||||
|
||||
const [otherBubbleBox, ownBubbleBox] = await Promise.all([
|
||||
otherRow.getByTestId("message-body-surface").boundingBox(),
|
||||
ownFirstRow.getByTestId("message-body-surface").boundingBox(),
|
||||
]);
|
||||
if (!otherBubbleBox || !ownBubbleBox) {
|
||||
throw new Error("Expected own-message bubble geometry.");
|
||||
}
|
||||
expect(ownBubbleBox.x).toBeGreaterThan(otherBubbleBox.x);
|
||||
await expect(
|
||||
ownFirstRow.locator('[data-testid^="message-avatar-"]'),
|
||||
).toHaveCount(0);
|
||||
const ownHoverTimestamp = ownFirstRow.getByTestId("message-hover-timestamp");
|
||||
await expect(ownHoverTimestamp).toHaveCSS("opacity", "0");
|
||||
await ownFirstRow.getByTestId("message-body-surface").hover();
|
||||
await expect(ownHoverTimestamp).toHaveCSS("opacity", "1");
|
||||
const ownActions = ownFirstRow.locator(
|
||||
'[data-testid^="message-action-bar-"]',
|
||||
);
|
||||
await expect(ownActions).toHaveAttribute("data-presentation", "inline");
|
||||
await expect
|
||||
.poll(() =>
|
||||
ownActions.evaluate((actions) =>
|
||||
Number.parseFloat(getComputedStyle(actions).opacity),
|
||||
),
|
||||
)
|
||||
.toBeGreaterThan(0.8);
|
||||
const [timestampTransition, actionTransition] = await Promise.all([
|
||||
ownHoverTimestamp.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
delay: style.transitionDelay,
|
||||
duration: style.transitionDuration,
|
||||
};
|
||||
}),
|
||||
ownActions.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
delay: style.transitionDelay,
|
||||
duration: style.transitionDuration,
|
||||
};
|
||||
}),
|
||||
]);
|
||||
expect(timestampTransition).toEqual(actionTransition);
|
||||
expect(timestampTransition.delay).toBe("0s");
|
||||
await expect(
|
||||
ownActions.getByRole("button", { name: "Open reactions" }),
|
||||
).toBeVisible();
|
||||
await expect(ownActions.getByRole("button", { name: "Reply" })).toBeVisible();
|
||||
await expect(
|
||||
ownActions.getByRole("button", { name: "More actions" }),
|
||||
).toBeVisible();
|
||||
const ownActionsBox = await ownActions.boundingBox();
|
||||
if (!ownActionsBox) {
|
||||
throw new Error("Expected own-message inline action geometry.");
|
||||
}
|
||||
expect(ownActionsBox.x + ownActionsBox.width).toBeLessThanOrEqual(
|
||||
ownBubbleBox.x - 7,
|
||||
);
|
||||
expect(
|
||||
Math.abs(
|
||||
ownActionsBox.y +
|
||||
ownActionsBox.height / 2 -
|
||||
(ownBubbleBox.y + ownBubbleBox.height / 2),
|
||||
),
|
||||
).toBeLessThanOrEqual(2);
|
||||
|
||||
const ownBubbleStyle = await ownFirstRow
|
||||
.getByTestId("message-body-surface")
|
||||
.evaluate((bubble) => {
|
||||
const style = getComputedStyle(bubble);
|
||||
const reference = document.createElement("div");
|
||||
reference.className = "bg-primary text-primary-foreground";
|
||||
document.body.append(reference);
|
||||
const referenceStyle = getComputedStyle(reference);
|
||||
const result = {
|
||||
backgroundColor: style.backgroundColor,
|
||||
color: style.color,
|
||||
primaryBackgroundColor: referenceStyle.backgroundColor,
|
||||
primaryForegroundColor: referenceStyle.color,
|
||||
};
|
||||
reference.remove();
|
||||
return result;
|
||||
});
|
||||
expect(ownBubbleStyle.backgroundColor).toBe(
|
||||
ownBubbleStyle.primaryBackgroundColor,
|
||||
);
|
||||
expect(ownBubbleStyle.color).toBe(ownBubbleStyle.primaryForegroundColor);
|
||||
const ownActionColor = await ownActions
|
||||
.getByRole("button", { name: "Open reactions" })
|
||||
.evaluate((button) => getComputedStyle(button).color);
|
||||
expect(ownActionColor).not.toBe(ownBubbleStyle.primaryForegroundColor);
|
||||
await expect(ownFirstRow.locator(".message-markdown")).toHaveCSS(
|
||||
"text-align",
|
||||
"left",
|
||||
);
|
||||
await expect(ownFirstRow.getByTestId("message-author")).toHaveCount(0);
|
||||
const otherBubble = otherRow.getByTestId("message-body-surface");
|
||||
await expect(otherBubble.getByTestId("message-author")).toHaveCount(0);
|
||||
await expect(
|
||||
ownSecondRow.locator('[data-testid^="message-avatar-"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(otherRow.getByTestId("message-author")).toHaveCount(1);
|
||||
await expect(
|
||||
otherBubble.getByTestId("message-bubble-avatar-anchor"),
|
||||
).toBeVisible();
|
||||
|
||||
const otherHoverTimestamp = otherRow.getByTestId("message-hover-timestamp");
|
||||
await expect(
|
||||
otherHoverTimestamp.getByTestId("message-timestamp"),
|
||||
).toHaveCount(1);
|
||||
await expect(otherHoverTimestamp).toHaveCSS("opacity", "0");
|
||||
await otherBubble.hover();
|
||||
await expect(otherHoverTimestamp).toHaveCSS("opacity", "1");
|
||||
|
||||
const [otherAvatarBox, otherAlignedBubbleBox] = await Promise.all([
|
||||
otherRow.locator('[data-testid^="message-avatar-"]').boundingBox(),
|
||||
otherBubble.boundingBox(),
|
||||
]);
|
||||
if (!otherAvatarBox || !otherAlignedBubbleBox) {
|
||||
throw new Error("Expected incoming avatar and bubble geometry.");
|
||||
}
|
||||
expect(
|
||||
Math.abs(
|
||||
otherAvatarBox.y +
|
||||
otherAvatarBox.height -
|
||||
(otherAlignedBubbleBox.y + otherAlignedBubbleBox.height),
|
||||
),
|
||||
).toBeLessThanOrEqual(1);
|
||||
|
||||
const threadSummary = ownFirstRow.getByTestId("message-thread-summary");
|
||||
await expect(threadSummary).toBeVisible();
|
||||
const bubbleForeground = ownBubbleStyle.primaryForegroundColor;
|
||||
await threadSummary.hover();
|
||||
await expect(threadSummary).toHaveCSS("color", bubbleForeground);
|
||||
await expect(
|
||||
threadSummary.getByTestId("message-thread-summary-reply-count"),
|
||||
).toHaveCSS("color", bubbleForeground);
|
||||
await expect(
|
||||
threadSummary.getByTestId("message-thread-summary-hover-action"),
|
||||
).toHaveCSS("color", bubbleForeground);
|
||||
await expect(
|
||||
threadSummary.getByTestId("message-thread-summary-surface"),
|
||||
).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
|
||||
const contentColumnBox = await otherRow
|
||||
.getByTestId("message-content-column")
|
||||
.boundingBox();
|
||||
if (!contentColumnBox) {
|
||||
throw new Error("Expected own-message content-column geometry.");
|
||||
}
|
||||
expect(otherAlignedBubbleBox.width).toBeGreaterThan(
|
||||
contentColumnBox.width * (2 / 3),
|
||||
);
|
||||
expect(otherAlignedBubbleBox.width).toBeLessThanOrEqual(
|
||||
contentColumnBox.width * 0.72 + 1,
|
||||
);
|
||||
|
||||
const [firstRadii, secondRadii] = await Promise.all([
|
||||
ownFirstRow.getByTestId("message-body-surface").evaluate((bubble) => {
|
||||
const style = getComputedStyle(bubble);
|
||||
return {
|
||||
bottomLeft: Number.parseFloat(style.borderBottomLeftRadius),
|
||||
bottomRight: Number.parseFloat(style.borderBottomRightRadius),
|
||||
};
|
||||
}),
|
||||
ownSecondRow.getByTestId("message-body-surface").evaluate((bubble) => {
|
||||
const style = getComputedStyle(bubble);
|
||||
return {
|
||||
topLeft: Number.parseFloat(style.borderTopLeftRadius),
|
||||
topRight: Number.parseFloat(style.borderTopRightRadius),
|
||||
};
|
||||
}),
|
||||
]);
|
||||
expect(firstRadii.bottomRight).toBeLessThan(firstRadii.bottomLeft);
|
||||
expect(secondRadii.topRight).toBeLessThan(secondRadii.topLeft);
|
||||
|
||||
await ownFirstRow.getByTestId("message-body-surface").hover();
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/right-aligned-own-bubbles.png` });
|
||||
});
|
||||
|
||||
test("standalone huddle cards do not get a second message container", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -2187,8 +2187,7 @@ test("opens a single-level thread panel with inline expansion", async ({
|
||||
);
|
||||
|
||||
await rootMessage.hover();
|
||||
await rootMessage.getByRole("button", { name: "More actions" }).click();
|
||||
await page.getByRole("menuitem", { name: "Reply", exact: true }).click();
|
||||
await rootMessage.getByRole("button", { name: "Reply" }).click();
|
||||
await expect(threadPanel).toBeVisible();
|
||||
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
|
||||
"Welcome to #general",
|
||||
@@ -2356,63 +2355,55 @@ test("opens a single-level thread panel with inline expansion", async ({
|
||||
const inlineActions = firstReplyRow.locator(
|
||||
'[data-testid^="message-action-bar-"]',
|
||||
);
|
||||
await expect(inlineActions).toHaveAttribute("data-presentation", "menu");
|
||||
await expect(inlineActions).toHaveAttribute("data-presentation", "inline");
|
||||
await expect(inlineActions.locator('[aria-label^="React with"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(
|
||||
inlineActions.getByRole("button", { name: "Open reactions" }),
|
||||
).toHaveCount(0);
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
inlineActions.getByRole("button", { name: "Reply" }),
|
||||
).toHaveCount(0);
|
||||
const compactMoreActions = firstReplyRow.getByRole("button", {
|
||||
).toBeVisible();
|
||||
const moreActions = firstReplyRow.getByRole("button", {
|
||||
name: "More actions",
|
||||
});
|
||||
await expect(compactMoreActions).toBeVisible();
|
||||
const [compactMoreActionsBox, threadResizeHandleBox] = await Promise.all([
|
||||
compactMoreActions.boundingBox(),
|
||||
await expect(moreActions).toBeVisible();
|
||||
const [moreActionsBox, threadResizeHandleBox] = await Promise.all([
|
||||
moreActions.boundingBox(),
|
||||
page.getByTestId("right-auxiliary-pane-resize-handle").boundingBox(),
|
||||
]);
|
||||
if (!compactMoreActionsBox || !threadResizeHandleBox) {
|
||||
if (!moreActionsBox || !threadResizeHandleBox) {
|
||||
throw new Error("Expected measurable thread action gutter.");
|
||||
}
|
||||
expect(compactMoreActionsBox.x).toBeGreaterThanOrEqual(
|
||||
expect(moreActionsBox.x).toBeGreaterThanOrEqual(
|
||||
threadResizeHandleBox.x + threadResizeHandleBox.width,
|
||||
);
|
||||
const [inlineActionsBox, replyBubbleBox, rootFontSize] = await Promise.all([
|
||||
const [inlineActionsBox, replyBubbleBox] = await Promise.all([
|
||||
inlineActions.boundingBox(),
|
||||
firstReplyRow.getByTestId("message-body-surface").boundingBox(),
|
||||
page.evaluate(() =>
|
||||
Number.parseFloat(getComputedStyle(document.documentElement).fontSize),
|
||||
),
|
||||
]);
|
||||
if (!inlineActionsBox || !replyBubbleBox) {
|
||||
throw new Error("Expected top-right thread action geometry.");
|
||||
throw new Error("Expected inline thread action geometry.");
|
||||
}
|
||||
expect(inlineActionsBox.x).toBeGreaterThanOrEqual(
|
||||
replyBubbleBox.x + replyBubbleBox.width + 7,
|
||||
);
|
||||
expect(
|
||||
Math.abs(
|
||||
inlineActionsBox.x +
|
||||
inlineActionsBox.width -
|
||||
replyBubbleBox.x -
|
||||
replyBubbleBox.width -
|
||||
rootFontSize * 0.5,
|
||||
inlineActionsBox.y +
|
||||
inlineActionsBox.height / 2 -
|
||||
(replyBubbleBox.y + replyBubbleBox.height / 2),
|
||||
),
|
||||
).toBeLessThanOrEqual(1);
|
||||
expect(
|
||||
Math.abs(inlineActionsBox.y - replyBubbleBox.y + rootFontSize * 0.5),
|
||||
).toBeLessThanOrEqual(1);
|
||||
await compactMoreActions.click();
|
||||
await moreActions.click();
|
||||
await expect(page.getByRole("menu")).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-testid^="message-quick-reactions-"]'),
|
||||
).toBeVisible();
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Reply", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /^React with / }).first(),
|
||||
).toBeVisible();
|
||||
).toHaveCount(0);
|
||||
const [threadContentColumnBox, threadComposerBox, threadRowFrame] =
|
||||
await Promise.all([
|
||||
firstReplyRow.getByTestId("message-content-column").boundingBox(),
|
||||
@@ -2436,13 +2427,6 @@ test("opens a single-level thread panel with inline expansion", async ({
|
||||
(threadComposerBox.x + threadComposerBox.width),
|
||||
),
|
||||
).toBeLessThanOrEqual(1);
|
||||
expect(
|
||||
Math.abs(
|
||||
replyBubbleBox.x +
|
||||
replyBubbleBox.width -
|
||||
(threadComposerBox.x + threadComposerBox.width),
|
||||
),
|
||||
).toBeLessThanOrEqual(1);
|
||||
expect(
|
||||
Math.abs(threadRowFrame.contentLeft - threadComposerBox.x),
|
||||
).toBeLessThanOrEqual(1);
|
||||
@@ -2456,7 +2440,9 @@ test("opens a single-level thread panel with inline expansion", async ({
|
||||
await threadPanel.screenshot({
|
||||
path: "test-results/message-feedback/thread-inline-actions.png",
|
||||
});
|
||||
await page.getByRole("menuitem", { name: "Reply", exact: true }).click();
|
||||
await page.keyboard.press("Escape");
|
||||
await firstReplyRow.hover();
|
||||
await firstReplyRow.getByRole("button", { name: "Reply" }).click();
|
||||
|
||||
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
|
||||
"Welcome to #general",
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
import { selectMessageQuickReaction } from "../helpers/messageActions";
|
||||
import { openMessageReactionPicker } from "../helpers/messageActions";
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
|
||||
// Reaction ordering end-to-end guard.
|
||||
@@ -92,13 +92,16 @@ async function getReactionBubbleGeometry(
|
||||
};
|
||||
}
|
||||
|
||||
/** Click a quick-reaction tray button by emoji (hover → click tray button). */
|
||||
async function addQuickReaction(
|
||||
/** Add a reaction through the bubble's direct reaction button. */
|
||||
async function addReaction(
|
||||
row: import("@playwright/test").Locator,
|
||||
emoji: string,
|
||||
label: string,
|
||||
search: string,
|
||||
) {
|
||||
await selectMessageQuickReaction(row, `React with ${label}`);
|
||||
await openMessageReactionPicker(row);
|
||||
const picker = row.page().locator("em-emoji-picker");
|
||||
await picker.locator("input[type='search']").fill(search);
|
||||
await picker.locator(`button[aria-label='${emoji}']`).first().click();
|
||||
// Wait for the optimistic pill to appear before continuing.
|
||||
await expect(
|
||||
row
|
||||
@@ -125,11 +128,11 @@ test("reaction pills render left-to-right in the order reactions were added", as
|
||||
// Add three reactions in order: 👍 → ❤️ → 😂.
|
||||
// Wait >1 s between each so mock bridge timestamps differ by at least 1 Unix
|
||||
// second — the formatter sorts by created_at, so distinct seconds matter.
|
||||
await addQuickReaction(row, "👍", ":+1:");
|
||||
await addReaction(row, "👍", "thumbs up");
|
||||
await page.waitForTimeout(1100);
|
||||
await addQuickReaction(row, "❤️", ":heart:");
|
||||
await addReaction(row, "❤️", "red heart");
|
||||
await page.waitForTimeout(1100);
|
||||
await addQuickReaction(row, "😂", ":joy:");
|
||||
await addReaction(row, "😂", "face with tears of joy");
|
||||
|
||||
const pills = await getPillOrder(row);
|
||||
expect(pills).toEqual(["👍", "❤️", "😂"]);
|
||||
@@ -175,9 +178,9 @@ test("a later emoji that accrues more reactors stays to the right of an earlier
|
||||
// since the key invariant is positional stability: even if ❤️ had higher
|
||||
// count it must stay right of 👍.
|
||||
// We use 🎉 (added second) and verify it stays right of 👍 (added first).
|
||||
await addQuickReaction(row, "👍", ":+1:");
|
||||
await addReaction(row, "👍", "thumbs up");
|
||||
await page.waitForTimeout(1100);
|
||||
await addQuickReaction(row, "🎉", ":tada:");
|
||||
await addReaction(row, "🎉", "party popper");
|
||||
|
||||
// Both pills present in chronological order: 👍 left, 🎉 right.
|
||||
const afterAdd = await getPillOrder(row);
|
||||
|
||||
@@ -18,6 +18,14 @@ export async function selectMessageAction(row: Locator, name: string) {
|
||||
}
|
||||
|
||||
export async function openMessageReactionPicker(row: Locator) {
|
||||
await row.hover();
|
||||
const directTrigger = row.getByRole("button", { name: "Open reactions" });
|
||||
if (await directTrigger.count()) {
|
||||
await expect(directTrigger).toBeVisible();
|
||||
await directTrigger.click();
|
||||
return;
|
||||
}
|
||||
|
||||
const menu = await openMessageActions(row);
|
||||
await menu.getByRole("button", { name: "Open reactions" }).click();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user