Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72277399f0 | ||
|
|
e1168f47bb | ||
|
|
f5a184e428 | ||
|
|
a5471a0576 | ||
|
|
ed494d2ae5 | ||
|
|
ce1354ed93 | ||
|
|
adf94d7bc1 | ||
|
|
0eccf9bea9 | ||
|
|
62c9ac8436 | ||
|
|
aaf75fe74a |
+1
-1
@@ -233,7 +233,7 @@ All decoration files are 256×256 APNGs. They animate natively in all modern bro
|
||||
Decoration preference is stored in the public Matrix profile field `io.lotus.avatar_decoration` (a slug string, e.g. `lotus_flower`). Any Lotus Chat user viewing your profile sees your current decoration.
|
||||
|
||||
**CDN:**
|
||||
Files are self-hosted on the Lotus Nextcloud instance. Direct access: `https://drive.lotusguild.org/public.php/dav/files/{token}/cinny-decorations/{slug}.png`. `<img>` elements load cross-origin freely — no CORS headers needed.
|
||||
Static files at `https://chat.lotusguild.org/decorations/{slug}.png` (thumbnails: `thumbs/{slug}.webp`), cached for a week. They come from the versioned `avatar-decorations` package in Gitea's package registry, installed by the matrix repo's LXC 106 deploy (see its README, "Avatar decorations"). To add or replace decorations, publish a new package version there, then update the catalog in `avatarDecorations.ts`. They used to be served from Nextcloud (matrix #22). `<img>` elements load cross-origin freely, so no CORS headers are needed.
|
||||
|
||||
**Module-level cache with in-flight deduplication:**
|
||||
`useAvatarDecoration(userId)` fetches the profile field once per user per session. A `Map<userId, slug|null>` cache prevents redundant requests; a second `pending` waiters map ensures multiple components requesting the same userId simultaneously share one HTTP request rather than firing duplicates.
|
||||
|
||||
Generated
+4
-4
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.25",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.26",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
@@ -2695,9 +2695,9 @@
|
||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||
},
|
||||
"node_modules/@lotusguild/element-call-embedded": {
|
||||
"version": "0.25.0-lotus.25",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.25/element-call-embedded-0.25.0-lotus.25.tgz",
|
||||
"integrity": "sha512-Vvw6B+QoG0/qFJcBzhlAbE+vnk83DikpeeF2xbygpoHfmG9r1H/mS47V91jAMH2vmNbGiALwFlLbJjEET43A2g==",
|
||||
"version": "0.25.0-lotus.26",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.26/element-call-embedded-0.25.0-lotus.26.tgz",
|
||||
"integrity": "sha512-u0gFV/ne61MHVkl2JFY6TjC0C/vY2oYTz6dFaDtAmdLUBH6gU+6ar0QOr5xs9bhr4+HT8CSBibU8mLTR+ByjKA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.25",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.26",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
// of this file at runtime (by regex) instead of re-declaring it, so the two can
|
||||
// never drift. If you migrate the CDN, change it here ONLY — keep the
|
||||
// `export const DECORATION_CDN = '...'` shape so the sync script can still parse it.
|
||||
export const DECORATION_CDN =
|
||||
'https://drive.lotusguild.org/public.php/dav/files/bHswJ9pNKp2t26N/cinny-decorations';
|
||||
// matrix #22: static files on chat.lotusguild.org (a versioned package installed
|
||||
// by the matrix repo's LXC 106 deploy), not Nextcloud's public DAV. Absolute, so
|
||||
// the desktop app and the Element Call fork's origin allowlist both work.
|
||||
export const DECORATION_CDN = 'https://chat.lotusguild.org/decorations';
|
||||
|
||||
// Runtime base. A deployment can repoint the decorations at a different host
|
||||
// without editing the catalog literal above (which scripts/syncDecorations.mjs
|
||||
|
||||
@@ -32,24 +32,12 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../common-settings/styles.css';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { isValidServerPattern, matchesAnyGlob } from '../../utils/serverAcl';
|
||||
import { isValidServerPattern, matchesAnyGlob, normalizeServerAcl } from '../../utils/serverAcl';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ServerAclContent = {
|
||||
allow: string[];
|
||||
deny: string[];
|
||||
allow_ip_literals: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_ACL: ServerAclContent = {
|
||||
allow: ['*'],
|
||||
deny: [],
|
||||
allow_ip_literals: false,
|
||||
};
|
||||
|
||||
// ── Server list sub-component ─────────────────────────────────────────────────
|
||||
|
||||
type ServerListProps = {
|
||||
@@ -185,20 +173,20 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
||||
|
||||
// Read current ACL from room state
|
||||
const aclEvent = useStateEvent(room, StateEvent.RoomServerAcl);
|
||||
const currentAcl = aclEvent?.getContent<ServerAclContent>() ?? DEFAULT_ACL;
|
||||
const currentAcl = normalizeServerAcl(aclEvent?.getContent());
|
||||
|
||||
// Local draft state — initialised from current ACL
|
||||
const [allowList, setAllowList] = useState<string[]>(() => currentAcl.allow ?? ['*']);
|
||||
const [denyList, setDenyList] = useState<string[]>(() => currentAcl.deny ?? []);
|
||||
const [allowList, setAllowList] = useState<string[]>(() => currentAcl.allow);
|
||||
const [denyList, setDenyList] = useState<string[]>(() => currentAcl.deny);
|
||||
const [allowIpLiterals, setAllowIpLiterals] = useState<boolean>(
|
||||
() => currentAcl.allow_ip_literals ?? false,
|
||||
() => currentAcl.allow_ip_literals,
|
||||
);
|
||||
|
||||
// Track whether there are unsaved changes
|
||||
const isDirty =
|
||||
JSON.stringify(allowList) !== JSON.stringify(currentAcl.allow ?? ['*']) ||
|
||||
JSON.stringify(denyList) !== JSON.stringify(currentAcl.deny ?? []) ||
|
||||
allowIpLiterals !== (currentAcl.allow_ip_literals ?? false);
|
||||
JSON.stringify(allowList) !== JSON.stringify(currentAcl.allow) ||
|
||||
JSON.stringify(denyList) !== JSON.stringify(currentAcl.deny) ||
|
||||
allowIpLiterals !== currentAcl.allow_ip_literals;
|
||||
|
||||
// Save handler
|
||||
const [saveState, save] = useAsyncCallback(
|
||||
@@ -336,7 +324,10 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
||||
id="allow-ip-literals"
|
||||
checked={allowIpLiterals}
|
||||
disabled={!canEdit}
|
||||
onClick={() => setAllowIpLiterals(!allowIpLiterals)}
|
||||
// folds' Checkbox has no onChange; readOnly tells React the
|
||||
// click handler controls it (no "checked without onChange").
|
||||
readOnly
|
||||
onClick={(e) => setAllowIpLiterals(e.currentTarget.checked)}
|
||||
size="300"
|
||||
variant="Primary"
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getOriginBaseUrl, withOriginBaseUrl } from '../../../pages/pathUtils';
|
||||
import pkg from '../../../../../package.json';
|
||||
import { clearCacheAndReload } from '../../../../client/initMatrix';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { tauriInvoke } from '../../../hooks/useTauri';
|
||||
|
||||
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-logo.png');
|
||||
|
||||
@@ -78,6 +79,51 @@ function useServerSupport(): { support: MSC1929Support | null; loading: boolean
|
||||
type AboutProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
/**
|
||||
* [Gitea #276] Which build someone is on: the web deploy's commit and build
|
||||
* date (stamped in by vite.config.js), plus the desktop app's own version from
|
||||
* Tauri, the number a desktop or updater bug report needs.
|
||||
*/
|
||||
function BuildInfo() {
|
||||
const [desktopVersion, setDesktopVersion] = useState<string>();
|
||||
useEffect(() => {
|
||||
tauriInvoke()?.('plugin:app|version')
|
||||
.then((v) => {
|
||||
if (typeof v === 'string') setDesktopVersion(v);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
const commit = __LOTUS_BUILD_COMMIT__;
|
||||
const built = new Date(__LOTUS_BUILD_DATE__);
|
||||
const builtText = Number.isNaN(built.getTime())
|
||||
? undefined
|
||||
: built.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
||||
return (
|
||||
<Box direction="Column" gap="0">
|
||||
{desktopVersion && (
|
||||
<Text size="T200" priority="300">
|
||||
Desktop {desktopVersion}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="T200" priority="300">
|
||||
Web build{' '}
|
||||
{commit !== 'unknown' ? (
|
||||
<a
|
||||
href={`https://code.lotusguild.org/LotusGuild/cinny/commit/${commit}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{commit}
|
||||
</a>
|
||||
) : (
|
||||
commit
|
||||
)}
|
||||
{builtText && ` · built ${builtText}`}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function About({ requestClose }: AboutProps) {
|
||||
const mx = useMatrixClient();
|
||||
const { support: serverSupport, loading: supportLoading } = useServerSupport();
|
||||
@@ -117,6 +163,7 @@ export function About({ requestClose }: AboutProps) {
|
||||
<Text size="T200">v{pkg.version}</Text>
|
||||
</Box>
|
||||
<Text>A Matrix client for Lotus Guild.</Text>
|
||||
<BuildInfo />
|
||||
</Box>
|
||||
|
||||
<Box gap="200" wrap="Wrap">
|
||||
|
||||
@@ -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,6 +1,11 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isValidServerPattern, globToRegExp, matchesAnyGlob } from './serverAcl';
|
||||
import {
|
||||
isValidServerPattern,
|
||||
globToRegExp,
|
||||
matchesAnyGlob,
|
||||
normalizeServerAcl,
|
||||
} from './serverAcl';
|
||||
|
||||
test('isValidServerPattern: accepts hostnames, IPs, and globs', () => {
|
||||
for (const v of [
|
||||
@@ -42,3 +47,35 @@ test('matchesAnyGlob: true if any glob matches (self-ban detection)', () => {
|
||||
assert.equal(matchesAnyGlob('lotusguild.org', ['*.evil.com']), false);
|
||||
assert.equal(matchesAnyGlob('lotusguild.org', []), false);
|
||||
});
|
||||
|
||||
test('no ACL event: every server allowed, IP literals allowed (#261)', () => {
|
||||
assert.deepEqual(normalizeServerAcl(undefined), {
|
||||
allow: ['*'],
|
||||
deny: [],
|
||||
allow_ip_literals: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('a missing allow_ip_literals defaults to true, per the spec (#261)', () => {
|
||||
assert.equal(normalizeServerAcl({ allow: ['*'], deny: ['bad.example'] }).allow_ip_literals, true);
|
||||
});
|
||||
|
||||
test('an explicit allow_ip_literals is kept', () => {
|
||||
assert.equal(
|
||||
normalizeServerAcl({ allow: ['*'], allow_ip_literals: false }).allow_ip_literals,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeServerAcl({ allow: ['*'], allow_ip_literals: true }).allow_ip_literals,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('missing or malformed lists become empty, non-strings are dropped', () => {
|
||||
assert.deepEqual(normalizeServerAcl({ allow: ['a.example', 3, null] }), {
|
||||
allow: ['a.example'],
|
||||
deny: [],
|
||||
allow_ip_literals: true,
|
||||
});
|
||||
assert.deepEqual(normalizeServerAcl({ allow: 'a.example', deny: {} }).allow, []);
|
||||
});
|
||||
|
||||
@@ -50,3 +50,29 @@ export function matchesAnyGlob(domain: string, globs: string[]): boolean {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `m.room.server_acl` content with the spec's defaults filled in (#261).
|
||||
* `allow_ip_literals` defaults to **true** when absent; `allow` and `deny`
|
||||
* default to empty lists, but with no ACL event at all every server is allowed,
|
||||
* so the editor starts from `allow: ['*']` in that case.
|
||||
* https://spec.matrix.org/v1.12/client-server-api/#mroomserver_acl
|
||||
*/
|
||||
export type ServerAclContent = {
|
||||
allow: string[];
|
||||
deny: string[];
|
||||
allow_ip_literals: boolean;
|
||||
};
|
||||
|
||||
const stringList = (value: unknown): string[] | undefined =>
|
||||
Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : undefined;
|
||||
|
||||
export function normalizeServerAcl(content: Record<string, unknown> | undefined): ServerAclContent {
|
||||
if (!content) return { allow: ['*'], deny: [], allow_ip_literals: true };
|
||||
return {
|
||||
allow: stringList(content.allow) ?? [],
|
||||
deny: stringList(content.deny) ?? [],
|
||||
allow_ip_literals:
|
||||
typeof content.allow_ip_literals === 'boolean' ? content.allow_ip_literals : true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Vendored
+4
@@ -1,5 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
// [Gitea #276] Set by vite.config.js `define`.
|
||||
declare const __LOTUS_BUILD_COMMIT__: string;
|
||||
declare const __LOTUS_BUILD_DATE__: string;
|
||||
|
||||
declare module 'browser-encrypt-attachment' {
|
||||
export interface EncryptedAttachmentInfo {
|
||||
v: string;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import fs from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import buildConfig from './build.config';
|
||||
|
||||
@@ -272,8 +273,26 @@ const vendorChunks = (id) => {
|
||||
if (id.includes('node_modules/chroma-js')) return 'chroma';
|
||||
};
|
||||
|
||||
// [Gitea #276] Which build is this? The short commit and build time are shown
|
||||
// in Settings → About (web deploys change many times a day while the package
|
||||
// version stays the same). LOTUS_BUILD_COMMIT overrides; no git → "unknown".
|
||||
const buildCommit = (() => {
|
||||
if (process.env.LOTUS_BUILD_COMMIT) return process.env.LOTUS_BUILD_COMMIT.slice(0, 12);
|
||||
try {
|
||||
return execSync('git rev-parse --short=8 HEAD', { stdio: ['ignore', 'pipe', 'ignore'] })
|
||||
.toString()
|
||||
.trim();
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
})();
|
||||
|
||||
export default defineConfig({
|
||||
appType: 'spa',
|
||||
define: {
|
||||
__LOTUS_BUILD_COMMIT__: JSON.stringify(buildCommit),
|
||||
__LOTUS_BUILD_DATE__: JSON.stringify(new Date().toISOString()),
|
||||
},
|
||||
publicDir: false,
|
||||
base: buildConfig.base,
|
||||
server: {
|
||||
|
||||
Reference in New Issue
Block a user