Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed494d2ae5 |
@@ -1,10 +1,11 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
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.
|
// full-screen fixed panel on narrow viewports — the app's canonical drawer.
|
||||||
export const BookmarksPanel = style({
|
export const BookmarksPanel = style({
|
||||||
width: toRem(266),
|
width: SIDE_PANEL_WIDTH,
|
||||||
'@media': {
|
'@media': {
|
||||||
'(max-width: 750px)': {
|
'(max-width: 750px)': {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ import { settingsAtom } from '../../../state/settings';
|
|||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { useAlive } from '../../../hooks/useAlive';
|
import { useAlive } from '../../../hooks/useAlive';
|
||||||
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
||||||
import { buildTopicContent } from '../../../utils/topicMarkdown';
|
|
||||||
|
const MARKDOWN_PATTERN = /(\*\*|__|\*|_|~~|`|\[.+?\]\(.+?\))/;
|
||||||
|
|
||||||
function wrapSelection(textarea: HTMLTextAreaElement, syntax: string, placeholder: string) {
|
function wrapSelection(textarea: HTMLTextAreaElement, syntax: string, placeholder: string) {
|
||||||
const start = textarea.selectionStart;
|
const start = textarea.selectionStart;
|
||||||
@@ -70,6 +71,30 @@ function wrapSelection(textarea: HTMLTextAreaElement, syntax: string, placeholde
|
|||||||
textarea.setSelectionRange(cursorStart, cursorEnd);
|
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 = {
|
type RoomProfileEditProps = {
|
||||||
canEditAvatar: boolean;
|
canEditAvatar: boolean;
|
||||||
canEditName: boolean;
|
canEditName: boolean;
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
import { color, config, toRem } from 'folds';
|
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
|
// 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
|
// timeline), exactly like MembersDrawer — not a floating overlay. Same width as
|
||||||
// little wider than the 266px member/bookmark drawers because it hosts a media
|
// every other right-hand panel (styles/sidePanel.ts, #263). On narrow viewports
|
||||||
// grid. On narrow viewports it becomes a full-screen fixed panel, matching the
|
// it becomes a full-screen fixed panel, matching the app's other drawers.
|
||||||
// app's other drawers.
|
|
||||||
export const MediaGalleryDrawer = style({
|
export const MediaGalleryDrawer = style({
|
||||||
width: toRem(320),
|
width: SIDE_PANEL_WIDTH,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
'@media': {
|
'@media': {
|
||||||
'(max-width: 750px)': {
|
'(max-width: 750px)': {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { keyframes, style } from '@vanilla-extract/css';
|
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({
|
export const MembersDrawer = style({
|
||||||
width: toRem(266),
|
width: SIDE_PANEL_WIDTH,
|
||||||
'@media': {
|
'@media': {
|
||||||
'(max-width: 750px)': {
|
'(max-width: 750px)': {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
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({
|
export const ThreadPanel = style({
|
||||||
width: toRem(360),
|
width: SIDE_PANEL_WIDTH,
|
||||||
'@media': {
|
'@media': {
|
||||||
'(max-width: 750px)': {
|
'(max-width: 750px)': {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
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({
|
export const ThreadsListPanel = style({
|
||||||
width: toRem(340),
|
width: SIDE_PANEL_WIDTH,
|
||||||
'@media': {
|
'@media': {
|
||||||
'(max-width: 750px)': {
|
'(max-width: 750px)': {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
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({
|
export const WidgetsPanel = style({
|
||||||
width: toRem(360),
|
width: SIDE_PANEL_WIDTH,
|
||||||
'@media': {
|
'@media': {
|
||||||
'(max-width: 750px)': {
|
'(max-width: 750px)': {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
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" });
|
|
||||||
});
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
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