A long reply quote stays inside its column #112

Merged
void merged 3 commits from void/reply-preview-bounds-r into dev 2026-11-12 10:50:04 +00:00
5 changed files with 92 additions and 14 deletions

View file

@ -60,13 +60,13 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
<Box
grow={own ? 'No' : 'Yes'}
direction="Column"
className={classNames(own && css.BubbleColumnOwn)}
className={classNames(css.BubbleColumn, own && css.BubbleColumnOwn)}
>
{header && <div className={classNames(own && css.BubbleHeaderOwn)}>{header}</div>}
{hideBubble ? (
children
) : (
<Box>
<Box className={css.BubbleContentRow}>
<Box
className={classNames(
css.BubbleContent,

View file

@ -0,0 +1,48 @@
import { readFileSync } from 'fs';
import { describe, expect, it } from 'vitest';
import zlib from 'zlib';
import { compile, processVanillaFile } from '@vanilla-extract/integration';
// A long reply quote (nowrap) sized the own-message row to 782px on a 390px phone. The row, the column and the bubble
// must be able to shrink to their parent, so pin the emitted declarations (as phoneTapTargets.test.ts does).
const emitted = async () => {
const filePath = `${process.cwd()}/src/app/components/message/layout/layout.css.ts`;
const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' });
const code = await processVanillaFile({ source, filePath, identOption: 'debug' });
const bytes = Buffer.from(
(code.match(/source=#?([A-Za-z0-9+/=]+)/) as RegExpMatchArray)[1],
'base64'
);
return (bytes[0] === 0x1f && bytes[1] === 0x8b ? zlib.gunzipSync(bytes) : bytes).toString();
};
const declarations = (css: string, className: string): string[] => {
const rule = new RegExp(`^\\.layout_${className}__[\\w-]+ \\{\\n([^}]*)\\}`, 'm').exec(css);
expect(rule, `no .layout_${className} rule emitted`).toBeTruthy();
return (rule as RegExpExecArray)[1].split('\n').map((l) => l.trim().replace(/;$/, ''));
};
describe('bubble rows shrink to their column', () => {
it.each([
['BubbleColumn', ['min-width: 0']],
['BubbleContentRow', ['min-width: 0', 'max-width: 100%']],
['BubbleContent', ['min-width: 0']],
])('%s', async (name, expected) => {
const css = await emitted();
expect(declarations(css, name)).toEqual(expect.arrayContaining(expected));
});
it('BubbleContent keeps its 800px cap inside the column width', async () => {
const css = await emitted();
expect(declarations(css, 'BubbleContent').join('\n')).toMatch(/max-width: min\(100%, 50rem\)/);
});
it('Bubble.tsx puts BubbleColumn on the column and BubbleContentRow on the row', () => {
const tsx = readFileSync(
`${process.cwd()}/src/app/components/message/layout/Bubble.tsx`,
'utf8'
);
expect(tsx).toMatch(/classNames\(css\.BubbleColumn,/);
expect(tsx).toMatch(/<Box className=\{css\.BubbleContentRow\}>/);
});
});

View file

@ -139,8 +139,22 @@ export const BubbleBefore = style({
minWidth: toRem(36),
});
// minWidth 0 down the column: a flex item never shrinks below its content (a reply quote's unbroken text),
// so without it a long quote pushes the column past the row, and a right-aligned row pushes it off the left edge.
export const BubbleColumn = style({
minWidth: 0,
});
// The row around a bubble sizes to its content in a right-aligned (own) column, and a quote's nowrap text
// makes that content as wide as the quote: cap the row at the column so the bubble can shrink inside it.
export const BubbleContentRow = style({
minWidth: 0,
maxWidth: '100%',
});
export const BubbleContent = style({
maxWidth: toRem(800),
minWidth: 0,
maxWidth: `min(100%, ${toRem(800)})`,
padding: config.space.S200,
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,

View file

@ -33,6 +33,8 @@ export type DemoLine = {
body: string;
html?: string;
pose?: string;
// Index of an earlier line in the same room this one replies to.
replyTo?: number;
minutesAgo: number;
};
export type DemoRoom = {
@ -116,6 +118,14 @@ export const ROOMS: DemoRoom[] = [
},
// A long display name: the name pill has to show all of it on a phone.
{ from: user('heimdall'), body: 'Ok.', pose: 'thinking', minutesAgo: 20 },
// A long message to reply to: the quote above each reply must stay inside its column on a phone.
{
from: user('visitor'),
body: "The read exists from 2026-11-04. It's research only, nothing is wired up yet, so please do not treat the notes_in_a_single_unbroken_token_that_keeps_going_past_the_edge as a decision.",
minutesAgo: 12,
},
{ from: ME, body: 'Noted, thank you.', replyTo: 8, minutesAgo: 11 },
{ from: user('void'), body: 'Same here.', replyTo: 8, minutesAgo: 10 },
],
},
{

View file

@ -145,21 +145,27 @@ export const createDemoServer = (now: () => number = Date.now): DemoServer => {
ROOMS.forEach((room) => {
stateOf.set(room.id, buildState(room));
const lineIds: string[] = [];
room.lines.forEach((l) => {
const event = mk(
'm.room.message',
l.from,
{
msgtype: 'm.text',
body: l.body,
...(l.html ? { format: 'org.matrix.custom.html', formatted_body: l.html } : {}),
...(l.pose ? { [POSE_KEY]: l.pose } : {}),
...(l.replyTo === undefined
? {}
: { 'm.relates_to': { 'm.in_reply_to': { event_id: lineIds[l.replyTo] } } }),
},
now() - l.minutesAgo * 60 * 1000
);
lineIds.push(event.event_id);
timeline.push({
version: 1,
roomId: room.id,
event: mk(
'm.room.message',
l.from,
{
msgtype: 'm.text',
body: l.body,
...(l.html ? { format: 'org.matrix.custom.html', formatted_body: l.html } : {}),
...(l.pose ? { [POSE_KEY]: l.pose } : {}),
},
now() - l.minutesAgo * 60 * 1000
),
event,
});
});
});