Prototype conversational message layout

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-18 15:45:17 +01:00
parent 0b5a0fc6ac
commit e6bf4ba044
41 changed files with 1854 additions and 364 deletions

View File

@@ -1,4 +1,4 @@
use nostr::{Event, EventId, Keys, PublicKey};
use nostr::{Event, EventId, Keys, PublicKey, Timestamp};
use tauri::{AppHandle, State};
mod forum;
@@ -489,6 +489,7 @@ pub async fn send_channel_message(
sent_from_thread_tag: Option<Vec<String>>,
mention_pubkeys: Option<Vec<String>>,
kind: Option<u32>,
created_at: Option<u64>,
state: State<'_, AppState>,
) -> Result<SendChannelMessageResponse, String> {
let channel_uuid = uuid::Uuid::parse_str(&channel_id)
@@ -554,6 +555,11 @@ pub async fn send_channel_message(
}
};
let event_created_at = created_at.unwrap_or_else(|| Timestamp::now().as_secs());
let response_created_at = i64::try_from(event_created_at)
.map_err(|_| "message timestamp exceeds the supported range".to_string())?;
let builder = builder.custom_created_at(Timestamp::from(event_created_at));
let result = submit_event(builder, &state).await?;
let depth = match (&parent_event_id, &resolved_root) {
@@ -568,7 +574,7 @@ pub async fn send_channel_message(
root_event_id: resolved_root,
parent_event_id,
depth,
created_at: chrono::Utc::now().timestamp(),
created_at: response_created_at,
})
}

View File

@@ -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 &&

View File

@@ -106,6 +106,11 @@ export type ChannelPaneProps = {
mentionPubkeys: string[],
mediaTags?: string[][],
channelId?: string | null,
threadContext?: {
parentEventId: string | null;
threadHeadId: string | null;
} | null,
createdAt?: number,
) => Promise<void>;
onSendToChannel: (
message: TimelineMessage,
@@ -128,6 +133,7 @@ export type ChannelPaneProps = {
parentEventId: string | null;
threadHeadId: string | null;
} | null,
createdAt?: number,
) => Promise<void>;
onTargetReached?: (messageId: string) => void;
onToggleReaction?: (

View File

@@ -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

View File

@@ -111,9 +111,8 @@ export function HomeView({
homeInboxWidthPx < INBOX_SINGLE_COLUMN_BREAKPOINT_PX;
const [filter, setFilter] = React.useState<InboxFilter>("all");
const [unreadOnly, setUnreadOnly] = React.useState(false);
// Explicit selections are mirrored to the URL (`?item=`), so back/forward
// restores the detail pane each history entry was showing and reloads
// restore it from the URL. Default/automatic selection stays local-only —
// Explicit selections are mirrored to `?item=`, so navigation and reloads
// restore the detail pane. Default/automatic selection stays local-only —
// background data loads must never trigger navigations.
const { applyPatch: applyInboxSearchPatch, values: inboxSearchValues } =
useHistorySearchState(INBOX_SEARCH_KEYS);
@@ -834,6 +833,7 @@ export function HomeView({
mediaTags,
mentionPubkeys,
parentEventId,
createdAt,
}) => {
const channelId = selectedItem?.item.channelId;
if (!selectedItem || !channelId || !canReply) {
@@ -857,6 +857,7 @@ export function HomeView({
undefined,
emojiTags,
mentionTags,
...([undefined, undefined, createdAt] as const),
);
const authorPubkey = currentPubkey ?? itemToReply.item.pubkey;
const reply: InboxReply = {

View File

@@ -122,6 +122,7 @@ type InboxDetailPaneProps = {
mediaTags?: string[][];
mentionPubkeys: string[];
parentEventId: string | null;
createdAt?: number;
}) => Promise<void>;
onToggleReaction?: (
message: TimelineMessage,
@@ -678,6 +679,7 @@ function InboxMessageDetailPane({
agentPubkeys={agentPubkeys}
canReply={canReply}
channelId={item.item.channelId}
currentPubkey={currentPubkey}
isContinuation={isContinuation}
isFollowedByContinuation={isFollowedByContinuation}
isFirst={index === 0}
@@ -769,9 +771,17 @@ function InboxMessageDetailPane({
});
onEditTargetChange(null);
}}
onSend={(content, mentionPubkeys, mediaTags) =>
onSend={(
content,
mentionPubkeys,
mediaTags,
_channelId,
_threadContext,
createdAt,
) =>
onSendReply({
content,
createdAt,
mediaTags,
mentionPubkeys,
parentEventId: composerParentEventId,

View File

@@ -8,6 +8,10 @@ import type { TimelineMessage } from "@/features/messages/types";
import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAuthPubkey";
import { MessageActionBar } from "@/features/messages/ui/MessageActionBar";
import { MessageAgentOwner } from "@/features/messages/ui/MessageAgentOwner";
import {
MessageBubbleStatusMetadata,
MessageHoverTimestamp,
} from "@/features/messages/ui/MessageBubbleMetadata";
import {
MessageAuthorText,
MessageHeaderRow,
@@ -17,6 +21,7 @@ import {
getMessageBubbleBottomPaddingClass,
getMessageBubbleLayout,
MESSAGE_BUBBLE_CONTENT_PADDING_CLASSES,
MESSAGE_BUBBLE_MAX_WIDTH_CLASSES,
} from "@/features/messages/ui/messageBubbleLayout";
import { UnreadDivider } from "@/features/messages/ui/UnreadDivider";
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
@@ -24,6 +29,7 @@ import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { cn } from "@/shared/lib/cn";
import { useMessageStyle } from "@/shared/lib/messageStylePreference";
import { useOwnMessageAlignment } from "@/shared/lib/ownMessageAlignmentPreference";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Markdown } from "@/shared/ui/markdown";
import { hasLinkPreviewSuppression } from "@/features/messages/lib/formatTimelineMessages";
@@ -38,6 +44,7 @@ type InboxMessageRowProps = {
canReply: boolean;
/** Channel UUID for "Copy link" — passed straight through to MessageActionBar. */
channelId?: string | null;
currentPubkey?: string;
isContinuation?: boolean;
isFollowedByContinuation?: boolean;
isFirst?: boolean;
@@ -57,6 +64,7 @@ export function InboxMessageRow({
agentPubkeys,
canReply,
channelId = null,
currentPubkey,
isContinuation = false,
isFollowedByContinuation = false,
isFirst = false,
@@ -68,6 +76,14 @@ export function InboxMessageRow({
showUnreadBoundary = false,
}: InboxMessageRowProps) {
const showMessageBubbles = useMessageStyle() === "bubbles";
const ownMessageAlignment = useOwnMessageAlignment();
const useGroupedBubbleLayout = showMessageBubbles;
const isOwnMessage = Boolean(
currentPubkey &&
normalizePubkey(message.authorPubkey) === normalizePubkey(currentPubkey),
);
const alignOwnMessageRight =
useGroupedBubbleLayout && ownMessageAlignment === "right" && isOwnMessage;
const timelineMessage = React.useMemo(
() => toTimelineMessage(message),
[message],
@@ -108,21 +124,31 @@ export function InboxMessageRow({
const { radiusClass, rowSpacingClass } = getMessageBubbleLayout(
isContinuation,
isFollowedByContinuation,
alignOwnMessageRight ? "right" : "left",
);
const hoverTimestampNode = useGroupedBubbleLayout ? (
<MessageHoverTimestamp
createdAt={timelineMessage.createdAt}
time={timelineMessage.time}
/>
) : null;
const actionBarNode =
canReply || canToggleReactions || onEdit ? (
canReply || canToggleReactions || onEdit || useGroupedBubbleLayout ? (
<div
className={cn(
"absolute top-1 z-10",
showMessageBubbles ? "right-0" : "right-2",
"absolute z-10 flex items-center gap-2",
showMessageBubbles && "text-foreground",
showMessageBubbles
? isFirst
? "sm:top-3"
: "sm:top-2 sm:-translate-y-1/2"
: !isFirst && "sm:top-0 sm:-translate-y-1/2",
? cn(
"top-1/2 -translate-y-1/2",
alignOwnMessageRight ? "right-full mr-3" : "left-full ml-3",
)
: cn("right-2 top-1", !isFirst && "sm:top-0 sm:-translate-y-1/2"),
)}
>
{alignOwnMessageRight ? hoverTimestampNode : null}
<MessageActionBar
actionAlign={alignOwnMessageRight ? "end" : "start"}
channelId={channelId}
message={timelineMessage}
onEdit={onEdit ? () => onEdit(message) : undefined}
@@ -135,8 +161,37 @@ export function InboxMessageRow({
onReply={canReply ? () => onSelectReplyTarget(message) : undefined}
reactionErrorMessage={reactionErrorMessage}
reactions={reactions}
presentation="menu"
presentation={showMessageBubbles ? "inline" : "menu"}
/>
{alignOwnMessageRight ? null : hoverTimestampNode}
</div>
) : null;
const profileAvatarNode = (
<UserProfilePopover
botIdenticonValue={message.authorLabel}
pubkey={message.authorPubkey}
role={profileRole}
triggerElement="span"
>
<span className="inline-flex shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
<UserAvatar
avatarUrl={message.avatarUrl}
className="h-9 w-9 shrink-0"
displayName={message.authorLabel}
size="md"
/>
</span>
</UserProfilePopover>
);
const bubbleAvatarNode =
useGroupedBubbleLayout &&
!alignOwnMessageRight &&
!isFollowedByContinuation ? (
<div
className="absolute bottom-0 right-full mr-2.5"
data-testid="inbox-message-bubble-avatar-anchor"
>
{profileAvatarNode}
</div>
) : null;
@@ -161,13 +216,15 @@ export function InboxMessageRow({
) : null}
<article
className={cn(
"group/message relative z-10 mx-1 flex gap-2.5 rounded-2xl px-2 transition-colors",
"group/message message-hover-region relative z-10 mx-1 flex gap-2.5 rounded-2xl px-2 transition-colors",
showMessageBubbles
? rowSpacingClass
: "py-conversation-row hover:bg-muted/50 focus-within:bg-muted/50",
alignOwnMessageRight && "flex-row-reverse",
isContinuation ? "items-center" : "items-start",
)}
data-message-id={message.id}
data-own-message={isOwnMessage ? "true" : undefined}
data-testid={
message.isSelected
? "home-inbox-selected-message"
@@ -176,9 +233,11 @@ export function InboxMessageRow({
>
{showMessageBubbles ? null : actionBarNode}
{isContinuation ? (
{alignOwnMessageRight ? null : useGroupedBubbleLayout ? (
<div aria-hidden className="w-9 shrink-0" />
) : isContinuation ? (
<div
aria-hidden="true"
aria-hidden={isContinuation}
className="flex w-9 shrink-0 self-stretch items-start justify-end pt-0.5"
title={message.fullTimestampLabel}
>
@@ -190,73 +249,91 @@ export function InboxMessageRow({
</p>
</div>
) : (
<div className="relative shrink-0">
<UserProfilePopover
botIdenticonValue={message.authorLabel}
pubkey={message.authorPubkey}
role={profileRole}
triggerElement="span"
>
<span className="inline-flex shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
<UserAvatar
avatarUrl={message.avatarUrl}
className="h-9 w-9 shrink-0"
displayName={message.authorLabel}
size="md"
/>
</span>
</UserProfilePopover>
</div>
<div className="relative shrink-0">{profileAvatarNode}</div>
)}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
{isContinuation ? null : (
<MessageHeaderRow className="gap-x-2">
<UserProfilePopover
botIdenticonValue={message.authorLabel}
pubkey={message.authorPubkey}
role={profileRole}
triggerElement="span"
>
<MessageAuthorText className="block max-w-full">
{message.authorLabel}
</MessageAuthorText>
</UserProfilePopover>
<div
className={cn(
"flex min-w-0 flex-1 flex-col gap-0.5",
alignOwnMessageRight && "items-end",
)}
>
{isContinuation ||
(useGroupedBubbleLayout && alignOwnMessageRight) ? null : (
<MessageHeaderRow
className={cn(
"gap-x-2",
alignOwnMessageRight && "justify-end text-right",
)}
>
{alignOwnMessageRight ? null : (
<UserProfilePopover
botIdenticonValue={message.authorLabel}
pubkey={message.authorPubkey}
role={profileRole}
triggerElement="span"
>
<MessageAuthorText className="block max-w-full">
{message.authorLabel}
</MessageAuthorText>
</UserProfilePopover>
)}
{message.isAgent ? (
<MessageAgentOwner
ownerLabel={message.ownerLabel}
ownerPubkey={message.ownerPubkey}
/>
) : null}
<p
className="shrink-0 text-message-timestamp font-normal tabular-nums text-muted-foreground/55"
data-testid="inbox-message-timestamp"
>
{message.fullTimestampLabel}
</p>
{useGroupedBubbleLayout ? null : (
<p
className="shrink-0 text-message-timestamp font-normal tabular-nums text-muted-foreground/55"
data-testid="inbox-message-timestamp"
>
{message.fullTimestampLabel}
</p>
)}
</MessageHeaderRow>
)}
<div
className={cn(
isContinuation ? "mt-0" : "mt-conversation-body",
showMessageBubbles && "w-fit max-w-full",
isContinuation || useGroupedBubbleLayout
? "mt-0"
: "mt-conversation-body",
showMessageBubbles && "w-full max-w-full",
alignOwnMessageRight && "ml-auto",
)}
>
{showMessageBubbles ? (
<div
className={cn(
"relative w-fit max-w-full bg-muted/50",
"relative w-fit",
MESSAGE_BUBBLE_MAX_WIDTH_CLASSES,
alignOwnMessageRight
? "ml-auto bg-primary text-primary-foreground"
: "bg-muted/50",
MESSAGE_BUBBLE_CONTENT_PADDING_CLASSES,
radiusClass,
getMessageBubbleBottomPaddingClass(reactions.length > 0),
)}
data-testid="message-body-surface"
>
{bubbleAvatarNode}
{actionBarNode}
{useGroupedBubbleLayout &&
(timelineMessage.pending || timelineMessage.edited) ? (
<MessageBubbleStatusMetadata
edited={timelineMessage.edited}
isOwn={alignOwnMessageRight}
pending={timelineMessage.pending}
/>
) : null}
<Markdown
className={cn(
"message-bubble-markdown max-w-full text-left text-message text-foreground",
"message-bubble-markdown max-w-full text-left text-message",
alignOwnMessageRight
? "message-bubble-own text-primary-foreground"
: "text-foreground",
emojiOnly &&
"text-4xl leading-tight [&_p]:leading-tight [&_img[data-custom-emoji]]:h-[1.45em] [&_img[data-custom-emoji]]:align-middle [&_button:has(img[data-custom-emoji])]:align-middle",
)}
@@ -297,6 +374,7 @@ export function InboxMessageRow({
)}
<MessageReactions
canToggle={canToggleReactions}
className={alignOwnMessageRight ? "justify-end" : undefined}
messageId={message.id}
onSelect={(emoji) => {
void handleReactionSelect(emoji);
@@ -312,7 +390,12 @@ export function InboxMessageRow({
reactions={reactions}
/>
{reactionErrorMessage ? (
<p className="mt-1.5 text-xs text-destructive">
<p
className={cn(
"mt-1.5 text-xs text-destructive",
alignOwnMessageRight && "text-right",
)}
>
{reactionErrorMessage}
</p>
) : null}

View File

@@ -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(

View File

@@ -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 },
]);
});

View File

@@ -0,0 +1,52 @@
import { buildOutgoingMessage, type ImetaMedia } from "./imetaMediaMarkdown";
export type OutgoingMessagePart = {
content: string;
createdAt?: number;
kind: "media" | "text";
outgoingTags: string[][] | undefined;
};
/**
* Build the ordered events for one composer submission. Attachments remain
* together in a media-only event and precede any authored text event.
*/
export function buildOutgoingMessageParts({
body,
media,
nowSeconds = Math.floor(Date.now() / 1_000),
spoileredMediaUrls = new Set(),
textTags = [],
}: {
body: string;
media: ReadonlyArray<ImetaMedia>;
nowSeconds?: number;
spoileredMediaUrls?: ReadonlySet<string>;
textTags?: string[][];
}): OutgoingMessagePart[] {
const parts: OutgoingMessagePart[] = [];
const hasText = body.trim().length > 0;
if (media.length > 0) {
const mediaMessage = buildOutgoingMessage("", media, spoileredMediaUrls);
parts.push({
content: mediaMessage.content,
// Relay events sort by whole-second timestamps and then event id. Give
// the media half of a mixed submission the immediately preceding second
// so every client renders it first without delaying either network send.
createdAt: hasText ? Math.max(0, nowSeconds - 1) : undefined,
kind: "media",
outgoingTags: mediaMessage.mediaTags,
});
}
if (hasText) {
parts.push({
content: body,
kind: "text",
outgoingTags: textTags.length > 0 ? textTags : undefined,
});
}
return parts;
}

View File

@@ -74,9 +74,11 @@ type MoreActionsMenuProps = {
side?: "bottom" | "top";
isFollowingThread?: boolean;
isUnread?: boolean;
align?: "end" | "start";
};
function MoreActionsMenu({
align = "end",
channelId,
compact = false,
message,
@@ -146,7 +148,7 @@ function MoreActionsMenu({
<TooltipContent>More actions</TooltipContent>
</Tooltip>
<DropdownMenuContent
align="end"
align={align}
side={side}
sideOffset={6}
onCloseAutoFocus={(event) => {
@@ -415,6 +417,7 @@ function isCustomEmojiShortcode(emoji: string) {
}
export const MessageActionBar = React.memo(function MessageActionBar({
actionAlign = "end",
channelId,
message,
onDelete,
@@ -435,6 +438,7 @@ export const MessageActionBar = React.memo(function MessageActionBar({
presentation = "tray",
actions = "all",
}: {
actionAlign?: "end" | "start";
/** Channel UUID — required for the "Copy link" action; when omitted the
* action is hidden (callers like the home inbox that lack the context). */
channelId?: string | null;
@@ -549,7 +553,7 @@ export const MessageActionBar = React.memo(function MessageActionBar({
<TooltipContent>React</TooltipContent>
</Tooltip>
<PopoverContent
align="end"
align={actionAlign}
className="w-auto overflow-hidden rounded-2xl border-0 bg-transparent p-0 shadow-none"
side="top"
sideOffset={10}
@@ -593,6 +597,7 @@ export const MessageActionBar = React.memo(function MessageActionBar({
className={cn(
"transition-opacity duration-150 ease-out",
presentation === "tray" && "-m-1 p-1",
presentation === "inline" && "p-1",
"opacity-100 sm:pointer-events-none sm:opacity-0",
"sm:group-hover/message:pointer-events-auto sm:group-hover/message:opacity-100",
"sm:group-focus-within/message:pointer-events-auto sm:group-focus-within/message:opacity-100",
@@ -614,7 +619,9 @@ export const MessageActionBar = React.memo(function MessageActionBar({
"flex items-center gap-0.5",
presentation === "tray"
? "p-1"
: "gap-0 bg-transparent [&_button]:h-6 [&_button]:w-6",
: presentation === "inline"
? "gap-1 bg-transparent [&_button]:h-7 [&_button]:w-7"
: "gap-0 bg-transparent [&_button]:h-6 [&_button]:w-6",
)}
>
{presentation === "menu" ? (
@@ -683,6 +690,7 @@ export const MessageActionBar = React.memo(function MessageActionBar({
{hasMoreMenuActions ? (
<MoreActionsMenu
align={actionAlign}
channelId={channelId}
message={message}
onDelete={onDelete}

View File

@@ -0,0 +1,171 @@
import type { ReactNode } from "react";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { cn } from "@/shared/lib/cn";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { MessageTimestamp } from "./MessageTimestamp";
export function MessageAvatarProfile({
author,
children,
pubkey,
role,
}: {
author: string;
children: ReactNode;
pubkey?: string;
role?: string;
}) {
if (!pubkey) {
return children;
}
return (
<UserProfilePopover botIdenticonValue={author} pubkey={pubkey} role={role}>
<button
className="flex shrink-0 items-start rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
{children}
</button>
</UserProfilePopover>
);
}
export function MessageAvatarPosition({
anchorToBubble = false,
children,
}: {
anchorToBubble?: boolean;
children: ReactNode;
}) {
return (
<div
className={
anchorToBubble
? "absolute bottom-0 right-full mr-2.5"
: "flex shrink-0 items-start"
}
data-testid={
anchorToBubble
? "message-bubble-avatar-anchor"
: "message-avatar-gutter"
}
>
{children}
</div>
);
}
export function MessageHoverTimestampGutter({
createdAt,
isThreadReplyLayout,
time,
}: {
createdAt: number;
isThreadReplyLayout: boolean;
time: string;
}) {
return (
<div
aria-hidden
className={cn(
"relative flex w-9 shrink-0 items-start justify-end pt-0.5",
isThreadReplyLayout ? "self-start" : "self-stretch",
)}
>
<MessageTimestamp
className="opacity-0 transition-opacity group-hover/message:opacity-100 group-focus-within/message:opacity-100"
createdAt={createdAt}
hideDayPeriod
time={time}
/>
</div>
);
}
export function MessageHoverTimestamp({
createdAt,
time,
}: {
createdAt: number;
time: string;
}) {
return (
<div
className="message-hover-timestamp pointer-events-none flex shrink-0 items-center"
data-testid="message-hover-timestamp"
>
<MessageTimestamp createdAt={createdAt} hideDayPeriod time={time} />
</div>
);
}
export function MessageStatusMetadata({
className,
edited = false,
pending = false,
}: {
className?: string;
edited?: boolean;
pending?: boolean;
}) {
if (!pending && !edited) {
return null;
}
return (
<>
{pending ? (
<p
className={cn("font-normal text-muted-foreground/70", className)}
data-testid="message-send-status"
>
Sending…
</p>
) : null}
{edited ? (
<Tooltip>
<TooltipTrigger asChild>
<p className={cn("text-muted-foreground/70", className)}>
(edited)
</p>
</TooltipTrigger>
<TooltipContent>This message has been edited</TooltipContent>
</Tooltip>
) : null}
</>
);
}
export function MessageBubbleStatusMetadata({
edited = false,
isOwn = false,
pending = false,
}: {
edited?: boolean;
isOwn?: boolean;
pending?: boolean;
}) {
const ownMetadataClass = isOwn ? "text-primary-foreground/70!" : undefined;
if (!pending && !edited) {
return null;
}
return (
<div
className={cn(
"mb-1 flex min-h-4 min-w-0 items-baseline gap-1.5 text-xs leading-message-author",
isOwn && "justify-end",
)}
data-testid="message-bubble-status"
>
<MessageStatusMetadata
className={ownMetadataClass}
edited={edited}
pending={pending}
/>
</div>
);
}

View File

@@ -91,6 +91,7 @@ export type MessageComposerProps = {
parentEventId: string | null;
threadHeadId: string | null;
} | null,
createdAt?: number,
) => Promise<void>;
placeholder?: string;
profiles?: UserProfileLookup;

View File

@@ -34,6 +34,7 @@ import {
import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAuthPubkey";
import { cn } from "@/shared/lib/cn";
import { useMessageStyle } from "@/shared/lib/messageStylePreference";
import { useOwnMessageAlignment } from "@/shared/lib/ownMessageAlignmentPreference";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
@@ -51,6 +52,13 @@ import { hasLinkPreviewSuppression } from "@/features/messages/lib/formatTimelin
import { toast } from "sonner";
import { MessageAgentOwner } from "./MessageAgentOwner";
import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader";
import {
MessageAvatarPosition,
MessageAvatarProfile,
MessageBubbleStatusMetadata,
MessageHoverTimestampGutter,
MessageStatusMetadata,
} from "./MessageBubbleMetadata";
import { MessageTimestamp } from "./MessageTimestamp";
import { MessageRowActions } from "./MessageRowActions";
import type {
@@ -61,15 +69,16 @@ import {
getMessageBubbleBottomPaddingClass,
getMessageBubbleLayout,
MESSAGE_BUBBLE_CONTENT_PADDING_CLASSES,
MESSAGE_BUBBLE_MAX_WIDTH_CLASSES,
} from "./messageBubbleLayout";
import { SentFromThreadLine } from "./SentFromThreadLine";
import { WaveMessageAttachment } from "./WaveMessageAttachment";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
const DiffMessage = React.lazy(() => import("./DiffMessage"));
const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded"));
const MESSAGE_BODY_SURFACE_CLASSES = cn(
"w-fit max-w-full bg-muted/50",
"w-fit bg-muted/50",
MESSAGE_BUBBLE_MAX_WIDTH_CLASSES,
MESSAGE_BUBBLE_CONTENT_PADDING_CLASSES,
);
@@ -121,6 +130,7 @@ export const MessageRow = React.memo(
// it into a grouped message row with no header.
const isDisplayedAsContinuation = isContinuation && !message.pending;
const showMessageBubbles = useMessageStyle() === "bubbles";
const ownMessageAlignment = useOwnMessageAlignment();
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
null,
);
@@ -258,6 +268,12 @@ export const MessageRow = React.memo(
message.kind === KIND_HUDDLE_STARTED ||
message.kind === KIND_STREAM_MESSAGE_DIFF ||
waveMessage !== null;
const useGroupedBubbleLayout =
showMessageBubbles && !hasStandaloneCardSurface;
const alignOwnMessageRight =
useGroupedBubbleLayout &&
ownMessageAlignment === "right" &&
message.accent;
const bodyOffsetClass = emojiOnly ? "mt-1" : "mt-conversation-body";
const { nonDmChannelNames: channelNames } = useChannelNavigation();
@@ -378,8 +394,9 @@ export const MessageRow = React.memo(
<Markdown
channelNames={channelNames}
className={cn(
"max-w-full text-message",
"max-w-full text-left text-message",
showMessageBubbles && "message-bubble-markdown",
alignOwnMessageRight && "message-bubble-own",
emojiOnly &&
"text-4xl leading-tight [&_p]:leading-tight [&_img[data-custom-emoji]]:h-[1.45em] [&_img[data-custom-emoji]]:align-middle [&_button:has(img[data-custom-emoji])]:align-middle",
)}
@@ -429,8 +446,6 @@ export const MessageRow = React.memo(
const isThreadReplyLayout = layoutVariant === "thread-reply";
const guideBleedRem = isThreadReplyLayout ? 0.25 : 0;
const avatarButtonRadiusClass = "rounded-full";
const showRespondToIndicator =
message.respondTo === "anyone" || message.respondTo === "allowlist";
@@ -475,45 +490,38 @@ export const MessageRow = React.memo(
</div>
);
const continuationTimestampGutter = (
<div
aria-hidden
className={cn(
"relative flex w-9 shrink-0 justify-end items-start pt-0.5",
isThreadReplyLayout ? "self-start" : "self-stretch",
)}
>
<MessageTimestamp
className="opacity-0 transition-opacity group-hover/message:opacity-100 group-focus-within/message:opacity-100"
createdAt={message.createdAt}
hideDayPeriod
time={message.time}
/>
</div>
);
const avatarGutterNode = isDisplayedAsContinuation ? (
continuationTimestampGutter
) : message.pubkey ? (
<UserProfilePopover
const profileAvatarNode = (
<MessageAvatarProfile
author={message.author}
pubkey={message.pubkey}
role={profilePopoverRole}
botIdenticonValue={message.author}
>
<button
className={cn(
"flex shrink-0 items-start focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
avatarButtonRadiusClass,
)}
type="button"
>
{avatarNode}
</button>
</UserProfilePopover>
) : (
<div className="flex shrink-0 items-start">{avatarNode}</div>
{avatarNode}
</MessageAvatarProfile>
);
const visibleAvatarGutterNode = (
<MessageAvatarPosition>{profileAvatarNode}</MessageAvatarPosition>
);
const bubbleAvatarNode =
useGroupedBubbleLayout &&
!alignOwnMessageRight &&
!isFollowedByContinuation ? (
<MessageAvatarPosition anchorToBubble>
{profileAvatarNode}
</MessageAvatarPosition>
) : null;
const avatarGutterNode =
alignOwnMessageRight ? null : useGroupedBubbleLayout ? (
<div aria-hidden className="w-9 shrink-0" />
) : isDisplayedAsContinuation ? (
<MessageHoverTimestampGutter
createdAt={message.createdAt}
isThreadReplyLayout={isThreadReplyLayout}
time={message.time}
/>
) : (
visibleAvatarGutterNode
);
const authorNode = message.pubkey ? (
<MessageAuthorText hoverUnderline>{message.author}</MessageAuthorText>
) : (
@@ -525,13 +533,13 @@ export const MessageRow = React.memo(
ownerPubkey={message.ownerPubkey}
/>
) : null;
const anchorActionBarToBubble =
showMessageBubbles && !hasStandaloneCardSurface;
const actionBarNode = (
<MessageRowActions
anchorToBubble={anchorActionBarToBubble}
bubbleAlignment={alignOwnMessageRight ? "right" : "left"}
channelId={channelId}
isFollowingThread={isFollowingThread}
isUnread={isUnread}
@@ -556,82 +564,70 @@ export const MessageRow = React.memo(
reactionErrorMessage={reactionErrorMessage}
reactions={reactions}
showMessageBubbles={showMessageBubbles}
showHoverTimestamp={useGroupedBubbleLayout}
/>
);
const hasStatusMetadata = message.pending || message.edited;
const statusMetadataNode = (
<MessageStatusMetadata
edited={message.edited}
pending={message.pending}
/>
);
const statusMetadataNode =
message.pending || message.edited ? (
<>
{message.pending ? (
<p
className="font-normal text-muted-foreground/70"
data-testid="message-send-status"
>
Sending…
</p>
) : null}
{message.edited ? (
<Tooltip>
<TooltipTrigger asChild>
<p className="text-muted-foreground/70">(edited)</p>
</TooltipTrigger>
<TooltipContent>This message has been edited</TooltipContent>
</Tooltip>
) : null}
</>
) : null;
const inlineMetadataNode = (
<div className="flex shrink-0 items-baseline gap-2 text-xs">
<MessageTimestamp createdAt={message.createdAt} time={message.time} />
{statusMetadataNode}
</div>
);
const continuationMetadataNode =
isDisplayedAsContinuation && statusMetadataNode ? (
!useGroupedBubbleLayout &&
(isDisplayedAsContinuation || alignOwnMessageRight) &&
hasStatusMetadata ? (
<div className="mt-0.5 flex items-baseline gap-2 text-xs">
{statusMetadataNode}
</div>
) : null;
const headerNode = isDisplayedAsContinuation ? null : (
<MessageHeaderRow>
{message.pubkey ? (
<UserProfilePopover
pubkey={message.pubkey}
role={profilePopoverRole}
botIdenticonValue={message.author}
>
<button
className="truncate rounded leading-message-author focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
type="button"
const headerNode =
isDisplayedAsContinuation ||
(useGroupedBubbleLayout && alignOwnMessageRight) ? null : (
<MessageHeaderRow>
{message.pubkey ? (
<UserProfilePopover
pubkey={message.pubkey}
role={profilePopoverRole}
botIdenticonValue={message.author}
>
{authorNode}
</button>
</UserProfilePopover>
) : (
authorNode
)}
{agentOwnerNode}
{inlineMetadataNode}
{message.personaDisplayName &&
message.personaDisplayName !== message.author ? (
<span className="text-xs text-muted-foreground">
{message.personaDisplayName}
</span>
) : null}
</MessageHeaderRow>
);
const bodyContainerClass = isDisplayedAsContinuation
? "mt-0"
: bodyOffsetClass;
<button
className="truncate rounded leading-message-author focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
{authorNode}
</button>
</UserProfilePopover>
) : (
authorNode
)}
{agentOwnerNode}
{message.personaDisplayName &&
message.personaDisplayName !== message.author ? (
<span className="text-xs text-muted-foreground">
{message.personaDisplayName}
</span>
) : null}
{useGroupedBubbleLayout ? null : inlineMetadataNode}
</MessageHeaderRow>
);
const bodyContainerClass =
isDisplayedAsContinuation || useGroupedBubbleLayout
? "mt-0"
: bodyOffsetClass;
const { radiusClass, rowSpacingClass: bubbleRowSpacingClass } =
getMessageBubbleLayout(
isDisplayedAsContinuation,
isFollowedByContinuation,
alignOwnMessageRight ? "right" : "left",
);
const coreMessageBodyNode = (
<>
<SentFromThreadLine channelId={channelId} tags={message.tags} />
@@ -639,6 +635,14 @@ export const MessageRow = React.memo(
{continuationMetadataNode}
</>
);
const bubbleStatusNode =
useGroupedBubbleLayout && hasStatusMetadata ? (
<MessageBubbleStatusMetadata
edited={message.edited}
isOwn={alignOwnMessageRight}
pending={message.pending}
/>
) : null;
const expandedDiffNode =
expandedDiffId === message.id ? (
<React.Suspense
@@ -690,7 +694,12 @@ export const MessageRow = React.memo(
</div>
);
const bubbleMessageBodyNode = (
<div className={cn(bodyContainerClass, "w-fit max-w-full")}>
<div
className={cn(
bodyContainerClass,
hasStandaloneCardSurface ? "w-fit max-w-full" : "w-full max-w-full",
)}
>
{hasStandaloneCardSurface ? (
<div data-testid="message-standalone-card-content">
{coreMessageBodyNode}
@@ -707,7 +716,9 @@ export const MessageRow = React.memo(
)}
data-testid="message-body-surface"
>
{bubbleAvatarNode}
{anchorActionBarToBubble ? actionBarNode : null}
{bubbleStatusNode}
{coreMessageBodyNode}
{expandedDiffNode}
{bodyFooter}
@@ -881,7 +892,7 @@ export const MessageRow = React.memo(
<article
className={cn(
"group/message relative z-10 rounded-2xl transition-colors",
"group/message message-hover-region relative z-10 rounded-2xl transition-colors",
playEntrance && "motion-enter-conversation",
showMessageBubbles ? bubbleRowSpacingClass : "py-conversation-row",
showMessageBubbles
@@ -894,6 +905,7 @@ export const MessageRow = React.memo(
? "mx-1 px-2"
: "px-2",
"flex gap-2.5",
alignOwnMessageRight && "own-message-row flex-row-reverse",
isDisplayedAsContinuation ? "items-center" : "items-start",
hasActiveReminder ? "bg-blue-500/10" : "",
highlighted

View File

@@ -4,9 +4,11 @@ import type {
} from "@/features/messages/types";
import { cn } from "@/shared/lib/cn";
import { MessageActionBar } from "./MessageActionBar";
import { MessageHoverTimestamp } from "./MessageBubbleMetadata";
type MessageRowActionsProps = {
anchorToBubble: boolean;
bubbleAlignment: "left" | "right";
channelId?: string | null;
isFollowingThread?: boolean;
isUnread?: boolean;
@@ -25,10 +27,12 @@ type MessageRowActionsProps = {
reactionErrorMessage?: string | null;
reactions: TimelineReaction[];
showMessageBubbles: boolean;
showHoverTimestamp?: boolean;
};
export function MessageRowActions({
anchorToBubble,
bubbleAlignment,
channelId,
isFollowingThread,
isUnread,
@@ -47,19 +51,33 @@ export function MessageRowActions({
reactionErrorMessage,
reactions,
showMessageBubbles,
showHoverTimestamp = false,
}: MessageRowActionsProps) {
const showInlineBubbleActions = showMessageBubbles && anchorToBubble;
return (
<div
className={cn(
"absolute z-10",
showMessageBubbles
? anchorToBubble
? "-right-2 -top-2"
: "right-1 top-1"
"absolute z-10 flex items-center gap-2",
showInlineBubbleActions && "text-foreground",
showInlineBubbleActions
? cn(
"top-1/2 -translate-y-1/2",
bubbleAlignment === "right"
? "right-full mr-3"
: "left-full ml-3",
)
: "right-1 top-1",
)}
>
{showHoverTimestamp && bubbleAlignment === "right" ? (
<MessageHoverTimestamp
createdAt={message.createdAt}
time={message.time}
/>
) : null}
<MessageActionBar
actionAlign={bubbleAlignment === "left" ? "start" : "end"}
channelId={channelId}
isFollowingThread={isFollowingThread}
isUnread={isUnread}
@@ -75,10 +93,16 @@ export function MessageRowActions({
onReply={onReply}
onSendToChannel={onSendToChannel}
onUnfollowThread={onUnfollowThread}
presentation="menu"
presentation={showInlineBubbleActions ? "inline" : "menu"}
reactionErrorMessage={reactionErrorMessage}
reactions={reactions}
/>
{showHoverTimestamp && bubbleAlignment === "left" ? (
<MessageHoverTimestamp
createdAt={message.createdAt}
time={message.time}
/>
) : null}
</div>
);
}

View File

@@ -91,6 +91,7 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & {
parentEventId: string | null;
threadHeadId: string | null;
} | null,
createdAt?: number,
) => Promise<void>;
onSendToChannel?: (
message: TimelineMessage,

View File

@@ -253,7 +253,10 @@ export function MessageThreadSummaryRow({
</div>
<div className="relative z-10 min-w-0">
<div>
<span className="font-medium transition-colors group-hover:text-foreground">
<span
className="font-medium transition-colors group-hover:text-foreground"
data-testid="message-thread-summary-reply-count"
>
{summary.replyCount} {replyLabel}
</span>
{unreadCount != null && unreadCount > 0 ? (
@@ -263,7 +266,10 @@ export function MessageThreadSummaryRow({
) : null}
{summary.lastReplyAt ? (
<>
<span className="mx-1 font-normal text-muted-foreground/50">
<span
className="mx-1 font-normal text-muted-foreground/50"
data-testid="message-thread-summary-separator"
>
·
</span>
<span className="inline-grid font-normal text-muted-foreground/70">

View File

@@ -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;

View File

@@ -25,6 +25,7 @@ import type { TimelineMessage } from "@/features/messages/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ChannelType } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { useMessageStyle } from "@/shared/lib/messageStylePreference";
import { channelChrome } from "@/shared/layout/chromeLayout";
import { DayDivider } from "./DayDivider";
import { MessageRowItem, SystemRow } from "./TimelineMessageRow";
@@ -168,6 +169,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
onVirtualizerRangeChanged,
onVirtualizerScrollerChange,
}: TimelineMessageListProps) {
const showMessageBubbles = useMessageStyle() === "bubbles";
const entries = React.useMemo(
() =>
mainEntries ??
@@ -329,6 +331,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
onVirtualizerRangeChanged={onVirtualizerRangeChanged}
onVirtualizerScrollerChange={onVirtualizerScrollerChange}
renderItem={renderItem}
showMessageBubbles={showMessageBubbles}
/>
);
}
@@ -341,6 +344,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
"relative flex flex-col",
!hideDayDividers &&
group.headingTimestamp !== null &&
!showMessageBubbles &&
"before:absolute before:inset-x-0 before:top-1/2 before:h-px before:-translate-y-1/2 before:bg-border/35 before:content-['']",
)}
data-day-label={
@@ -385,6 +389,7 @@ type VirtualizedTimelineRowsProps = {
onVirtualizerRangeChanged?: () => void;
onVirtualizerScrollerChange?: (element: HTMLDivElement | null) => void;
renderItem: (item: TimelineNonDayItem) => React.ReactNode;
showMessageBubbles: boolean;
};
type VirtualizedTimelineItemShellProps = {
@@ -425,6 +430,7 @@ function VirtualizedTimelineRows({
onVirtualizerRangeChanged,
onVirtualizerScrollerChange,
renderItem,
showMessageBubbles,
}: VirtualizedTimelineRowsProps) {
const listRef = React.useRef<VListHandle>(null);
const hostRef = React.useRef<HTMLDivElement>(null);
@@ -765,7 +771,11 @@ function VirtualizedTimelineRows({
const dayLabel = formatDayHeading(item.headingTimestamp);
return (
<div
className="relative flex flex-col before:absolute before:inset-x-0 before:top-1/2 before:h-px before:-translate-y-1/2 before:bg-border/35 before:content-['']"
className={cn(
"relative flex flex-col",
!showMessageBubbles &&
"before:absolute before:inset-x-0 before:top-1/2 before:h-px before:-translate-y-1/2 before:bg-border/35 before:content-['']",
)}
data-day-label={dayLabel}
data-testid="message-timeline-day-group"
key={virtualizedItemKey(item)}

View File

@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import test from "node:test";
const layout = await import("./messageBubbleLayout.ts");
test("lets long bubbles use more of wide conversations", () => {
assert.equal(
layout.MESSAGE_BUBBLE_MAX_WIDTH_CLASSES,
"max-w-[min(72%,48rem)]",
);
});
test("uses full radii for a standalone left-aligned bubble", () => {
assert.equal(
layout.getMessageBubbleLayout(false, false).radiusClass,
"rounded-r-2xl rounded-tl-2xl rounded-bl-2xl",
);
});
test("joins grouped left-aligned bubbles on their left edge", () => {
assert.equal(
layout.getMessageBubbleLayout(false, true).radiusClass,
"rounded-r-2xl rounded-tl-2xl rounded-bl-md",
);
assert.equal(
layout.getMessageBubbleLayout(true, false).radiusClass,
"rounded-r-2xl rounded-tl-md rounded-bl-2xl",
);
});
test("mirrors grouped bubble corners when own messages align right", () => {
assert.equal(
layout.getMessageBubbleLayout(false, true, "right").radiusClass,
"rounded-l-2xl rounded-tr-2xl rounded-br-md",
);
assert.equal(
layout.getMessageBubbleLayout(true, false, "right").radiusClass,
"rounded-l-2xl rounded-tr-md rounded-br-2xl",
);
});

View File

@@ -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"

View File

@@ -24,11 +24,8 @@ import {
} from "@/features/messages/lib/backgroundMediaUploadStore";
import type { UseChannelLinksResult } from "@/features/messages/lib/useChannelLinks";
import type { UseEmojiAutocompleteResult } from "@/features/messages/lib/useEmojiAutocomplete";
import {
buildOutgoingMessage,
type ImetaMedia,
mergeOutgoingTags,
} from "@/features/messages/lib/imetaMediaMarkdown";
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
import { buildOutgoingMessageParts } from "@/features/messages/lib/outgoingMessageParts";
import type { UseMentionsResult } from "@/features/messages/lib/useMentions";
import type { UseRichTextEditorResult } from "@/features/messages/lib/useRichTextEditor";
import type { UseDraftsResult } from "@/features/messages/lib/useDrafts";
@@ -69,6 +66,7 @@ type UseMentionSendFlowOptions = {
parentEventId: string | null;
threadHeadId: string | null;
} | null,
createdAt?: number,
) => Promise<void>
>;
richText: Pick<
@@ -488,7 +486,8 @@ export function useMentionSendFlow({
);
const send = onSendRef.current;
const persistCanceledDraft = () => {
let mediaMessageSent = false;
const persistCanceledDraft = (withoutMedia = false) => {
if (!draft.recoveryDraftKey) return;
const existing = drafts.loadDraft(draft.recoveryDraftKey);
if (
@@ -507,13 +506,13 @@ export function useMentionSendFlow({
draft.recoveryDraftKey,
draft.savedContent,
draft.capturedChannelId ?? draft.recoveryDraftKey,
draft.savedImeta,
[...draft.savedSpoileredAttachmentUrls],
withoutMedia ? [] : draft.savedImeta,
withoutMedia ? [] : [...draft.savedSpoileredAttachmentUrls],
draft.savedMentionRefs,
);
};
const restoreComposerAfterFailure = () => {
persistCanceledDraft();
const restoreComposerAfterFailure = (withoutMedia = false) => {
persistCanceledDraft(withoutMedia);
const canRestoreCurrentComposer =
isMountedRef.current &&
(draft.capturedChannelId === channelIdRef.current ||
@@ -523,7 +522,7 @@ export function useMentionSendFlow({
if (!canRestoreCurrentComposer && draft.recoveryDraftKey) {
saveQueuedAttachmentsForDraft(
draft.recoveryDraftKey,
draft.queuedAttachments,
withoutMedia ? [] : draft.queuedAttachments,
);
}
if (!canRestoreCurrentComposer) {
@@ -532,21 +531,23 @@ export function useMentionSendFlow({
setContent(draft.savedContent);
contentRef.current = draft.savedContent;
richText.setContent(draft.savedContent);
setPendingImeta(draft.savedImeta);
restoreQueuedAttachments(draft.queuedAttachments);
setPendingImeta(withoutMedia ? [] : draft.savedImeta);
restoreQueuedAttachments(withoutMedia ? [] : draft.queuedAttachments);
mentions.restoreDraftMentionRefs(draft.savedMentionRefs);
setSpoileredAttachmentUrls?.(
new Set(draft.savedSpoileredAttachmentUrls),
withoutMedia
? new Set()
: new Set(draft.savedSpoileredAttachmentUrls),
);
};
const finishSend = async (
uploaded: ImetaMedia[],
signal?: AbortSignal,
) => {
const { content: finalContent, mediaTags } = buildOutgoingMessage(
draft.trimmed,
[...draft.savedImeta, ...uploaded],
new Set([
const parts = buildOutgoingMessageParts({
body: draft.trimmed,
media: [...draft.savedImeta, ...uploaded],
spoileredMediaUrls: new Set([
...draft.savedSpoileredAttachmentUrls,
...draft.queuedAttachments.flatMap((attachment, index) =>
attachment.spoilered && uploaded[index]
@@ -554,24 +555,25 @@ export function useMentionSendFlow({
: [],
),
]),
);
const finalOutgoingTags = mergeOutgoingTags(
mediaTags,
outgoingTags ?? [],
);
textTags: outgoingTags,
});
if (signal?.aborted) return;
await send(
finalContent,
mentionPubkeys,
finalOutgoingTags,
sendChannelId,
draft.capturedThreadContext,
);
for (const part of parts) {
await send(
part.content,
part.kind === "text" ? mentionPubkeys : [],
part.outgoingTags,
sendChannelId,
draft.capturedThreadContext,
part.createdAt,
);
if (part.kind === "media") mediaMessageSent = true;
if (signal?.aborted) return;
}
if (signal?.aborted) return;
if (effectiveExplicitAgentPubkeys.length > 0) {
// Promote only explicitly authored agents that remained effective
// for this successful send. "Send without inviting" removes its
// excluded recipients here as well as from event routing.
// Promote only explicitly authored agents that remained effective.
// "Send without inviting" removes excluded recipients here too.
onSuccessfulExplicitAgentAudience?.({
channelId: sendChannelId ?? draft.capturedChannelId ?? "",
expectedGeneration: draft.audienceGeneration,
@@ -595,7 +597,7 @@ export function useMentionSendFlow({
try {
await finishSend(uploaded, signal);
} catch {
restoreComposerAfterFailure();
restoreComposerAfterFailure(mediaMessageSent);
}
},
onError: (error) => {
@@ -605,7 +607,7 @@ export function useMentionSendFlow({
);
},
onCancel: () => {
restoreComposerAfterFailure();
restoreComposerAfterFailure(mediaMessageSent);
},
});
if (!uploadStarted) {
@@ -629,7 +631,7 @@ export function useMentionSendFlow({
try {
await finishSend([]);
} catch {
restoreComposerAfterFailure();
restoreComposerAfterFailure(mediaMessageSent);
}
}
} finally {

View File

@@ -10,6 +10,7 @@ import {
import { useCommunities } from "@/features/communities/useCommunities";
import { AvatarFramingSlider } from "@/features/profile/ui/AnimatedAvatarControls";
import { contrastColorForBackground } from "@/features/profile/ui/ProfileAvatarEditor.utils";
import { MESSAGE_BUBBLE_MAX_WIDTH_CLASSES } from "@/features/messages/ui/messageBubbleLayout";
import { cn } from "@/shared/lib/cn";
import {
previewLinkPreviewStyle,
@@ -39,6 +40,13 @@ import {
useSavedMessageStyle,
type MessageStyle,
} from "@/shared/lib/messageStylePreference";
import {
previewOwnMessageAlignment,
setOwnMessageAlignment,
useOwnMessageAlignment,
useSavedOwnMessageAlignment,
type OwnMessageAlignment,
} from "@/shared/lib/ownMessageAlignmentPreference";
import {
ACCENT_COLORS,
DEFAULT_GLASS_OPACITY,
@@ -152,42 +160,106 @@ const MESSAGE_STYLE_OPTIONS: readonly {
},
];
const OWN_MESSAGE_ALIGNMENT_OPTIONS: readonly {
value: OwnMessageAlignment;
label: string;
}[] = [
{
value: "left",
label: "Left",
},
{
value: "right",
label: "Right",
},
];
function ConversationDensityPreviewMessage({
avatar,
author,
children,
isOwn = false,
timestamp,
}: {
avatar: string;
author: string;
children: ReactNode;
isOwn?: boolean;
timestamp: string;
}) {
const messageStyle = useMessageStyle();
const ownMessageAlignment = useOwnMessageAlignment();
const showBubble = messageStyle === "bubbles";
const useGroupedBubbleLayout = showBubble;
const alignRight =
useGroupedBubbleLayout && ownMessageAlignment === "right" && isOwn;
return (
<article className="flex gap-2.5 py-conversation-row">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
{avatar}
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-1.5 leading-message-author">
<span className="text-message font-semibold leading-message-author tracking-normal text-foreground">
{author}
</span>
<span className="text-message-timestamp font-normal text-muted-foreground/65">
{timestamp}
</span>
<article
className={cn(
"group/preview-message message-hover-region flex gap-2.5 py-conversation-row",
useGroupedBubbleLayout && !alignRight && "items-end",
alignRight && "flex-row-reverse",
)}
>
{alignRight ? null : (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
{avatar}
</div>
<div
className={cn(
"mt-conversation-body w-fit max-w-full text-message font-normal tracking-normal text-foreground",
showBubble && "rounded-2xl bg-muted/50 px-3.5 py-2.5",
)}
data-testid="conversation-preview-message-surface"
>
{children}
)}
<div
className={cn(
"min-w-0 flex-1",
alignRight && "flex flex-col items-end",
)}
>
{useGroupedBubbleLayout && alignRight ? null : (
<div
className={cn(
"flex min-w-0 flex-wrap items-baseline gap-x-1.5 leading-message-author",
useGroupedBubbleLayout && "mb-0.5",
)}
data-testid="conversation-preview-message-header"
>
<span className="text-message font-semibold leading-message-author tracking-normal text-foreground">
{author}
</span>
{useGroupedBubbleLayout ? null : (
<span className="text-message-timestamp font-normal text-muted-foreground/65">
{timestamp}
</span>
)}
</div>
)}
<div className="relative w-fit max-w-full">
{useGroupedBubbleLayout ? (
<span
className={cn(
"message-hover-timestamp pointer-events-none absolute top-1/2 -translate-y-1/2 shrink-0 whitespace-nowrap text-message-timestamp font-normal tabular-nums text-muted-foreground/55",
alignRight ? "right-full mr-2" : "left-full ml-2",
)}
data-testid="conversation-preview-hover-timestamp"
>
{timestamp}
</span>
) : null}
<div
className={cn(
"w-fit max-w-full text-left text-message font-normal tracking-normal text-foreground",
useGroupedBubbleLayout ? "mt-0" : "mt-conversation-body",
showBubble &&
cn(
MESSAGE_BUBBLE_MAX_WIDTH_CLASSES,
"rounded-2xl px-3.5 py-2.5",
),
showBubble && !alignRight && "bg-muted/50",
alignRight && "bg-primary text-primary-foreground",
)}
data-testid="conversation-preview-message-surface"
data-own-message={isOwn ? "true" : undefined}
>
{children}
</div>
</div>
</div>
</article>
@@ -215,7 +287,8 @@ function ConversationPreview() {
</ConversationDensityPreviewMessage>
<ConversationDensityPreviewMessage
avatar="T"
author="Theo"
author="You"
isOwn
timestamp="9:43"
>
<p>
@@ -237,6 +310,7 @@ export function ConversationDisplaySettings() {
const density = useConversationDensity();
const fontSize = useFontSize();
const messageStyle = useSavedMessageStyle();
const ownMessageAlignment = useSavedOwnMessageAlignment();
const activeMessageStyle =
MESSAGE_STYLE_OPTIONS.find((option) => option.value === messageStyle) ??
MESSAGE_STYLE_OPTIONS[0];
@@ -306,6 +380,31 @@ export function ConversationDisplaySettings() {
value={messageStyle}
/>
</SettingsOptionRow>
{messageStyle === "bubbles" ? (
<SettingsOptionRow data-testid="own-message-alignment-row">
<div className="min-w-0">
<p className="text-sm font-medium">My messages</p>
<p
className="text-sm font-normal text-muted-foreground/70"
data-settings-subcopy
>
{ownMessageAlignment === "right"
? "Show your messages on the right."
: "Keep your messages aligned with the conversation."}
</p>
</div>
<SettingsSegmentedControl
className="w-72"
legend="My messages"
onPreviewChange={previewOwnMessageAlignment}
onValueChange={setOwnMessageAlignment}
optionTestIdPrefix="own-message-alignment"
options={OWN_MESSAGE_ALIGNMENT_OPTIONS}
testId="own-message-alignment-control"
value={ownMessageAlignment}
/>
</SettingsOptionRow>
) : null}
<ConversationPreview />
</div>
);

View File

@@ -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}`;
}

View File

@@ -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",
);
});
});

View File

@@ -16,6 +16,7 @@ import {
} from "@/shared/ui/context-menu";
import { ChannelContextMenuItems } from "@/features/sidebar/ui/ChannelContextMenu";
import { resolveChannelSidebarSubtitle } from "@/features/sidebar/lib/channelLabels";
import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurnsStore";
import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
import { getEphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel";
@@ -273,6 +274,8 @@ export function ChannelMenuButton({
onSelectChannel: (channelId: string) => void;
}) {
const resolvedLabel = label ?? channel.name;
const isDm = channel.channelType === "dm";
const subtitle = isDm ? null : resolveChannelSidebarSubtitle(channel);
const ephemeralDisplay = getEphemeralChannelDisplay(channel);
const {
hasSidebarUnreadProjections,
@@ -303,6 +306,7 @@ export function ChannelMenuButton({
<SidebarMenuButton
className={cn(
"data-[active=true]:font-normal",
!isDm && "h-12 gap-2 p-1 pr-2 group-data-[collapsible=icon]:!p-0",
isActive
? "group-hover/menu-item:bg-sidebar-active group-hover/menu-item:text-sidebar-active-foreground"
: "group-hover/menu-item:bg-sidebar-accent group-hover/menu-item:text-sidebar-foreground",
@@ -313,23 +317,62 @@ export function ChannelMenuButton({
data-testid={`channel-${channel.name}`}
isActive={isActive}
onClick={() => onSelectChannel(channel.id)}
size={isDm ? "default" : "lg"}
tooltip={resolvedLabel}
type="button"
>
<SidebarChannelIcon
channel={channel}
className={
channel.channelType === "dm" ? undefined : inactiveContentOpacity
}
dmParticipants={dmParticipants}
presenceStatus={presenceStatus}
/>
<span
className={cn("min-w-0 flex-1 truncate", inactiveContentOpacity)}
data-sidebar-row-label
>
{resolvedLabel}
</span>
{isDm ? (
<SidebarChannelIcon
channel={channel}
dmParticipants={dmParticipants}
presenceStatus={presenceStatus}
/>
) : (
<span
aria-hidden="true"
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-xl bg-sidebar-border/55 text-sidebar-foreground/65 transition-colors group-data-[collapsible=icon]:size-8 group-data-[collapsible=icon]:rounded-md group-data-[collapsible=icon]:bg-transparent",
isActive &&
"bg-sidebar-active-foreground/10 text-sidebar-active-foreground",
inactiveContentOpacity,
)}
data-testid={`channel-icon-container-${channel.name}`}
>
<SidebarChannelIcon channel={channel} className="size-6" />
</span>
)}
{isDm ? (
<span
className={cn("min-w-0 flex-1 truncate", inactiveContentOpacity)}
data-sidebar-row-label
>
{resolvedLabel}
</span>
) : (
<span
className={cn(
"min-w-0 flex-1 group-data-[collapsible=icon]:hidden",
inactiveContentOpacity,
)}
>
<span
className={cn(
"block truncate text-sm leading-tight",
hasTopLevelUnread ? "font-bold" : "font-medium",
)}
data-sidebar-row-label
>
{resolvedLabel}
</span>
<span
className="mt-0.5 block truncate text-xs font-normal leading-tight text-sidebar-foreground/55"
data-testid={`channel-subtitle-${channel.name}`}
title={subtitle ?? undefined}
>
{subtitle}
</span>
</span>
)}
{ephemeralDisplay ? (
<EphemeralChannelBadge
display={ephemeralDisplay}

View File

@@ -227,14 +227,26 @@ function SidebarLoadingRow({
}) {
return (
<SidebarMenuItem>
<div className="flex h-8 items-center gap-2 rounded-md px-2">
<div
className={cn(
"flex items-center gap-2 rounded-md",
avatar ? "h-8 px-2" : "h-12 p-1 pr-2",
)}
>
<Skeleton
className={cn(
"shrink-0",
avatar ? "h-5 w-5 rounded-full" : "h-4 w-4 rounded-sm",
avatar ? "h-5 w-5 rounded-full" : "size-10 rounded-xl",
)}
/>
<Skeleton className={cn("h-4 min-w-0", widthClass)} />
{avatar ? (
<Skeleton className={cn("h-4 min-w-0", widthClass)} />
) : (
<div className="min-w-0 flex-1 space-y-1">
<Skeleton className={cn("h-3.5 min-w-0", widthClass)} />
<Skeleton className="h-3 w-full max-w-32" />
</div>
)}
</div>
</SidebarMenuItem>
);

View File

@@ -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();

View File

@@ -544,6 +544,7 @@ export async function sendChannelMessage(
mentionTags?: string[][],
linkPreviewTags?: string[][],
sentFromThreadTag?: string[],
createdAt?: number,
): Promise<SendChannelMessageResult> {
const response = await invokeTauri<RawSendChannelMessageResult>(
"send_channel_message",
@@ -558,6 +559,7 @@ export async function sendChannelMessage(
sentFromThreadTag: sentFromThreadTag ?? null,
mentionPubkeys: mentionPubkeys ?? null,
kind: kind ?? null,
createdAt: createdAt ?? null,
},
);
return {
@@ -580,7 +582,6 @@ export type BlobDescriptor = {
thumb?: string;
duration?: number;
image?: string;
/** Original filename captured client-side. */
filename?: string;
};
@@ -601,7 +602,6 @@ export async function pickAndUploadMedia(
export async function uploadMediaBytes(
data: number[],
filename?: string,
/** Correlation id for `media-upload-progress` events from the Rust side. */
progressId?: string,
): Promise<BlobDescriptor> {
return invokeTauri<BlobDescriptor>("upload_media_bytes", {

View File

@@ -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");
});

View File

@@ -0,0 +1,121 @@
import * as React from "react";
/** Horizontal placement for messages authored by the signed-in account. */
export type OwnMessageAlignment = "left" | "right";
export const OWN_MESSAGE_ALIGNMENT_STORAGE_KEY =
"buzz.appearance.ownMessageAlignment";
export const DEFAULT_OWN_MESSAGE_ALIGNMENT: OwnMessageAlignment = "left";
const savedListeners = new Set<() => void>();
const appliedListeners = new Set<() => void>();
let savedOwnMessageAlignment: OwnMessageAlignment =
DEFAULT_OWN_MESSAGE_ALIGNMENT;
let appliedOwnMessageAlignment: OwnMessageAlignment =
DEFAULT_OWN_MESSAGE_ALIGNMENT;
export function parseOwnMessageAlignment(
value: string | null | undefined,
): OwnMessageAlignment {
return value === "left" || value === "right"
? value
: DEFAULT_OWN_MESSAGE_ALIGNMENT;
}
function readStoredOwnMessageAlignment(): OwnMessageAlignment {
try {
return parseOwnMessageAlignment(
globalThis.localStorage?.getItem(OWN_MESSAGE_ALIGNMENT_STORAGE_KEY),
);
} catch {
return DEFAULT_OWN_MESSAGE_ALIGNMENT;
}
}
function applyOwnMessageAlignment(alignment: OwnMessageAlignment): void {
globalThis.document?.documentElement?.setAttribute(
"data-own-message-alignment",
alignment,
);
}
function notify(listeners: Set<() => void>): void {
for (const listener of listeners) listener();
}
/** Apply the persisted preference before React renders to avoid a layout jump. */
export function initializeOwnMessageAlignmentPreference(): void {
const nextAlignment = readStoredOwnMessageAlignment();
const savedChanged = nextAlignment !== savedOwnMessageAlignment;
const appliedChanged = nextAlignment !== appliedOwnMessageAlignment;
savedOwnMessageAlignment = nextAlignment;
appliedOwnMessageAlignment = nextAlignment;
applyOwnMessageAlignment(nextAlignment);
if (savedChanged) notify(savedListeners);
if (appliedChanged) notify(appliedListeners);
}
function subscribeSaved(listener: () => void): () => void {
savedListeners.add(listener);
return () => savedListeners.delete(listener);
}
function subscribeApplied(listener: () => void): () => void {
appliedListeners.add(listener);
return () => appliedListeners.delete(listener);
}
export function getOwnMessageAlignment(): OwnMessageAlignment {
return appliedOwnMessageAlignment;
}
export function getSavedOwnMessageAlignment(): OwnMessageAlignment {
return savedOwnMessageAlignment;
}
export function setOwnMessageAlignment(alignment: OwnMessageAlignment): void {
const savedChanged = alignment !== savedOwnMessageAlignment;
const appliedChanged = alignment !== appliedOwnMessageAlignment;
savedOwnMessageAlignment = alignment;
appliedOwnMessageAlignment = alignment;
applyOwnMessageAlignment(alignment);
try {
globalThis.localStorage?.setItem(
OWN_MESSAGE_ALIGNMENT_STORAGE_KEY,
alignment,
);
} catch {
// Persistence is best-effort; the live preference still applies.
}
if (savedChanged) notify(savedListeners);
if (appliedChanged) notify(appliedListeners);
}
/** Temporarily apply an alignment without changing the saved preference. */
export function previewOwnMessageAlignment(
alignment: OwnMessageAlignment | null,
): void {
const nextAlignment = alignment ?? savedOwnMessageAlignment;
if (nextAlignment === appliedOwnMessageAlignment) return;
appliedOwnMessageAlignment = nextAlignment;
applyOwnMessageAlignment(nextAlignment);
notify(appliedListeners);
}
/** The currently applied alignment, including a temporary settings preview. */
export function useOwnMessageAlignment(): OwnMessageAlignment {
return React.useSyncExternalStore(
subscribeApplied,
getOwnMessageAlignment,
() => DEFAULT_OWN_MESSAGE_ALIGNMENT,
);
}
/** The persisted alignment used to mark the selected settings option. */
export function useSavedOwnMessageAlignment(): OwnMessageAlignment {
return React.useSyncExternalStore(
subscribeSaved,
getSavedOwnMessageAlignment,
() => DEFAULT_OWN_MESSAGE_ALIGNMENT,
);
}

View File

@@ -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;

View File

@@ -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 {

View File

@@ -5690,7 +5690,12 @@ async function relayQuery(
async function submitSignedEvent(
config: E2eConfig | undefined,
template: { kind: number; content: string; tags: string[][] },
template: {
kind: number;
content: string;
createdAt?: number;
tags: string[][];
},
): Promise<{ event_id: string; accepted: boolean; message: string }> {
const identity = getRelayIdentity(config);
const signed = await signWithIdentity(identity, template);
@@ -9067,6 +9072,7 @@ async function handleSendChannelMessage(
linkPreviewTags?: string[][] | null;
sentFromThreadTag?: string[] | null;
suppressLinkPreviews?: boolean;
createdAt?: number | null;
},
config: E2eConfig | undefined,
): Promise<RawSendChannelMessageResponse> {
@@ -9077,6 +9083,7 @@ async function handleSendChannelMessage(
window.setTimeout(resolve, sendMessageDelayMs),
);
}
const createdAt = args.createdAt ?? Math.floor(Date.now() / 1000);
// NIP-92 imeta attachments. The real relay echoes these back on the stored
// event; mirror that here so attachment renderers (FileCard, images, video)
@@ -9130,18 +9137,23 @@ async function handleSendChannelMessage(
];
const identity = getIdentity(config);
if (!identity) {
const createdAt = Math.floor(Date.now() / 1000);
const mockPubkey = getMockMemberPubkey(config);
if (!args.parentEventId) {
const event = createMockEvent(kind, args.content, [
...buildTopLevelMessageTags(
args.channelId,
args.mentionPubkeys,
mockPubkey,
),
...extraTags,
]);
const event = createMockEvent(
kind,
args.content,
[
...buildTopLevelMessageTags(
args.channelId,
args.mentionPubkeys,
mockPubkey,
),
...extraTags,
],
mockPubkey,
createdAt,
);
recordMockMessage(args.channelId, event);
emitOrDeferMockSendMessageLiveEcho(args.channelId, event, config);
@@ -9235,6 +9247,7 @@ async function handleSendChannelMessage(
const result = await submitSignedEvent(config, {
kind,
content: args.content.trim(),
createdAt,
tags: [...tags, ...extraTags],
});
@@ -9243,7 +9256,7 @@ async function handleSendChannelMessage(
parent_event_id: args.parentEventId ?? null,
root_event_id: args.parentEventId ?? null,
depth: args.parentEventId ? 1 : 0,
created_at: Math.floor(Date.now() / 1000),
created_at: createdAt,
};
}

View File

@@ -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 }) => {

View File

@@ -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,
}) => {

View File

@@ -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

View File

@@ -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,
}) => {

View File

@@ -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",

View File

@@ -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);

View File

@@ -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();
}