Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
885ec7c82e | ||
|
|
7ad2a94e2e | ||
|
|
e1168f47bb | ||
|
|
f5a184e428 | ||
|
|
ed494d2ae5 | ||
|
|
ce1354ed93 |
+19
-1
@@ -118,8 +118,26 @@ test.describe('accessibility', () => {
|
||||
test('room settings', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'More Options' }).first().click();
|
||||
await page.getByText('Room Settings', { exact: true }).click();
|
||||
await expect(page.getByRole('dialog').first()).toBeVisible();
|
||||
// [Gitea #278] The dialog has an accessible name.
|
||||
await expect(page.getByRole('dialog', { name: 'A11y Room settings' })).toBeVisible();
|
||||
await auditPage(page, 'room settings');
|
||||
// [Gitea #278] aria-selected on the power chips, unlabelled pack name and
|
||||
// export date inputs were only on these pages.
|
||||
// One page at a time, in order.
|
||||
for (const name of ['Permissions', 'Emojis & Stickers', 'Export']) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await page.getByRole('dialog').getByText(name, { exact: true }).first().click();
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await page.waitForTimeout(1000);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await auditPage(page, `room settings: ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('inbox invites', async ({ page }) => {
|
||||
await page.goto('/inbox/invites/');
|
||||
await expect(page.getByRole('button', { name: /Known|Unknown|Spam/ }).first()).toBeVisible();
|
||||
await auditPage(page, 'inbox invites');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,7 +82,7 @@ export function ManualVerificationMethodSwitcher({
|
||||
<MenuItem
|
||||
size="300"
|
||||
variant="Surface"
|
||||
aria-selected={value === ManualVerificationMethod.RecoveryPassphrase}
|
||||
aria-pressed={value === ManualVerificationMethod.RecoveryPassphrase}
|
||||
radii="300"
|
||||
onClick={() => handleSelect(ManualVerificationMethod.RecoveryPassphrase)}
|
||||
>
|
||||
@@ -93,7 +93,7 @@ export function ManualVerificationMethodSwitcher({
|
||||
<MenuItem
|
||||
size="300"
|
||||
variant="Surface"
|
||||
aria-selected={value === ManualVerificationMethod.RecoveryKey}
|
||||
aria-pressed={value === ManualVerificationMethod.RecoveryKey}
|
||||
radii="300"
|
||||
onClick={() => handleSelect(ManualVerificationMethod.RecoveryKey)}
|
||||
>
|
||||
|
||||
@@ -7,8 +7,10 @@ import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||
type Modal500Props = {
|
||||
requestClose: () => void;
|
||||
children: ReactNode;
|
||||
/** [Gitea #278] The dialog's accessible name (role="dialog" needs one). */
|
||||
label: string;
|
||||
};
|
||||
export function Modal500({ requestClose, children }: Modal500Props) {
|
||||
export function Modal500({ requestClose, children, label }: Modal500Props) {
|
||||
const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
@@ -26,6 +28,7 @@ export function Modal500({ requestClose, children }: Modal500Props) {
|
||||
variant="Background"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
// On mobile expand to fill the viewport. On desktop fall back to the
|
||||
// folds `size="500"` width (~50rem) — overriding maxWidth here would
|
||||
// squish the two-pane settings layout.
|
||||
|
||||
@@ -117,7 +117,14 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||
|
||||
const renderPlaceholder = useCallback(
|
||||
({ attributes, children }: RenderPlaceholderProps) => (
|
||||
<span {...attributes} className={css.EditorPlaceholderContainer}>
|
||||
<span
|
||||
{...attributes}
|
||||
// [Gitea #278] Slate puts an inline `opacity: 0.333` on the placeholder,
|
||||
// which beat EditorPlaceholderContainer's AA-contrast opacity (#222):
|
||||
// measured 2.66:1. Drop it so the class decides.
|
||||
style={{ ...attributes.style, opacity: undefined }}
|
||||
className={css.EditorPlaceholderContainer}
|
||||
>
|
||||
{/* Inner component to style the actual text position and appearance */}
|
||||
<Text as="span" className={css.EditorPlaceholderTextVisual} truncate>
|
||||
{children}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function UsageSelector({ selected, onChange }: UsageSelectorProps) {
|
||||
key={getUsageStr(usage)}
|
||||
size="300"
|
||||
variant={isSelected(usage) ? 'SurfaceVariant' : 'Surface'}
|
||||
aria-selected={isSelected(usage)}
|
||||
aria-pressed={isSelected(usage)}
|
||||
radii="300"
|
||||
onClick={() => onChange(usage)}
|
||||
>
|
||||
|
||||
@@ -69,7 +69,7 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
|
||||
variant={selectedDay === day ? 'Primary' : 'SurfaceVariant'}
|
||||
fill="None"
|
||||
radii="300"
|
||||
aria-selected={selectedDay === day}
|
||||
aria-pressed={selectedDay === day}
|
||||
onClick={() => handleDay(day)}
|
||||
disabled={
|
||||
(selectedYear === minYear && selectedMonth === minMonth && day < minDay) ||
|
||||
@@ -90,7 +90,7 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
|
||||
variant={selectedMonth === month ? 'Primary' : 'SurfaceVariant'}
|
||||
fill="None"
|
||||
radii="300"
|
||||
aria-selected={selectedMonth === month}
|
||||
aria-pressed={selectedMonth === month}
|
||||
onClick={() => handleMonth(month)}
|
||||
disabled={
|
||||
(selectedYear === minYear && month < minMonth) ||
|
||||
@@ -115,7 +115,7 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
|
||||
variant={selectedYear === year ? 'Primary' : 'SurfaceVariant'}
|
||||
fill="None"
|
||||
radii="300"
|
||||
aria-selected={selectedYear === year}
|
||||
aria-pressed={selectedYear === year}
|
||||
onClick={() => handleYear(year)}
|
||||
>
|
||||
<Text size="T300">{year}</Text>
|
||||
|
||||
@@ -71,7 +71,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
||||
variant={hour === selectedHour ? 'Primary' : 'Background'}
|
||||
fill="None"
|
||||
radii="300"
|
||||
aria-selected={hour === selectedHour}
|
||||
aria-pressed={hour === selectedHour}
|
||||
onClick={() => handleHour(hour)}
|
||||
disabled={(minDay && hour < minHour24) || (maxDay && hour > maxHour24)}
|
||||
>
|
||||
@@ -90,7 +90,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
||||
variant={hour === selectedHour ? 'Primary' : 'Background'}
|
||||
fill="None"
|
||||
radii="300"
|
||||
aria-selected={hour === selectedHour}
|
||||
aria-pressed={hour === selectedHour}
|
||||
onClick={() => handleHour(hour)}
|
||||
disabled={
|
||||
(minDay && hour12to24(hour, selectedPM) < minHour24) ||
|
||||
@@ -109,7 +109,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
||||
variant={minute === selectedMinute ? 'Primary' : 'Background'}
|
||||
fill="None"
|
||||
radii="300"
|
||||
aria-selected={minute === selectedMinute}
|
||||
aria-pressed={minute === selectedMinute}
|
||||
onClick={() => handleMinute(minute)}
|
||||
disabled={
|
||||
(minDay && hour24 === minHour24 && minute < minMinute) ||
|
||||
@@ -127,7 +127,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
||||
variant={!selectedPM ? 'Primary' : 'SurfaceVariant'}
|
||||
fill="None"
|
||||
radii="300"
|
||||
aria-selected={!selectedPM}
|
||||
aria-pressed={!selectedPM}
|
||||
onClick={() => handlePeriod(false)}
|
||||
disabled={minDay && minPM}
|
||||
>
|
||||
@@ -138,7 +138,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
||||
variant={selectedPM ? 'Primary' : 'SurfaceVariant'}
|
||||
fill="None"
|
||||
radii="300"
|
||||
aria-selected={selectedPM}
|
||||
aria-pressed={selectedPM}
|
||||
onClick={() => handlePeriod(true)}
|
||||
disabled={maxDay && !maxPM}
|
||||
>
|
||||
|
||||
@@ -33,7 +33,9 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
|
||||
<AvatarImage
|
||||
className={classNames(css.UserAvatar, className)}
|
||||
src={src}
|
||||
alt={alt}
|
||||
// [Gitea #278] An avatar sits next to the person's name or inside a
|
||||
// labelled button, so without an explicit alt it is decorative.
|
||||
alt={alt ?? ''}
|
||||
onError={() => setError(true)}
|
||||
onLoad={handleLoad}
|
||||
draggable={false}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { config, toRem } from 'folds';
|
||||
import { config } from 'folds';
|
||||
import { SIDE_PANEL_WIDTH } from '../../styles/sidePanel';
|
||||
|
||||
// Mirrors MembersDrawer: a 266px side panel on desktop that becomes a
|
||||
// Mirrors MembersDrawer: a side panel (shared width) on desktop that becomes a
|
||||
// full-screen fixed panel on narrow viewports — the app's canonical drawer.
|
||||
export const BookmarksPanel = style({
|
||||
width: toRem(266),
|
||||
width: SIDE_PANEL_WIDTH,
|
||||
'@media': {
|
||||
'(max-width: 750px)': {
|
||||
position: 'fixed',
|
||||
|
||||
@@ -109,8 +109,11 @@ function CreatePackTile({ packs, roomId }: CreatePackTileProps) {
|
||||
alignItems="End"
|
||||
>
|
||||
<Box direction="Column" gap="100" grow="Yes">
|
||||
<Text size="L400">Name</Text>
|
||||
<Text as="label" htmlFor="room-pack-name" size="L400">
|
||||
Name
|
||||
</Text>
|
||||
<Input
|
||||
id="room-pack-name"
|
||||
name="nameInput"
|
||||
required
|
||||
size="400"
|
||||
|
||||
@@ -52,8 +52,7 @@ import { settingsAtom } from '../../../state/settings';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useAlive } from '../../../hooks/useAlive';
|
||||
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
||||
|
||||
const MARKDOWN_PATTERN = /(\*\*|__|\*|_|~~|`|\[.+?\]\(.+?\))/;
|
||||
import { buildTopicContent } from '../../../utils/topicMarkdown';
|
||||
|
||||
function wrapSelection(textarea: HTMLTextAreaElement, syntax: string, placeholder: string) {
|
||||
const start = textarea.selectionStart;
|
||||
@@ -71,30 +70,6 @@ function wrapSelection(textarea: HTMLTextAreaElement, syntax: string, placeholde
|
||||
textarea.setSelectionRange(cursorStart, cursorEnd);
|
||||
}
|
||||
|
||||
function topicMarkdownToHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/__(.+?)__/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/_(.+?)_/g, '<em>$1</em>')
|
||||
.replace(/~~(.+?)~~/g, '<del>$1</del>')
|
||||
.replace(/`(.+?)`/g, '<code>$1</code>')
|
||||
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
function buildTopicContent(topic: string): Record<string, string> {
|
||||
if (!MARKDOWN_PATTERN.test(topic)) return { topic };
|
||||
const formattedBody = topicMarkdownToHtml(topic);
|
||||
// Use HTML-stripped text as the plain topic so the header shows clean text, not raw markdown syntax
|
||||
const plainTopic = formattedBody.replace(/<br>/g, '\n').replace(/<[^>]+>/g, '');
|
||||
|
||||
return { topic: plainTopic, format: 'org.matrix.custom.html', formatted_body: formattedBody };
|
||||
}
|
||||
|
||||
type RoomProfileEditProps = {
|
||||
canEditAvatar: boolean;
|
||||
canEditName: boolean;
|
||||
|
||||
@@ -136,7 +136,8 @@ export function PermissionGroups({
|
||||
outlined={powerChanges}
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
aria-selected={opened}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={opened}
|
||||
disabled={!canEdit || applyingChanges}
|
||||
after={
|
||||
powerChanges && (
|
||||
@@ -200,7 +201,8 @@ export function PermissionGroups({
|
||||
outlined={powerChanges}
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
aria-selected={opened}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={opened}
|
||||
disabled={!canEdit || applyingChanges}
|
||||
after={
|
||||
powerChanges && (
|
||||
|
||||
@@ -353,8 +353,11 @@ ${msgRows}
|
||||
>
|
||||
<Box gap="400" wrap="Wrap">
|
||||
<Box direction="Column" gap="100" style={{ flex: 1, minWidth: 160 }}>
|
||||
<Text size="T300">From</Text>
|
||||
<Text as="label" htmlFor="export-from-date" size="T300">
|
||||
From
|
||||
</Text>
|
||||
<Input
|
||||
id="export-from-date"
|
||||
type="date"
|
||||
variant="Secondary"
|
||||
size="400"
|
||||
@@ -364,8 +367,11 @@ ${msgRows}
|
||||
/>
|
||||
</Box>
|
||||
<Box direction="Column" gap="100" style={{ flex: 1, minWidth: 160 }}>
|
||||
<Text size="T300">To</Text>
|
||||
<Text as="label" htmlFor="export-to-date" size="T300">
|
||||
To
|
||||
</Text>
|
||||
<Input
|
||||
id="export-to-date"
|
||||
type="date"
|
||||
variant="Secondary"
|
||||
size="400"
|
||||
|
||||
@@ -21,7 +21,7 @@ function RenderSettings({ state }: RenderSettingsProps) {
|
||||
if (!room) return null;
|
||||
|
||||
return (
|
||||
<Modal500 requestClose={closeSettings}>
|
||||
<Modal500 requestClose={closeSettings} label={`${room.name} settings`}>
|
||||
<SpaceProvider value={space ?? null}>
|
||||
<RoomProvider value={room}>
|
||||
<RoomSettings initialPage={page} requestClose={closeSettings} />
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
import { SIDE_PANEL_WIDTH } from '../../styles/sidePanel';
|
||||
|
||||
// Right-side drawer DOCKED into the room layout row (a flex sibling of the
|
||||
// timeline), exactly like MembersDrawer — not a floating overlay. 320px is a
|
||||
// little wider than the 266px member/bookmark drawers because it hosts a media
|
||||
// grid. On narrow viewports it becomes a full-screen fixed panel, matching the
|
||||
// app's other drawers.
|
||||
// timeline), exactly like MembersDrawer — not a floating overlay. Same width as
|
||||
// every other right-hand panel (styles/sidePanel.ts, #263). On narrow viewports
|
||||
// it becomes a full-screen fixed panel, matching the app's other drawers.
|
||||
export const MediaGalleryDrawer = style({
|
||||
width: toRem(320),
|
||||
width: SIDE_PANEL_WIDTH,
|
||||
overflow: 'hidden',
|
||||
'@media': {
|
||||
'(max-width: 750px)': {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
import { config, toRem } from 'folds';
|
||||
import { config } from 'folds';
|
||||
import { SIDE_PANEL_WIDTH } from '../../styles/sidePanel';
|
||||
|
||||
export const MembersDrawer = style({
|
||||
width: toRem(266),
|
||||
width: SIDE_PANEL_WIDTH,
|
||||
'@media': {
|
||||
'(max-width: 750px)': {
|
||||
position: 'fixed',
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { config, toRem } from 'folds';
|
||||
import { config } from 'folds';
|
||||
import { SIDE_PANEL_WIDTH } from '../../../styles/sidePanel';
|
||||
|
||||
export const ThreadPanel = style({
|
||||
width: toRem(360),
|
||||
width: SIDE_PANEL_WIDTH,
|
||||
'@media': {
|
||||
'(max-width: 750px)': {
|
||||
position: 'fixed',
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
import { color, config } from 'folds';
|
||||
import { SIDE_PANEL_WIDTH } from '../../../styles/sidePanel';
|
||||
|
||||
export const ThreadsListPanel = style({
|
||||
width: toRem(340),
|
||||
width: SIDE_PANEL_WIDTH,
|
||||
'@media': {
|
||||
'(max-width: 750px)': {
|
||||
position: 'fixed',
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { config, toRem } from 'folds';
|
||||
import { config } from 'folds';
|
||||
import { SIDE_PANEL_WIDTH } from '../../../styles/sidePanel';
|
||||
|
||||
export const WidgetsPanel = style({
|
||||
width: toRem(360),
|
||||
width: SIDE_PANEL_WIDTH,
|
||||
'@media': {
|
||||
'(max-width: 750px)': {
|
||||
position: 'fixed',
|
||||
|
||||
@@ -98,7 +98,7 @@ export function NotificationModeSwitcher({ pushRule, onChange }: NotificationMod
|
||||
key={mode}
|
||||
size="300"
|
||||
variant="Surface"
|
||||
aria-selected={mode === selectedMode}
|
||||
aria-pressed={mode === selectedMode}
|
||||
radii="300"
|
||||
onClick={() => handleSelect(mode)}
|
||||
>
|
||||
|
||||
@@ -21,7 +21,7 @@ function RenderSettings({ state }: RenderSettingsProps) {
|
||||
if (!room) return null;
|
||||
|
||||
return (
|
||||
<Modal500 requestClose={closeSettings}>
|
||||
<Modal500 requestClose={closeSettings} label={`${room.name} space settings`}>
|
||||
<SpaceProvider value={space ?? null}>
|
||||
<RoomProvider value={room}>
|
||||
<SpaceSettings initialPage={page} requestClose={closeSettings} />
|
||||
|
||||
@@ -369,7 +369,7 @@ function InviteFilters({
|
||||
<Box gap="200">
|
||||
<Chip
|
||||
variant={isKnown ? 'Success' : 'Surface'}
|
||||
aria-selected={isKnown}
|
||||
aria-pressed={isKnown}
|
||||
outlined={!isKnown}
|
||||
onClick={() => onFilter(InviteFilter.Known)}
|
||||
before={isKnown && <Icon size="100" src={Icons.Check} />}
|
||||
@@ -385,7 +385,7 @@ function InviteFilters({
|
||||
</Chip>
|
||||
<Chip
|
||||
variant={isUnknown ? 'Warning' : 'Surface'}
|
||||
aria-selected={isUnknown}
|
||||
aria-pressed={isUnknown}
|
||||
outlined={!isUnknown}
|
||||
onClick={() => onFilter(InviteFilter.Unknown)}
|
||||
before={isUnknown && <Icon size="100" src={Icons.Check} />}
|
||||
@@ -401,7 +401,7 @@ function InviteFilters({
|
||||
</Chip>
|
||||
<Chip
|
||||
variant={isSpam ? 'Critical' : 'Surface'}
|
||||
aria-selected={isSpam}
|
||||
aria-pressed={isSpam}
|
||||
outlined={!isSpam}
|
||||
onClick={() => onFilter(InviteFilter.Spam)}
|
||||
before={isSpam && <Icon size="100" src={Icons.Check} />}
|
||||
|
||||
@@ -217,7 +217,7 @@ export function SettingsTab() {
|
||||
</SidebarItemTooltip>
|
||||
<PresencePicker />
|
||||
{settingsOpen && (
|
||||
<Modal500 requestClose={() => setSettingsOpen(false)}>
|
||||
<Modal500 requestClose={() => setSettingsOpen(false)} label="Settings">
|
||||
<Settings initialPage={initialPage} requestClose={() => setSettingsOpen(false)} />
|
||||
</Modal500>
|
||||
)}
|
||||
|
||||
@@ -82,7 +82,7 @@ function UnverifiedIndicator() {
|
||||
</SidebarItem>
|
||||
)}
|
||||
{settings && (
|
||||
<Modal500 requestClose={closeSettings}>
|
||||
<Modal500 requestClose={closeSettings} label="Settings">
|
||||
<Settings initialPage={SettingsPages.DevicesPage} requestClose={closeSettings} />
|
||||
</Modal500>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { toRem } from 'folds';
|
||||
|
||||
/**
|
||||
* One width for every right-hand room panel (members, bookmarks, threads, a
|
||||
* thread, media gallery, widgets). Swapping one panel for another must not
|
||||
* resize the room view: when the widths differed, the header icons slid
|
||||
* sideways and a second click on the same spot hit a different button (#263).
|
||||
*/
|
||||
export const SIDE_PANEL_WIDTH = toRem(340);
|
||||
@@ -0,0 +1,72 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildTopicContent } from './topicMarkdown';
|
||||
|
||||
test('a URL with underscores is sent verbatim (#262)', () => {
|
||||
const t = 'Rules: https://wiki.lotusguild.org/en/server_rules_and_faq';
|
||||
assert.deepEqual(buildTopicContent(t), { topic: t });
|
||||
});
|
||||
|
||||
test('a markdown link keeps its underscored URL in both fields (#262)', () => {
|
||||
const c = buildTopicContent('[Our wiki](https://wiki.example.org/my_cool_page)');
|
||||
assert.ok('formatted_body' in c);
|
||||
assert.match(c.formatted_body, /href="https:\/\/wiki\.example\.org\/my_cool_page"/);
|
||||
assert.equal(c.topic, 'Our wiki (https://wiki.example.org/my_cool_page)');
|
||||
});
|
||||
|
||||
test('room aliases and user IDs with underscores are untouched (#262)', () => {
|
||||
for (const t of [
|
||||
'Join #my_room_name:matrix.org',
|
||||
'Ask @some_user_x:matrix.org',
|
||||
'see snake_case_word',
|
||||
]) {
|
||||
assert.deepEqual(buildTopicContent(t), { topic: t });
|
||||
}
|
||||
});
|
||||
|
||||
test('emphasis still works at word boundaries', () => {
|
||||
const c = buildTopicContent('this is _really_ *great* and **bold**');
|
||||
assert.ok('formatted_body' in c);
|
||||
assert.match(c.formatted_body, /<i data-md="_">really<\/i>/);
|
||||
assert.match(c.formatted_body, /<i data-md="\*">great<\/i>/);
|
||||
assert.match(c.formatted_body, /<strong data-md="\*\*">bold<\/strong>/);
|
||||
assert.equal(c.topic, 'this is really great and bold');
|
||||
});
|
||||
|
||||
test('emphasis around an alias keeps the alias intact', () => {
|
||||
const c = buildTopicContent('**Join #my_room:matrix.org**');
|
||||
assert.ok('formatted_body' in c);
|
||||
assert.match(c.formatted_body, /<strong data-md="\*\*">Join #my_room:matrix\.org<\/strong>/);
|
||||
assert.equal(c.topic, 'Join #my_room:matrix.org');
|
||||
});
|
||||
|
||||
test('inline code keeps its underscores and no backslashes appear', () => {
|
||||
const c = buildTopicContent('run `a_b_c` now');
|
||||
assert.ok('formatted_body' in c);
|
||||
assert.match(c.formatted_body, /<code data-md="`">a_b_c<\/code>/);
|
||||
assert.equal(c.topic, 'run a_b_c now');
|
||||
});
|
||||
|
||||
test('a double quote in a link target cannot break out of the attribute (#262)', () => {
|
||||
const c = buildTopicContent('[x](https://a" onclick="alert(1))');
|
||||
assert.ok('formatted_body' in c);
|
||||
assert.doesNotMatch(c.formatted_body, /" onclick="/);
|
||||
});
|
||||
|
||||
test('HTML in the topic is escaped, and the plain topic shows it as typed', () => {
|
||||
const c = buildTopicContent('**<b>hi</b> & bye**');
|
||||
assert.ok('formatted_body' in c);
|
||||
assert.match(c.formatted_body, /<b>hi<\/b> & bye/);
|
||||
assert.equal(c.topic, '<b>hi</b> & bye');
|
||||
});
|
||||
|
||||
test('line breaks become <br> and survive in the plain topic', () => {
|
||||
const c = buildTopicContent('**Rules**\nBe nice');
|
||||
assert.ok('formatted_body' in c);
|
||||
assert.equal(c.formatted_body, '<strong data-md="**">Rules</strong><br>Be nice');
|
||||
assert.equal(c.topic, 'Rules\nBe nice');
|
||||
});
|
||||
|
||||
test('plain text with no markdown is sent as typed', () => {
|
||||
assert.deepEqual(buildTopicContent("It's <fun> & games"), { topic: "It's <fun> & games" });
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { parseInlineMD } from '../plugins/markdown';
|
||||
import { sanitizeText } from './sanitize';
|
||||
|
||||
/**
|
||||
* Room topic markdown → `m.room.topic` content (#262).
|
||||
*
|
||||
* Uses the composer's inline markdown parser (the same rules as messages: links,
|
||||
* bare URLs and `"` are handled), with one topic-specific guard: an `_` between
|
||||
* two letters/digits is literal, as in CommonMark. Topics are full of
|
||||
* `#room_alias:server`, `@user_name:server` and snake_case paths, which the
|
||||
* shared parser would otherwise turn into italics and, worse, drop from the
|
||||
* plain `topic` that every other client shows.
|
||||
*/
|
||||
|
||||
// Spans the guard must not touch: inline code, link targets, bare URLs.
|
||||
const PROTECTED = /(`[^`\n]*`|\]\([^)\n]*\)|https?:\/\/[^\s<>"]+)/;
|
||||
const INTRAWORD_UNDERSCORES = /(?<=[\p{L}\p{N}])_+(?=[\p{L}\p{N}])/gu;
|
||||
|
||||
const escapeIntrawordUnderscores = (line: string): string =>
|
||||
line
|
||||
.split(PROTECTED)
|
||||
.map((part, i) =>
|
||||
// split() with one capture group puts the protected spans at odd indexes.
|
||||
i % 2 === 1 ? part : part.replace(INTRAWORD_UNDERSCORES, (m) => m.replace(/_/g, '\\_')),
|
||||
)
|
||||
.join('');
|
||||
|
||||
const ENTITIES: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
};
|
||||
const decodeEntities = (s: string): string =>
|
||||
s.replace(/&(?:amp|lt|gt|quot|#39);/g, (e) => ENTITIES[e]);
|
||||
|
||||
/** Readable plain text for `topic`: markers dropped, links kept as "text (url)". */
|
||||
const htmlToPlain = (html: string): string =>
|
||||
decodeEntities(
|
||||
html
|
||||
.replace(/<a\b[^>]*\bhref="([^"]*)"[^>]*>(.*?)<\/a>/g, (_m, href: string, label: string) => {
|
||||
const text = label.replace(/<[^>]+>/g, '');
|
||||
return text === href ? href : `${text} (${href})`;
|
||||
})
|
||||
.replace(/<br>/g, '\n')
|
||||
.replace(/<[^>]+>/g, ''),
|
||||
);
|
||||
|
||||
export type TopicContent =
|
||||
| { topic: string }
|
||||
| { topic: string; format: 'org.matrix.custom.html'; formatted_body: string };
|
||||
|
||||
export function buildTopicContent(text: string): TopicContent {
|
||||
const lines = text.split('\n');
|
||||
const html = lines
|
||||
.map((line) => parseInlineMD(sanitizeText(escapeIntrawordUnderscores(line))))
|
||||
.join('<br>');
|
||||
const plainHtml = lines.map(sanitizeText).join('<br>');
|
||||
// No markdown at all: send the text as typed, without a formatted body.
|
||||
if (html === plainHtml) return { topic: text };
|
||||
return { topic: htmlToPlain(html), format: 'org.matrix.custom.html', formatted_body: html };
|
||||
}
|
||||
Reference in New Issue
Block a user