diff --git a/client/src/components/Chat/Messages/Content/Container.tsx b/client/src/components/Chat/Messages/Content/Container.tsx
index 945d8ca1d51..db97b17e17a 100644
--- a/client/src/components/Chat/Messages/Content/Container.tsx
+++ b/client/src/components/Chat/Messages/Content/Container.tsx
@@ -1,18 +1,8 @@
-import { TMessage } from 'librechat-data-provider';
+import type { TMessage } from 'librechat-data-provider';
import MessageQuotes from './MessageQuotes';
import SkillPills from './SkillPills';
-import Files from './Files';
-const Container = ({
- children,
- message,
- hideFiles = false,
-}: {
- children: React.ReactNode;
- message?: TMessage;
- /** The message editor renders the files itself, as chips that can be removed. */
- hideFiles?: boolean;
-}) => (
+const Container = ({ children, message }: { children: React.ReactNode; message?: TMessage }) => (
- {!hideFiles &&
}
>
diff --git a/client/src/components/Chat/Messages/Content/EditMessage.tsx b/client/src/components/Chat/Messages/Content/EditMessage.tsx
index 1c9c397f5ea..e46a81fc90e 100644
--- a/client/src/components/Chat/Messages/Content/EditMessage.tsx
+++ b/client/src/components/Chat/Messages/Content/EditMessage.tsx
@@ -250,7 +250,7 @@ const EditMessage = ({
);
return (
-
+
{
setSelectedFile(file)}
/>
))}
@@ -44,6 +46,7 @@ const Files = ({ message }: { message?: TMessage }) => {
({
jest.mock(
'~/components/Chat/Input/Files/FileContainer',
() =>
- ({ onClick }: { onClick: () => void }) => ,
+ ({ onClick, buttonClassName }: { onClick: () => void; buttonClassName?: string }) => (
+
+ ),
+);
+jest.mock(
+ '../Image',
+ () =>
+ ({ alignRight, className }: { alignRight?: boolean; className?: string }) => (
+
+ image preview
+
+ ),
);
-jest.mock('../Image', () => ({ alignRight }: { alignRight?: boolean }) => (
-
- image preview
-
-));
jest.mock(
'../FilePreviewDialog',
() =>
@@ -40,6 +52,16 @@ it('exposes extracted image text without changing ordinary image previews', () =
);
expect(screen.getAllByText('image preview')).toHaveLength(1);
expect(screen.getByTestId('image-preview')).toHaveAttribute('data-aligned-right', 'true');
+ expect(screen.getByText('file action')).toHaveClass(
+ 'border-surface-user-message',
+ 'max-w-full',
+ 'bg-transparent',
+ );
+ expect(screen.getByTestId('image-preview')).toHaveClass(
+ 'border-surface-user-message',
+ 'mt-0',
+ 'shadow-none',
+ );
fireEvent.click(screen.getByText('file action'));
expect(screen.getByText('preview: text')).toBeInTheDocument();
});
diff --git a/client/src/components/Chat/Messages/MessageParts.tsx b/client/src/components/Chat/Messages/MessageParts.tsx
index 2c6b0315018..f5a8f782a60 100644
--- a/client/src/components/Chat/Messages/MessageParts.tsx
+++ b/client/src/components/Chat/Messages/MessageParts.tsx
@@ -116,6 +116,7 @@ function MessageParts(props: TMessageProps) {
>
}
label={label}
timestamp={reveal.revealedAt}
diff --git a/client/src/components/Chat/Messages/SearchMessage.tsx b/client/src/components/Chat/Messages/SearchMessage.tsx
index 13f0bbf05a7..e81eab67c7c 100644
--- a/client/src/components/Chat/Messages/SearchMessage.tsx
+++ b/client/src/components/Chat/Messages/SearchMessage.tsx
@@ -2,12 +2,13 @@ import { memo, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import type { TMessage } from 'librechat-data-provider';
import type { TMessageProps, TMessageIcon } from '~/common';
-import MessageRow, { getMessageRowWidthClass } from '~/components/Chat/Messages/ui/MessageRow';
import AuthorHeader from '~/components/Chat/Messages/Content/Parts/AuthorHeader';
import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons';
import { getHeaderModelName } from '~/components/Chat/Messages/ui/HeaderLabel';
+import { getMessageRowWidthClass } from '~/components/Chat/Messages/ui/width';
import { getHeaderPrefixForScreenReader, getMessageAriaLabel } from '~/utils';
import SearchContent, { rendersMarkdownLite } from './Content/SearchContent';
+import MessageRow from '~/components/Chat/Messages/ui/MessageRow';
import Icon from '~/components/Chat/Messages/MessageIcon';
import { useAuthContext, useLocalize } from '~/hooks';
import SearchButtons from './SearchButtons';
@@ -121,6 +122,7 @@ function SearchMessage({ message }: Pick
) {
className={cn('border-border-light mx-auto border-b px-4 py-4', getMessageRowWidthClass())}
>
}
label={messageLabel}
diff --git a/client/src/components/Chat/Messages/ui/MessageRender.tsx b/client/src/components/Chat/Messages/ui/MessageRender.tsx
index 077a0724394..f638d393ab6 100644
--- a/client/src/components/Chat/Messages/ui/MessageRender.tsx
+++ b/client/src/components/Chat/Messages/ui/MessageRender.tsx
@@ -168,6 +168,7 @@ const MessageRender = memo(function MessageRender({
}
label={messageLabel ?? ''}
diff --git a/client/src/components/Chat/Messages/ui/MessageRow.tsx b/client/src/components/Chat/Messages/ui/MessageRow.tsx
index b6885eda2f3..bc825904e8e 100644
--- a/client/src/components/Chat/Messages/ui/MessageRow.tsx
+++ b/client/src/components/Chat/Messages/ui/MessageRow.tsx
@@ -1,11 +1,16 @@
import { Zap } from 'lucide-react';
+import { ContentTypes } from 'librechat-data-provider';
+import type { TMessage } from 'librechat-data-provider';
import type { ReactNode } from 'react';
import MessageTimestamp from './MessageTimestamp';
+import { getMessageRowWidthClass } from './width';
import HeaderLabel from './HeaderLabel';
+import Files from '../Content/Files';
import { cn } from '~/utils';
type MessageRowProps = {
id?: string;
+ message?: TMessage;
label: string;
hoverLabel?: string | null;
icon: ReactNode;
@@ -74,20 +79,9 @@ function renderAuthorHeader({
);
}
-export function getMessageRowWidthClass({
- fullWidth = false,
- hasParallelContent = false,
-}: {
- fullWidth?: boolean;
- hasParallelContent?: boolean;
-} = {}) {
- if (fullWidth) return 'w-full max-w-full sm:px-2';
- if (hasParallelContent) return 'w-full sm:px-2 md:max-w-[58rem] xl:max-w-[70rem]';
- return 'w-full sm:px-2 md:max-w-3xl xl:max-w-4xl';
-}
-
export default function MessageRow({
id,
+ message,
icon,
label,
hoverLabel,
@@ -110,6 +104,24 @@ export default function MessageRow({
const widthClass = getMessageRowWidthClass({ fullWidth, hasParallelContent });
const isSystem = systemLabel != null && systemLabel !== '';
const isUserSide = isCreatedByUser && !isSystem;
+ const hasFiles = isUserSide && !isEditing && (message?.files?.length ?? 0) > 0;
+ const hasBody =
+ !hasFiles ||
+ Boolean(
+ message?.text?.trim() ||
+ message?.content?.some(
+ (part) =>
+ part != null &&
+ (part.type !== ContentTypes.TEXT ||
+ (typeof part.text === 'string' ? part.text : part.text?.value)?.trim()),
+ ) ||
+ message?.privacyRevision ||
+ message?.error ||
+ message?.unfinished ||
+ message?.quotes?.length ||
+ message?.alwaysAppliedSkills?.length ||
+ message?.manualSkills?.length,
+ );
return (
-
- {children}
-
+ {hasFiles && (
+
+
+
+ )}
+ {hasBody && (
+
+ {children}
+
+ )}
{footer}
diff --git a/client/src/components/Chat/Messages/ui/__tests__/MessageRow.spec.tsx b/client/src/components/Chat/Messages/ui/__tests__/MessageRow.spec.tsx
index 2c95e2e2b45..bd611dfee26 100644
--- a/client/src/components/Chat/Messages/ui/__tests__/MessageRow.spec.tsx
+++ b/client/src/components/Chat/Messages/ui/__tests__/MessageRow.spec.tsx
@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/react';
+import type { TMessage } from 'librechat-data-provider';
import MessageRow from '../MessageRow';
jest.mock('../MessageTimestamp', () => ({
@@ -8,10 +9,24 @@ jest.mock('../MessageTimestamp', () => ({
),
}));
+jest.mock('../../Content/Files', () => ({
+ __esModule: true,
+ default: () => ,
+}));
+
const MESSAGE_BODY = 'Message body';
+const attachedMessage = (overrides: Partial = {}) =>
+ ({
+ messageId: 'message-1',
+ isCreatedByUser: true,
+ text: MESSAGE_BODY,
+ files: [{ file_id: 'file-1', filename: 'notes.txt', type: 'text/plain' }],
+ ...overrides,
+ }) as TMessage;
const renderRow = ({
isCreatedByUser,
+ message,
hasParallelContent = false,
fullWidth = false,
isEditing = false,
@@ -20,6 +35,7 @@ const renderRow = ({
outlined,
}: {
isCreatedByUser: boolean;
+ message?: TMessage;
hasParallelContent?: boolean;
fullWidth?: boolean;
isEditing?: boolean;
@@ -30,6 +46,7 @@ const renderRow = ({
render(
}
@@ -49,6 +66,56 @@ const renderRow = ({
);
describe('MessageRow', () => {
+ it('places right-aligned attachments before and outside the text bubble', () => {
+ renderRow({ isCreatedByUser: true, message: attachedMessage() });
+
+ const attachments = screen.getByTestId('message-attachments');
+ const body = screen.getByTestId('message-body');
+ expect(attachments).toHaveClass('items-end', 'max-w-full');
+ expect(attachments).toContainElement(screen.getByTestId('attached-file'));
+ expect(body).not.toContainElement(attachments);
+ expect(attachments.nextElementSibling).toBe(body);
+ expect(body).toHaveClass('bg-surface-user-message');
+ expect(screen.getByTestId('message-actions')).toBeInTheDocument();
+ });
+
+ it.each([
+ { text: '' },
+ { text: ' ', content: [{ type: 'text', text: '' }] },
+ { text: '', content: [{ type: 'text', text: { value: '', annotations: [] } }] },
+ ] as Partial[])(
+ 'does not leave an empty bubble for attachments-only messages: %j',
+ (overrides) => {
+ renderRow({ isCreatedByUser: true, message: attachedMessage(overrides) });
+
+ expect(screen.getByTestId('attached-file')).toBeInTheDocument();
+ expect(screen.queryByTestId('message-body')).not.toBeInTheDocument();
+ expect(screen.getByTestId('message-actions')).toBeInTheDocument();
+ },
+ );
+
+ it.each([
+ { content: [{ type: 'text', text: { value: 'Assistant prompt', annotations: [] } }] },
+ { quotes: ['Excerpt'] },
+ { manualSkills: ['review'] },
+ { alwaysAppliedSkills: ['review'] },
+ { error: true },
+ { unfinished: true },
+ ] as Partial[])('keeps the bubble for non-text message content: %j', (overrides) => {
+ renderRow({ isCreatedByUser: true, message: attachedMessage({ text: '', ...overrides }) });
+ expect(screen.getByTestId('message-body')).toHaveClass('bg-surface-user-message');
+ });
+
+ it.each([
+ { isCreatedByUser: true, isEditing: true },
+ { isCreatedByUser: false },
+ { isCreatedByUser: true, systemLabel: 'System' },
+ ])('does not add an attachment strip to editors, assistant or system rows: %j', (props) => {
+ renderRow({ ...props, message: attachedMessage() });
+ expect(screen.queryByTestId('attached-file')).not.toBeInTheDocument();
+ expect(screen.getByTestId('message-body')).toBeInTheDocument();
+ });
+
it('renders a system row in the transcript under a visible heading', () => {
renderRow({ isCreatedByUser: true, systemLabel: 'System' });
diff --git a/client/src/components/Chat/Messages/ui/__tests__/width.spec.ts b/client/src/components/Chat/Messages/ui/__tests__/width.spec.ts
new file mode 100644
index 00000000000..bd3c7e6e50f
--- /dev/null
+++ b/client/src/components/Chat/Messages/ui/__tests__/width.spec.ts
@@ -0,0 +1,17 @@
+import { getMessageRowWidthClass } from '../width';
+
+it('uses the standard reading column by default', () => {
+ expect(getMessageRowWidthClass()).toBe('w-full sm:px-2 md:max-w-3xl xl:max-w-4xl');
+});
+
+it('widens parallel content', () => {
+ expect(getMessageRowWidthClass({ hasParallelContent: true })).toBe(
+ 'w-full sm:px-2 md:max-w-[58rem] xl:max-w-[70rem]',
+ );
+});
+
+it.each([false, true])('prioritizes full width over parallel content: %s', (hasParallelContent) => {
+ expect(getMessageRowWidthClass({ fullWidth: true, hasParallelContent })).toBe(
+ 'w-full max-w-full sm:px-2',
+ );
+});
diff --git a/client/src/components/Chat/Messages/ui/width.ts b/client/src/components/Chat/Messages/ui/width.ts
new file mode 100644
index 00000000000..2b4a43e6827
--- /dev/null
+++ b/client/src/components/Chat/Messages/ui/width.ts
@@ -0,0 +1,11 @@
+export function getMessageRowWidthClass({
+ fullWidth = false,
+ hasParallelContent = false,
+}: {
+ fullWidth?: boolean;
+ hasParallelContent?: boolean;
+} = {}) {
+ if (fullWidth) return 'w-full max-w-full sm:px-2';
+ if (hasParallelContent) return 'w-full sm:px-2 md:max-w-[58rem] xl:max-w-[70rem]';
+ return 'w-full sm:px-2 md:max-w-3xl xl:max-w-4xl';
+}
diff --git a/client/src/components/Chat/Subagents/EventSubagentActivityGroup.tsx b/client/src/components/Chat/Subagents/EventSubagentActivityGroup.tsx
index a64a3a51234..c64911d6b44 100644
--- a/client/src/components/Chat/Subagents/EventSubagentActivityGroup.tsx
+++ b/client/src/components/Chat/Subagents/EventSubagentActivityGroup.tsx
@@ -4,7 +4,7 @@ import { Button, cn } from '@librechat/client';
import { Bot, ChevronDown } from 'lucide-react';
import type { ParentSubagentSummary } from 'librechat-data-provider';
import { isLiveSubagentStatus, subagentStatusDotClass, subagentStatusLabelKey } from './status';
-import { getMessageRowWidthClass } from '~/components/Chat/Messages/ui/MessageRow';
+import { getMessageRowWidthClass } from '~/components/Chat/Messages/ui/width';
import { useChatSurface, useOpenSubagentPanel } from './surface';
import { useParentSubagents } from './ParentSubagentsProvider';
import { eventSubagentSelection } from './eventSelection';
diff --git a/client/src/components/Messages/ContentRender.tsx b/client/src/components/Messages/ContentRender.tsx
index 799a9198990..e8484f6f0b6 100644
--- a/client/src/components/Messages/ContentRender.tsx
+++ b/client/src/components/Messages/ContentRender.tsx
@@ -168,6 +168,7 @@ const ContentRender = memo(function ContentRender({
return (
({
+ ...jest.requireActual('librechat-data-provider'),
+ dataService: {
+ ...jest.requireActual('librechat-data-provider').dataService,
+ getOwnerMessageTexts: jest.fn(),
+ },
+}));
+
+jest.mock('~/hooks/AuthContext', () => ({
+ useAuthContext: () => ({ user: { id: 'owner', tenantId: 'tenant' } }),
+}));
+jest.mock('~/hooks', () => ({
+ useLocalize: () => (key: string) => key,
+ useAttachments: () => ({}),
+ useContentMetadata: () => ({ hasParallelContent: false }),
+ useMessageActions: () => ({
+ edit: false,
+ index: 0,
+ conversation: { conversationId: 'protected-conversation', endpoint: 'agents' },
+ messageLabel: 'You',
+ getCanCopy: () => true,
+ }),
+}));
+jest.mock('~/components/Chat/Messages/MessageIcon', () => ({
+ __esModule: true,
+ default: () => null,
+}));
+jest.mock('~/components/Chat/Messages/HoverButtons', () => ({
+ __esModule: true,
+ default: () => null,
+}));
+jest.mock('~/components/Chat/Messages/SiblingSwitch', () => ({
+ __esModule: true,
+ default: () => null,
+}));
+jest.mock('~/components/Chat/Messages/Elapsed', () => ({
+ __esModule: true,
+ default: () => null,
+ shouldShowElapsed: () => false,
+}));
+
+const canonical: TMessage = {
+ messageId: 'protected-message',
+ parentMessageId: 'parent',
+ conversationId: 'protected-conversation',
+ isCreatedByUser: true,
+ text: '[EMAIL_1_turn]',
+ privacyRevision: 'turn',
+ files: [
+ { file_id: 'notes', filename: 'notes.txt', type: 'text/plain', bytes: 128 },
+ {
+ file_id: 'image',
+ filename: 'preview.png',
+ type: 'image/png',
+ filepath: '/images/preview.png',
+ width: 320,
+ height: 180,
+ },
+ ],
+};
+const original = {
+ messageId: canonical.messageId,
+ canonicalText: canonical.text,
+ revision: 'turn',
+ text: 'alice@example.com',
+};
+const chatContext = { latestMessageId: canonical.messageId } as TMessageChatContext;
+
+for (const [name, Row] of [
+ ['plain', MessageRender],
+ ['structured', ContentRender],
+] as const) {
+ describe(`${name} protected attachments`, () => {
+ const message: TMessage = {
+ ...canonical,
+ ...(name === 'structured'
+ ? { content: [{ type: ContentTypes.TEXT, text: canonical.text }] }
+ : {}),
+ };
+ function renderMessage(overrides: Partial = {}) {
+ const current = { ...message, ...overrides };
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+
+
+
+
+
+ ,
+ );
+ }
+
+ function expectAttachments() {
+ const attachments = screen.getByTestId('message-attachments');
+ const body = screen.getByTestId('message-body');
+ expect(within(attachments).getByRole('button', { name: 'notes.txt' })).toBeVisible();
+ expect(
+ within(attachments).getByRole('button', { name: 'View preview.png in dialog' }),
+ ).toBeVisible();
+ expect(body).not.toContainElement(attachments);
+ return attachments;
+ }
+
+ it('preserves previews while owner text loads and after it resolves', async () => {
+ let finish!: (value: { messages: (typeof original)[] }) => void;
+ (dataService.getOwnerMessageTexts as jest.Mock).mockReturnValue(
+ new Promise((resolve) => {
+ finish = resolve;
+ }),
+ );
+ renderMessage();
+ expectAttachments();
+ expect(screen.getByRole('status')).toHaveTextContent('com_ui_private_text_loading');
+ await act(async () => finish({ messages: [original] }));
+ expect(await screen.findByText(original.text)).toBeInTheDocument();
+ expectAttachments();
+ expect(message.text).toBe(canonical.text);
+ fireEvent.click(screen.getByRole('button', { name: 'View preview.png in dialog' }));
+ expect(await screen.findByRole('dialog')).toBeVisible();
+ });
+
+ it('keeps attachments and private-text recovery when canonical text is empty', async () => {
+ (dataService.getOwnerMessageTexts as jest.Mock).mockResolvedValue({ messages: [] });
+ renderMessage({ text: '', content: [] });
+ expectAttachments();
+ expect(
+ await screen.findByRole('button', { name: 'com_ui_private_text_retry' }),
+ ).toBeVisible();
+ expect(screen.getByRole('status')).toHaveTextContent('com_ui_private_text_unavailable');
+ expectAttachments();
+ });
+ });
+}
diff --git a/client/src/components/Share/Message.tsx b/client/src/components/Share/Message.tsx
index e3ad8fd5238..f482f62d60a 100644
--- a/client/src/components/Share/Message.tsx
+++ b/client/src/components/Share/Message.tsx
@@ -67,6 +67,7 @@ export default function Message(props: TMessageProps) {
}
label={messageLabel}
diff --git a/client/src/routes/Search.tsx b/client/src/routes/Search.tsx
index f25cae8113c..9fd191e8c21 100644
--- a/client/src/routes/Search.tsx
+++ b/client/src/routes/Search.tsx
@@ -7,7 +7,7 @@ import { List, CellMeasurer, CellMeasurerCache } from 'react-virtualized';
import { EmptyState, Spinner, useRemScale, useToastContext } from '@librechat/client';
import type { Index, ListRowProps } from 'react-virtualized';
import type { TMessage } from 'librechat-data-provider';
-import { getMessageRowWidthClass } from '~/components/Chat/Messages/ui/MessageRow';
+import { getMessageRowWidthClass } from '~/components/Chat/Messages/ui/width';
import { useElementSize, useLocalize, useAuthContext } from '~/hooks';
import SearchMessage from '~/components/Chat/Messages/SearchMessage';
import { useMessagesInfiniteQuery } from '~/data-provider';
diff --git a/e2e/specs/mock/scenarios/message-attachments.spec.ts b/e2e/specs/mock/scenarios/message-attachments.spec.ts
new file mode 100644
index 00000000000..bf77a0c2dec
--- /dev/null
+++ b/e2e/specs/mock/scenarios/message-attachments.spec.ts
@@ -0,0 +1,179 @@
+import { randomUUID } from 'crypto';
+import { expect, test } from '@playwright/test';
+import { clickHouseTheme } from '../../../../packages/client/src/theme/themes/clickhouse';
+import {
+ deleteConversations,
+ deleteMessagesByConversation,
+ seedConversations,
+ seedMessages,
+ withMongo,
+} from '../db';
+import { getE2EUser } from '../../../setup/user';
+
+const IMAGE =
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=';
+const TEXT = 'Keep these attachments separate from the text bubble.';
+
+test.use({ viewport: { width: 1280, height: 800 } });
+
+for (const definition of ['stock', 'clickhouse'] as const) {
+ for (const mode of ['light', 'dark'] as const) {
+ test(`message attachments in ${definition} ${mode} @scenario:message-attachments-${definition}-${mode}`, async ({
+ page,
+ }) => {
+ await page.addInitScript(
+ ({ selectedMode, theme }) => {
+ localStorage.setItem('color-theme', selectedMode);
+ localStorage.removeItem('theme-colors');
+ localStorage.removeItem('theme-name');
+ if (theme) {
+ localStorage.setItem('theme-definition', JSON.stringify(theme));
+ localStorage.setItem('theme-source', 'definition');
+ } else {
+ localStorage.removeItem('theme-definition');
+ localStorage.removeItem('theme-source');
+ }
+ },
+ { selectedMode: mode, theme: definition === 'clickhouse' ? clickHouseTheme : null },
+ );
+ const conversationId = randomUUID();
+ const messageId = randomUUID();
+ const structuredId = randomUUID();
+ const filesOnlyId = randomUUID();
+ const { email } = getE2EUser();
+ const files = [
+ { file_id: randomUUID(), filename: 'notes.txt', type: 'text/plain', bytes: 128 },
+ {
+ file_id: randomUUID(),
+ filename: 'preview.png',
+ type: 'image/png',
+ filepath: IMAGE,
+ width: 320,
+ height: 180,
+ },
+ ];
+ await seedConversations(email, [
+ { conversationId, title: 'Separate attachments', updatedAt: new Date() },
+ ]);
+ await seedMessages(email, conversationId, [
+ {
+ messageId,
+ parentMessageId: '00000000-0000-0000-0000-000000000000',
+ text: TEXT,
+ isCreatedByUser: true,
+ sender: 'User',
+ files,
+ },
+ {
+ messageId: structuredId,
+ parentMessageId: messageId,
+ text: TEXT,
+ content: [{ type: 'text', text: TEXT }],
+ isCreatedByUser: true,
+ sender: 'User',
+ files,
+ },
+ {
+ messageId: filesOnlyId,
+ parentMessageId: structuredId,
+ text: '',
+ content: [{ type: 'text', text: '' }],
+ isCreatedByUser: true,
+ sender: 'User',
+ files: [files[0]],
+ },
+ ]);
+ try {
+ await page.goto(`/c/${conversationId}`);
+ const row = page.locator(`[id="${messageId}"]`);
+ const attachments = row.getByTestId('message-attachments');
+ const body = row.getByTestId('message-body');
+ await expect(body).toContainText(TEXT);
+ await expect(attachments.getByRole('button')).toHaveCount(2);
+ const structured = page.locator(`[id="${structuredId}"]`);
+ await expect(structured.getByTestId('message-attachments').getByRole('button')).toHaveCount(
+ 2,
+ );
+ await expect(structured.getByTestId('message-body')).toContainText(TEXT);
+ expect(await body.getByRole('button').count()).toBe(0);
+ const bubbleColor = await body.evaluate((node) => getComputedStyle(node).backgroundColor);
+ for (const button of await attachments.getByRole('button').all()) {
+ const border = await button.evaluate((node) => {
+ const style = getComputedStyle(node);
+ return { color: style.borderTopColor, width: style.borderTopWidth };
+ });
+ expect(border).toEqual({ color: bubbleColor, width: '1px' });
+ }
+ await expect(page.locator(`[id="${filesOnlyId}"]`).getByTestId('message-body')).toHaveCount(
+ 0,
+ );
+ await test.info().attach(`attachments-${definition}-${mode}`, {
+ body: await row.screenshot({ animations: 'disabled' }),
+ contentType: 'image/png',
+ });
+ await attachments.getByRole('button', { name: 'View preview.png in dialog' }).click();
+ await expect(page.getByRole('dialog')).toBeVisible();
+ await page.keyboard.press('Escape');
+ await expect(page.getByRole('dialog')).toBeHidden();
+ await withMongo(async (db) => {
+ await db
+ .collection('messages')
+ .updateMany(
+ { conversationId, messageId: { $in: [messageId, structuredId] } },
+ { $set: { privacyRevision: 'attachment-revision' } },
+ );
+ });
+ let ownerAvailable = true;
+ const ownerText = 'Owner-only attachment prompt';
+ await page.route(`**/api/messages/${conversationId}/owner-text`, (route) =>
+ route.fulfill({
+ json: {
+ messages: ownerAvailable
+ ? [messageId, structuredId].map((id) => ({
+ messageId: id,
+ canonicalText: TEXT,
+ revision: 'attachment-revision',
+ text: ownerText,
+ }))
+ : [],
+ },
+ }),
+ );
+ await page.reload();
+ for (const id of [messageId, structuredId]) {
+ const protectedRow = page.locator(`[id="${id}"]`);
+ await expect(protectedRow.getByTestId('message-body')).toContainText(ownerText);
+ await expect(
+ protectedRow.getByTestId('message-attachments').getByRole('button'),
+ ).toHaveCount(2);
+ }
+ await attachments.getByRole('button', { name: 'View preview.png in dialog' }).click();
+ await expect(page.getByRole('dialog')).toBeVisible();
+ await page.keyboard.press('Escape');
+ await expect(page.getByRole('dialog')).toBeHidden();
+ ownerAvailable = false;
+ await page.reload();
+ for (const id of [messageId, structuredId]) {
+ const protectedRow = page.locator(`[id="${id}"]`);
+ await expect(protectedRow.getByTestId('message-body')).toContainText(TEXT);
+ await expect(
+ protectedRow.getByRole('button', { name: 'Retry loading original' }),
+ ).toBeVisible();
+ await expect(
+ protectedRow.getByTestId('message-attachments').getByRole('button'),
+ ).toHaveCount(2);
+ }
+ await page.setViewportSize({ width: 320, height: 800 });
+ await expect(attachments).toBeVisible();
+ expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
+ 320,
+ );
+ } finally {
+ await Promise.all([
+ deleteMessagesByConversation([conversationId]),
+ deleteConversations([conversationId]),
+ ]);
+ }
+ });
+ }
+}