diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 4f839638b9..e4e92818af 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -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>, mention_pubkeys: Option>, kind: Option, + created_at: Option, state: State<'_, AppState>, ) -> Result { 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, }) } diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 8fc7cfaf51..ba214a3def 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -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 && diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 030229dc24..ebac5d476a 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -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; onSendToChannel: ( message: TimelineMessage, @@ -128,6 +133,7 @@ export type ChannelPaneProps = { parentEventId: string | null; threadHeadId: string | null; } | null, + createdAt?: number, ) => Promise; onTargetReached?: (messageId: string) => void; onToggleReaction?: ( diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index d7b2e5a6fd..e8dca3e2e1 100644 --- a/desktop/src/features/channels/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -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 diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 3b7bff44ea..3a933f8ae6 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -111,9 +111,8 @@ export function HomeView({ homeInboxWidthPx < INBOX_SINGLE_COLUMN_BREAKPOINT_PX; const [filter, setFilter] = React.useState("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 = { diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index b5e5c4d388..e2b91e9460 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -122,6 +122,7 @@ type InboxDetailPaneProps = { mediaTags?: string[][]; mentionPubkeys: string[]; parentEventId: string | null; + createdAt?: number; }) => Promise; 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, diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx index 881b5407b0..e7f7256ca0 100644 --- a/desktop/src/features/home/ui/InboxMessageRow.tsx +++ b/desktop/src/features/home/ui/InboxMessageRow.tsx @@ -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 ? ( + + ) : null; const actionBarNode = - canReply || canToggleReactions || onEdit ? ( + canReply || canToggleReactions || onEdit || useGroupedBubbleLayout ? (
+ {alignOwnMessageRight ? hoverTimestampNode : null} 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} +
+ ) : null; + const profileAvatarNode = ( + + + + + + ); + const bubbleAvatarNode = + useGroupedBubbleLayout && + !alignOwnMessageRight && + !isFollowedByContinuation ? ( +
+ {profileAvatarNode}
) : null; @@ -161,13 +216,15 @@ export function InboxMessageRow({ ) : null}
{showMessageBubbles ? null : actionBarNode} - {isContinuation ? ( + {alignOwnMessageRight ? null : useGroupedBubbleLayout ? ( +
+ ) : isContinuation ? ( ) : ( -
- - - - - -
+
{profileAvatarNode}
)} -
- {isContinuation ? null : ( - - - - {message.authorLabel} - - +
+ {isContinuation || + (useGroupedBubbleLayout && alignOwnMessageRight) ? null : ( + + {alignOwnMessageRight ? null : ( + + + {message.authorLabel} + + + )} {message.isAgent ? ( ) : null} -

- {message.fullTimestampLabel} -

+ {useGroupedBubbleLayout ? null : ( +

+ {message.fullTimestampLabel} +

+ )}
)}
{showMessageBubbles ? (
0), )} data-testid="message-body-surface" > + {bubbleAvatarNode} {actionBarNode} + {useGroupedBubbleLayout && + (timelineMessage.pending || timelineMessage.edited) ? ( + + ) : null} { void handleReactionSelect(emoji); @@ -312,7 +390,12 @@ export function InboxMessageRow({ reactions={reactions} /> {reactionErrorMessage ? ( -

+

{reactionErrorMessage}

) : null} diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index e59cd72a91..61b4f751c2 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -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( diff --git a/desktop/src/features/messages/lib/outgoingMessageParts.test.mjs b/desktop/src/features/messages/lib/outgoingMessageParts.test.mjs new file mode 100644 index 0000000000..108f36c049 --- /dev/null +++ b/desktop/src/features/messages/lib/outgoingMessageParts.test.mjs @@ -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 }, + ]); +}); diff --git a/desktop/src/features/messages/lib/outgoingMessageParts.ts b/desktop/src/features/messages/lib/outgoingMessageParts.ts new file mode 100644 index 0000000000..8f10db5720 --- /dev/null +++ b/desktop/src/features/messages/lib/outgoingMessageParts.ts @@ -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; + nowSeconds?: number; + spoileredMediaUrls?: ReadonlySet; + 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; +} diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index 880296aceb..34bd62f9db 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -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({ More actions { @@ -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({ React {presentation === "menu" ? ( @@ -683,6 +690,7 @@ export const MessageActionBar = React.memo(function MessageActionBar({ {hasMoreMenuActions ? ( + + + ); +} + +export function MessageAvatarPosition({ + anchorToBubble = false, + children, +}: { + anchorToBubble?: boolean; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +export function MessageHoverTimestampGutter({ + createdAt, + isThreadReplyLayout, + time, +}: { + createdAt: number; + isThreadReplyLayout: boolean; + time: string; +}) { + return ( +
+ +
+ ); +} + +export function MessageHoverTimestamp({ + createdAt, + time, +}: { + createdAt: number; + time: string; +}) { + return ( +
+ +
+ ); +} + +export function MessageStatusMetadata({ + className, + edited = false, + pending = false, +}: { + className?: string; + edited?: boolean; + pending?: boolean; +}) { + if (!pending && !edited) { + return null; + } + + return ( + <> + {pending ? ( +

+ Sending… +

+ ) : null} + {edited ? ( + + +

+ (edited) +

+
+ This message has been edited +
+ ) : 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 ( +
+ +
+ ); +} diff --git a/desktop/src/features/messages/ui/MessageComposer.types.ts b/desktop/src/features/messages/ui/MessageComposer.types.ts index ddf987d0f3..70d6e1e579 100644 --- a/desktop/src/features/messages/ui/MessageComposer.types.ts +++ b/desktop/src/features/messages/ui/MessageComposer.types.ts @@ -91,6 +91,7 @@ export type MessageComposerProps = { parentEventId: string | null; threadHeadId: string | null; } | null, + createdAt?: number, ) => Promise; placeholder?: string; profiles?: UserProfileLookup; diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index dfe216ffbf..0f7ef59cfd 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -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( 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( ); - const continuationTimestampGutter = ( -
- -
- ); - - const avatarGutterNode = isDisplayedAsContinuation ? ( - continuationTimestampGutter - ) : message.pubkey ? ( - - - - ) : ( -
{avatarNode}
+ {avatarNode} + ); - + const visibleAvatarGutterNode = ( + {profileAvatarNode} + ); + const bubbleAvatarNode = + useGroupedBubbleLayout && + !alignOwnMessageRight && + !isFollowedByContinuation ? ( + + {profileAvatarNode} + + ) : null; + const avatarGutterNode = + alignOwnMessageRight ? null : useGroupedBubbleLayout ? ( +
+ ) : isDisplayedAsContinuation ? ( + + ) : ( + visibleAvatarGutterNode + ); const authorNode = message.pubkey ? ( {message.author} ) : ( @@ -525,13 +533,13 @@ export const MessageRow = React.memo( ownerPubkey={message.ownerPubkey} /> ) : null; - const anchorActionBarToBubble = showMessageBubbles && !hasStandaloneCardSurface; const actionBarNode = ( + ); + const hasStatusMetadata = message.pending || message.edited; + const statusMetadataNode = ( + ); - - const statusMetadataNode = - message.pending || message.edited ? ( - <> - {message.pending ? ( -

- Sending… -

- ) : null} - {message.edited ? ( - - -

(edited)

-
- This message has been edited -
- ) : null} - - ) : null; - const inlineMetadataNode = (
{statusMetadataNode}
); - const continuationMetadataNode = - isDisplayedAsContinuation && statusMetadataNode ? ( + !useGroupedBubbleLayout && + (isDisplayedAsContinuation || alignOwnMessageRight) && + hasStatusMetadata ? (
{statusMetadataNode}
) : null; - - const headerNode = isDisplayedAsContinuation ? null : ( - - {message.pubkey ? ( - - - - ) : ( - authorNode - )} - {agentOwnerNode} - {inlineMetadataNode} - {message.personaDisplayName && - message.personaDisplayName !== message.author ? ( - - {message.personaDisplayName} - - ) : null} - - ); - const bodyContainerClass = isDisplayedAsContinuation - ? "mt-0" - : bodyOffsetClass; + + + ) : ( + authorNode + )} + {agentOwnerNode} + {message.personaDisplayName && + message.personaDisplayName !== message.author ? ( + + {message.personaDisplayName} + + ) : null} + {useGroupedBubbleLayout ? null : inlineMetadataNode} + + ); + const bodyContainerClass = + isDisplayedAsContinuation || useGroupedBubbleLayout + ? "mt-0" + : bodyOffsetClass; const { radiusClass, rowSpacingClass: bubbleRowSpacingClass } = getMessageBubbleLayout( isDisplayedAsContinuation, isFollowedByContinuation, + alignOwnMessageRight ? "right" : "left", ); - const coreMessageBodyNode = ( <> @@ -639,6 +635,14 @@ export const MessageRow = React.memo( {continuationMetadataNode} ); + const bubbleStatusNode = + useGroupedBubbleLayout && hasStatusMetadata ? ( + + ) : null; const expandedDiffNode = expandedDiffId === message.id ? ( ); const bubbleMessageBodyNode = ( -
+
{hasStandaloneCardSurface ? (
{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(
+ {showHoverTimestamp && bubbleAlignment === "right" ? ( + + ) : null} + {showHoverTimestamp && bubbleAlignment === "left" ? ( + + ) : null}
); } diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index ac28c7f80d..8425c1e10e 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -91,6 +91,7 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & { parentEventId: string | null; threadHeadId: string | null; } | null, + createdAt?: number, ) => Promise; onSendToChannel?: ( message: TimelineMessage, diff --git a/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx b/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx index bf53c59648..800982d4e7 100644 --- a/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx +++ b/desktop/src/features/messages/ui/MessageThreadSummaryRow.tsx @@ -253,7 +253,10 @@ export function MessageThreadSummaryRow({
- + {summary.replyCount} {replyLabel} {unreadCount != null && unreadCount > 0 ? ( @@ -263,7 +266,10 @@ export function MessageThreadSummaryRow({ ) : null} {summary.lastReplyAt ? ( <> - + · diff --git a/desktop/src/features/messages/ui/NewMessageScreen.tsx b/desktop/src/features/messages/ui/NewMessageScreen.tsx index f94dd5d1e1..671996c3b4 100644 --- a/desktop/src/features/messages/ui/NewMessageScreen.tsx +++ b/desktop/src/features/messages/ui/NewMessageScreen.tsx @@ -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; diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index bf2da03f40..0af8ad2163 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -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(null); const hostRef = React.useRef(null); @@ -765,7 +771,11 @@ function VirtualizedTimelineRows({ const dayLabel = formatDayHeading(item.headingTimestamp); return (
{ + 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", + ); +}); diff --git a/desktop/src/features/messages/ui/messageBubbleLayout.ts b/desktop/src/features/messages/ui/messageBubbleLayout.ts index af97119d5d..1ecb03cd40 100644 --- a/desktop/src/features/messages/ui/messageBubbleLayout.ts +++ b/desktop/src/features/messages/ui/messageBubbleLayout.ts @@ -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" diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 4a721dcfed..5921e69527 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -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 >; 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 { diff --git a/desktop/src/features/settings/ui/AppearanceSettingsControls.tsx b/desktop/src/features/settings/ui/AppearanceSettingsControls.tsx index 77a19aa1c2..5bed543d4b 100644 --- a/desktop/src/features/settings/ui/AppearanceSettingsControls.tsx +++ b/desktop/src/features/settings/ui/AppearanceSettingsControls.tsx @@ -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 ( -
-
- {avatar} -
-
-
- - {author} - - - {timestamp} - +
+ {alignRight ? null : ( +
+ {avatar}
-
- {children} + )} +
+ {useGroupedBubbleLayout && alignRight ? null : ( +
+ + {author} + + {useGroupedBubbleLayout ? null : ( + + {timestamp} + + )} +
+ )} +
+ {useGroupedBubbleLayout ? ( + + {timestamp} + + ) : null} +
+ {children} +
@@ -215,7 +287,8 @@ function ConversationPreview() {

@@ -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} /> + {messageStyle === "bubbles" ? ( + +

+

My messages

+

+ {ownMessageAlignment === "right" + ? "Show your messages on the right." + : "Keep your messages aligned with the conversation."} +

+
+ + + ) : null}
); diff --git a/desktop/src/features/sidebar/lib/channelLabels.ts b/desktop/src/features/sidebar/lib/channelLabels.ts index 8408c34d8c..a8bfbb5de1 100644 --- a/desktop/src/features/sidebar/lib/channelLabels.ts +++ b/desktop/src/features/sidebar/lib/channelLabels.ts @@ -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}`; +} diff --git a/desktop/src/features/sidebar/ui/SidebarSection.test.mjs b/desktop/src/features/sidebar/ui/SidebarSection.test.mjs index 0a1990579a..d7c9c56880 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.test.mjs +++ b/desktop/src/features/sidebar/ui/SidebarSection.test.mjs @@ -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", + ); + }); +}); diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 1a6403fb24..9823ced175 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -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({ onSelectChannel(channel.id)} + size={isDm ? "default" : "lg"} tooltip={resolvedLabel} type="button" > - - - {resolvedLabel} - + {isDm ? ( + + ) : ( + + )} + {isDm ? ( + + {resolvedLabel} + + ) : ( + + + {resolvedLabel} + + + {subtitle} + + + )} {ephemeralDisplay ? ( -
+
- + {avatar ? ( + + ) : ( +
+ + +
+ )}
); diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index 47dc02075f..398598311d 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -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(); diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 8eb626a81d..789c34103f 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -544,6 +544,7 @@ export async function sendChannelMessage( mentionTags?: string[][], linkPreviewTags?: string[][], sentFromThreadTag?: string[], + createdAt?: number, ): Promise { const response = await invokeTauri( "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 { return invokeTauri("upload_media_bytes", { diff --git a/desktop/src/shared/lib/ownMessageAlignmentPreference.test.mjs b/desktop/src/shared/lib/ownMessageAlignmentPreference.test.mjs new file mode 100644 index 0000000000..36c4e89510 --- /dev/null +++ b/desktop/src/shared/lib/ownMessageAlignmentPreference.test.mjs @@ -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"); +}); diff --git a/desktop/src/shared/lib/ownMessageAlignmentPreference.ts b/desktop/src/shared/lib/ownMessageAlignmentPreference.ts new file mode 100644 index 0000000000..10dd9a767c --- /dev/null +++ b/desktop/src/shared/lib/ownMessageAlignmentPreference.ts @@ -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, + ); +} diff --git a/desktop/src/shared/styles/globals/markdown.css b/desktop/src/shared/styles/globals/markdown.css index b76e7f117c..a7927ad48a 100644 --- a/desktop/src/shared/styles/globals/markdown.css +++ b/desktop/src/shared/styles/globals/markdown.css @@ -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; diff --git a/desktop/src/shared/styles/globals/utilities.css b/desktop/src/shared/styles/globals/utilities.css index a9da737bce..f5217d8c46 100644 --- a/desktop/src/shared/styles/globals/utilities.css +++ b/desktop/src/shared/styles/globals/utilities.css @@ -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 { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 4188408a5d..1c8362a497 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -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 { @@ -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, }; } diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 4a55849920..6459b4d950 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -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 }) => { diff --git a/desktop/tests/e2e/file-attachment.spec.ts b/desktop/tests/e2e/file-attachment.spec.ts index d5680b6c90..edf07b4e60 100644 --- a/desktop/tests/e2e/file-attachment.spec.ts +++ b/desktop/tests/e2e/file-attachment.spec.ts @@ -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, }) => { diff --git a/desktop/tests/e2e/inbox-reactions.spec.ts b/desktop/tests/e2e/inbox-reactions.spec.ts index 74138a0cca..2252ab8898 100644 --- a/desktop/tests/e2e/inbox-reactions.spec.ts +++ b/desktop/tests/e2e/inbox-reactions.spec.ts @@ -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 diff --git a/desktop/tests/e2e/message-feedback-snapshots.spec.ts b/desktop/tests/e2e/message-feedback-snapshots.spec.ts index 677954c379..1ed1d486dd 100644 --- a/desktop/tests/e2e/message-feedback-snapshots.spec.ts +++ b/desktop/tests/e2e/message-feedback-snapshots.spec.ts @@ -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, }) => { diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 2b6dbc1296..8653c1cfff 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -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", diff --git a/desktop/tests/e2e/reaction-order.spec.ts b/desktop/tests/e2e/reaction-order.spec.ts index f28591839c..f84c01000f 100644 --- a/desktop/tests/e2e/reaction-order.spec.ts +++ b/desktop/tests/e2e/reaction-order.spec.ts @@ -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); diff --git a/desktop/tests/helpers/messageActions.ts b/desktop/tests/helpers/messageActions.ts index 71e0f0f970..fb078b5dfd 100644 --- a/desktop/tests/helpers/messageActions.ts +++ b/desktop/tests/helpers/messageActions.ts @@ -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(); }