From f38619d9746ccb2f2e30e305ecf085fba0afdc40 Mon Sep 17 00:00:00 2001 From: Hampus Date: Sun, 6 Sep 2026 22:41:57 +0200 Subject: [PATCH] fix(app): isolate bidi usernames from message timestamps (#2560) --- .../features/theme/styles/Message.module.css | 4 ++ .../theme/styles/MessageBidiIsolation.test.ts | 58 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 fluxer_app/src/features/theme/styles/MessageBidiIsolation.test.ts diff --git a/fluxer_app/src/features/theme/styles/Message.module.css b/fluxer_app/src/features/theme/styles/Message.module.css index 53788763e..ebe2ee340 100644 --- a/fluxer_app/src/features/theme/styles/Message.module.css +++ b/fluxer_app/src/features/theme/styles/Message.module.css @@ -428,6 +428,7 @@ max-inline-size: none; overflow: visible; white-space: nowrap; + unicode-bidi: plaintext; user-select: text; -webkit-user-select: text; } @@ -505,6 +506,7 @@ font-weight: 400; color: var(--message-timestamp-color); line-height: var(--message-line-height); + unicode-bidi: isolate; } .messageTimestamp { @@ -557,6 +559,7 @@ font-size: var(--message-timestamp-compact-font-size); font-weight: 500; line-height: var(--message-line-height); + unicode-bidi: isolate; } .messageTimestampHover { @@ -948,6 +951,7 @@ white-space: nowrap; max-width: 30%; vertical-align: baseline; + unicode-bidi: plaintext; font-weight: 500; color: color-mix( in srgb, diff --git a/fluxer_app/src/features/theme/styles/MessageBidiIsolation.test.ts b/fluxer_app/src/features/theme/styles/MessageBidiIsolation.test.ts new file mode 100644 index 000000000..fc855609c --- /dev/null +++ b/fluxer_app/src/features/theme/styles/MessageBidiIsolation.test.ts @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {readFileSync} from 'node:fs'; +import {fileURLToPath} from 'node:url'; +import {describe, expect, it} from 'vitest'; + +const MESSAGE_CSS = readFileSync(fileURLToPath(new URL('./Message.module.css', import.meta.url)), 'utf8'); + +const ISOLATED_NAMES = ['.messageUsername', '.repliedUsername']; +const ISOLATED_TIMESTAMPS = [ + '.messageTimestamp', + '.messageTimestampCompact', + '.messageTimestampHover', + '.messageTimestampCompactHover', +]; + +function rules(css: string): Array<{selector: string; declarations: string}> { + const source = css.replace(/\/\*[\s\S]*?\*\//g, ''); + const out: Array<{selector: string; declarations: string}> = []; + const chain: Array = []; + let buffer = ''; + for (const char of source) { + if (char === '{') { + chain.push(buffer.split(/[;}]/).pop()?.trim().replace(/\s+/g, ' ') ?? ''); + buffer = ''; + } else if (char === '}') { + if (chain.length > 0) { + out.push({selector: chain.join(' '), declarations: buffer}); + chain.pop(); + } + buffer = ''; + } else { + buffer += char; + } + } + return out; +} + +function declaredBidi(className: string): Array { + const selectorPattern = new RegExp(`\\${className}(?![\\w-])`); + const out: Array = []; + for (const rule of rules(MESSAGE_CSS)) { + if (!rule.selector.split(',').some((selector) => selectorPattern.test(selector))) continue; + const match = rule.declarations.match(/unicode-bidi\s*:\s*([^;]+)/); + if (match) out.push(match[1].trim()); + } + return out; +} + +describe('message bidi isolation', () => { + it.each(ISOLATED_NAMES)('resolves %s in its own bidi paragraph', (className) => { + expect(declaredBidi(className)).toContain('plaintext'); + }); + + it.each(ISOLATED_TIMESTAMPS)('keeps %s out of a neighbouring right-to-left run', (className) => { + expect(declaredBidi(className)).toContain('isolate'); + }); +});