diff --git a/src/app/components/message/layout/Bubble.tsx b/src/app/components/message/layout/Bubble.tsx index bd2056b9..6f4e94b0 100644 --- a/src/app/components/message/layout/Bubble.tsx +++ b/src/app/components/message/layout/Bubble.tsx @@ -60,13 +60,13 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>( {header &&
{header}
} {hideBubble ? ( children ) : ( - + { + 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(//); + }); +}); diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 3037e66b..3d1a9c83 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -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, diff --git a/src/preview-demo/fixtures.ts b/src/preview-demo/fixtures.ts index 18b2fac1..f88ff746 100644 --- a/src/preview-demo/fixtures.ts +++ b/src/preview-demo/fixtures.ts @@ -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 }, ], }, { diff --git a/src/preview-demo/server.ts b/src/preview-demo/server.ts index ed8cbf46..4a70c95b 100644 --- a/src/preview-demo/server.ts +++ b/src/preview-demo/server.ts @@ -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, }); }); });