Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 ed494d2ae5 fix(room): one width for every right-hand panel (#263)
CI / Build & Quality Checks (pull_request) Successful in 1m43s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m12s
Members and bookmarks were 266px, the gallery 320, the threads list 340,
a thread and widgets 360. Swapping panels resized the room view, so the
header icons slid sideways (74px for members -> threads) and a second
click on the same spot opened Widgets instead of closing the panel. All
six now use SIDE_PANEL_WIDTH (340px, styles/sidePanel.ts).

Fixes #263

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 14:14:26 -04:00
19 changed files with 146 additions and 372 deletions
@@ -34,8 +34,7 @@ export function SettingTile({ title, description, before, after, children }: Set
}); });
return ( return (
// data-setting-tile: lets a group card draw dividers between tiles only (#273). <Box alignItems="Center" gap="300">
<Box alignItems="Center" gap="300" data-setting-tile="">
{before && <Box shrink="No">{before}</Box>} {before && <Box shrink="No">{before}</Box>}
<Box grow="Yes" direction="Column" gap="100"> <Box grow="Yes" direction="Column" gap="100">
{title && ( {title && (
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.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;
+10 -13
View File
@@ -401,19 +401,16 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
{displayName} {displayName}
</Text> </Text>
<Box alignItems="Center" gap="200"> <Box alignItems="Center" gap="200">
{/* [Gitea #270] The bar's % width is of its own track, <div
not of the row, so the count always fits beside it. */} style={{
<div style={{ flex: 1, minWidth: 0 }}> height: 6,
<div width: barWidth,
style={{ background: color.Primary.Main,
height: 6, borderRadius: config.radii.R300,
width: barWidth, transition: 'width 0.3s ease',
background: color.Primary.Main, flexShrink: 0,
borderRadius: config.radii.R300, }}
transition: 'width 0.3s ease', />
}}
/>
</div>
<Text size="T200" priority="300" style={{ flexShrink: 0 }}> <Text size="T200" priority="300" style={{ flexShrink: 0 }}>
{count} {count}
</Text> </Text>
@@ -32,12 +32,24 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { SequenceCard } from '../../components/sequence-card'; import { SequenceCard } from '../../components/sequence-card';
import { SequenceCardStyle } from '../common-settings/styles.css'; import { SequenceCardStyle } from '../common-settings/styles.css';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { isValidServerPattern, matchesAnyGlob, normalizeServerAcl } from '../../utils/serverAcl'; import { isValidServerPattern, matchesAnyGlob } from '../../utils/serverAcl';
import { MobileTouchTarget } from '../../styles/mobile.css'; import { MobileTouchTarget } from '../../styles/mobile.css';
import { useModalStyle } from '../../hooks/useModalStyle'; import { useModalStyle } from '../../hooks/useModalStyle';
// ── Types ───────────────────────────────────────────────────────────────────── // ── 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 ───────────────────────────────────────────────── // ── Server list sub-component ─────────────────────────────────────────────────
type ServerListProps = { type ServerListProps = {
@@ -173,20 +185,20 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
// Read current ACL from room state // Read current ACL from room state
const aclEvent = useStateEvent(room, StateEvent.RoomServerAcl); const aclEvent = useStateEvent(room, StateEvent.RoomServerAcl);
const currentAcl = normalizeServerAcl(aclEvent?.getContent()); const currentAcl = aclEvent?.getContent<ServerAclContent>() ?? DEFAULT_ACL;
// Local draft state — initialised from current ACL // Local draft state — initialised from current ACL
const [allowList, setAllowList] = useState<string[]>(() => currentAcl.allow); const [allowList, setAllowList] = useState<string[]>(() => currentAcl.allow ?? ['*']);
const [denyList, setDenyList] = useState<string[]>(() => currentAcl.deny); const [denyList, setDenyList] = useState<string[]>(() => currentAcl.deny ?? []);
const [allowIpLiterals, setAllowIpLiterals] = useState<boolean>( const [allowIpLiterals, setAllowIpLiterals] = useState<boolean>(
() => currentAcl.allow_ip_literals, () => currentAcl.allow_ip_literals ?? false,
); );
// Track whether there are unsaved changes // Track whether there are unsaved changes
const isDirty = const isDirty =
JSON.stringify(allowList) !== JSON.stringify(currentAcl.allow) || JSON.stringify(allowList) !== JSON.stringify(currentAcl.allow ?? ['*']) ||
JSON.stringify(denyList) !== JSON.stringify(currentAcl.deny) || JSON.stringify(denyList) !== JSON.stringify(currentAcl.deny ?? []) ||
allowIpLiterals !== currentAcl.allow_ip_literals; allowIpLiterals !== (currentAcl.allow_ip_literals ?? false);
// Save handler // Save handler
const [saveState, save] = useAsyncCallback( const [saveState, save] = useAsyncCallback(
@@ -324,10 +336,7 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
id="allow-ip-literals" id="allow-ip-literals"
checked={allowIpLiterals} checked={allowIpLiterals}
disabled={!canEdit} disabled={!canEdit}
// folds' Checkbox has no onChange; readOnly tells React the onClick={() => setAllowIpLiterals(!allowIpLiterals)}
// click handler controls it (no "checked without onChange").
readOnly
onClick={(e) => setAllowIpLiterals(e.currentTarget.checked)}
size="300" size="300"
variant="Primary" variant="Primary"
/> />
+5 -5
View File
@@ -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)': {
+3 -2
View File
@@ -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',
+18 -24
View File
@@ -1137,31 +1137,25 @@ export const Message = React.memo(
</AvatarBase> </AvatarBase>
); );
// [Gitea #269] The content column shrinks to the message's width; the
// receipt pill / delivery status sit OUTSIDE it, in a full-width column,
// so their `margin-left: auto` reaches the row's right edge instead of
// wherever the text happens to end.
const msgContentJSX = ( const msgContentJSX = (
<Box direction="Column" style={{ maxWidth: '100%', flexGrow: 1, minWidth: 0 }}> <Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}> {reply}
{reply} {edit && onEditId ? (
{edit && onEditId ? ( <MessageEditor
<MessageEditor style={{
style={{ maxWidth: '100%',
maxWidth: '100%', width: '100vw',
width: '100vw', }}
}} roomId={room.roomId}
roomId={room.roomId} room={room}
room={room} mEvent={mEvent}
mEvent={mEvent} imagePackRooms={imagePackRooms}
imagePackRooms={imagePackRooms} onCancel={() => onEditId()}
onCancel={() => onEditId()} />
/> ) : (
) : ( children
children )}
)} {reactions}
{reactions}
</Box>
{readReceiptUsers.length > 0 && ( {readReceiptUsers.length > 0 && (
<ReadReceiptAvatars <ReadReceiptAvatars
room={room} room={room}
@@ -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',
@@ -1,12 +1,7 @@
import React, { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Avatar, Box, Button, Chip, Icon, Icons, Input, Text, Spinner, color } from 'folds'; import { Box, Button, Chip, Icon, Icons, Input, Text, Spinner, color } from 'folds';
import { Method } from 'matrix-js-sdk'; import { Method } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useUserProfile } from '../../../hooks/useUserProfile';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { nameInitials } from '../../../utils/common';
import { UserAvatar } from '../../../components/user-avatar';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { import {
@@ -19,8 +14,6 @@ import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
const PROFILE_FIELD = 'io.lotus.avatar_decoration'; const PROFILE_FIELD = 'io.lotus.avatar_decoration';
const CELL_SIZE = 72; const CELL_SIZE = 72;
// Matches AvatarDecoration's default inset, so the preview looks like the timeline.
const PREVIEW_INSET = 8;
function DecorationPreviewCell({ function DecorationPreviewCell({
slug, slug,
@@ -93,12 +86,6 @@ function categoryOf(slug: string | null): string {
export function ProfileDecoration() { export function ProfileDecoration() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const userId = mx.getUserId()!; const userId = mx.getUserId()!;
const useAuthentication = useMediaAuthentication();
const profile = useUserProfile(userId);
const myName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const myAvatarUrl = profile.avatarUrl
? (mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined;
const [current, setCurrent] = useState<string | null>(null); const [current, setCurrent] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null); const [selected, setSelected] = useState<string | null>(null);
@@ -211,10 +198,6 @@ export function ProfileDecoration() {
<Box direction="Column" gap="300"> <Box direction="Column" gap="300">
{/* Current selection preview */} {/* Current selection preview */}
<Box alignItems="Center" gap="300"> <Box alignItems="Center" gap="300">
{/* [Gitea #272] Your own avatar with the decoration overlaid, as the
timeline draws it (AvatarDecoration: the image is the avatar plus
an 8px margin on every side). With no decoration the circle gets
a visible dashed outline instead of vanishing into the card. */}
<div <div
style={{ style={{
position: 'relative', position: 'relative',
@@ -222,29 +205,10 @@ export function ProfileDecoration() {
height: CELL_SIZE, height: CELL_SIZE,
flexShrink: 0, flexShrink: 0,
borderRadius: '50%', borderRadius: '50%',
boxSizing: 'border-box', background: color.SurfaceVariant.Container,
border: selected overflow: 'hidden',
? 'none'
: `2px dashed color-mix(in srgb, ${color.SurfaceVariant.OnContainer} 45%, transparent)`,
}} }}
> >
<Avatar
size="500"
radii="Pill"
style={{
position: 'absolute',
inset: PREVIEW_INSET,
width: CELL_SIZE - PREVIEW_INSET * 2,
height: CELL_SIZE - PREVIEW_INSET * 2,
}}
>
<UserAvatar
userId={userId}
src={myAvatarUrl}
alt=""
renderFallback={() => <Text size="H5">{nameInitials(myName)}</Text>}
/>
</Avatar>
{selected && ( {selected && (
<img <img
src={decorationUrl(selected)} src={decorationUrl(selected)}
@@ -256,7 +220,6 @@ export function ProfileDecoration() {
height: '100%', height: '100%',
objectFit: 'contain', objectFit: 'contain',
pointerEvents: 'none', pointerEvents: 'none',
zIndex: 1,
}} }}
/> />
)} )}
+34 -51
View File
@@ -111,8 +111,7 @@ import {
translationUnavailableText, translationUnavailableText,
} from '../../../utils/translation/langUtils'; } from '../../../utils/translation/langUtils';
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine'; import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
import { SequenceCardStyle, SettingTileGroup } from '../styles.css'; import { SequenceCardStyle } from '../styles.css';
import { ScreenSize, useScreenSize } from '../../../hooks/useScreenSize';
import { UpdateProgress, useTauriUpdater } from '../../../hooks/useTauriUpdater'; import { UpdateProgress, useTauriUpdater } from '../../../hooks/useTauriUpdater';
import { import {
describeUpdateError, describeUpdateError,
@@ -1599,7 +1598,6 @@ function Privacy() {
// [Gitea #104] Sync toggle + the two manual actions. The toggle is itself // [Gitea #104] Sync toggle + the two manual actions. The toggle is itself
// device-local (never synced) so switching it off here sticks. // device-local (never synced) so switching it off here sticks.
function SettingsSyncSection() { function SettingsSyncSection() {
const isMobile = useScreenSize() === ScreenSize.Mobile;
const [settingsSync, setSettingsSync] = useSetting(settingsAtom, 'settingsSync'); const [settingsSync, setSettingsSync] = useSetting(settingsAtom, 'settingsSync');
const { pushNow, clearRemote } = useSettingsSyncActions(); const { pushNow, clearRemote } = useSettingsSyncActions();
const [busy, setBusy] = useState<'push' | 'clear' | null>(null); const [busy, setBusy] = useState<'push' | 'clear' | null>(null);
@@ -1623,34 +1621,6 @@ function SettingsSyncSection() {
} }
}; };
const syncButtons = (
<Box gap="200">
<Button
size="300"
variant="Secondary"
fill="Soft"
outlined
radii="300"
disabled={busy !== null}
before={busy === 'push' ? <Spinner size="100" variant="Secondary" /> : undefined}
onClick={() => run('push')}
>
<Text size="B300">Push now</Text>
</Button>
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
disabled={busy !== null}
before={busy === 'clear' ? <Spinner size="100" variant="Critical" /> : undefined}
onClick={() => run('clear')}
>
<Text size="B300">Clear synced copy</Text>
</Button>
</Box>
);
return ( return (
<Box direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="L400">Sync</Text> <Text size="L400">Sync</Text>
@@ -1666,12 +1636,34 @@ function SettingsSyncSection() {
<SettingTile <SettingTile
title="Use This Device's Settings Everywhere" title="Use This Device's Settings Everywhere"
description="Push this device's current preferences so every other device adopts them, even ones that changed something more recently." description="Push this device's current preferences so every other device adopts them, even ones that changed something more recently."
// [Gitea #273] On phones the two buttons squeezed the text into a after={
// narrow column; put them under the description there. <Box gap="200">
after={isMobile ? undefined : syncButtons} <Button
> size="300"
{isMobile && <Box style={{ paddingTop: config.space.S100 }}>{syncButtons}</Box>} variant="Secondary"
</SettingTile> fill="Soft"
outlined
radii="300"
disabled={busy !== null}
before={busy === 'push' ? <Spinner size="100" variant="Secondary" /> : undefined}
onClick={() => run('push')}
>
<Text size="B300">Push now</Text>
</Button>
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
disabled={busy !== null}
before={busy === 'clear' ? <Spinner size="100" variant="Critical" /> : undefined}
onClick={() => run('clear')}
>
<Text size="B300">Clear synced copy</Text>
</Button>
</Box>
}
/>
{note && ( {note && (
<Text <Text
size="T200" size="T200"
@@ -1808,8 +1800,7 @@ function Calls() {
title="Noise Suppression" title="Noise Suppression"
description={ description={
<Box direction="Column" gap="200"> <Box direction="Column" gap="200">
{/* [Gitea #271] T200 like every other setting description. */} <Text>
<Text size="T200">
Filter background noise from your mic during calls. Browser-native uses the built-in Filter background noise from your mic during calls. Browser-native uses the built-in
WebRTC suppressor (Google NSNet2). ML runs a dedicated model for stronger removal. WebRTC suppressor (Google NSNet2). ML runs a dedicated model for stronger removal.
</Text> </Text>
@@ -2042,7 +2033,7 @@ function Calls() {
)} )}
</SequenceCard> </SequenceCard>
<SequenceCard <SequenceCard
className={`${SequenceCardStyle} ${SettingTileGroup}`} className={SequenceCardStyle}
variant="SurfaceVariant" variant="SurfaceVariant"
direction="Column" direction="Column"
gap="400" gap="400"
@@ -2139,7 +2130,7 @@ function Calls() {
)} )}
</SequenceCard> </SequenceCard>
<SequenceCard <SequenceCard
className={`${SequenceCardStyle} ${SettingTileGroup}`} className={SequenceCardStyle}
variant="SurfaceVariant" variant="SurfaceVariant"
direction="Column" direction="Column"
gap="400" gap="400"
@@ -2273,11 +2264,7 @@ function Calls() {
/> />
</SequenceCard> </SequenceCard>
<SequenceCard <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
className={`${SequenceCardStyle} ${SettingTileGroup}`}
variant="SurfaceVariant"
direction="Column"
>
<SettingTile <SettingTile
title="Soundboard" title="Soundboard"
description="Show a soundboard button in the call bar. Upload short audio clips (like custom emojis) to play them into the call. Clips sync across your devices." description="Show a soundboard button in the call bar. Upload short audio clips (like custom emojis) to play them into the call. Clips sync across your devices."
@@ -2764,11 +2751,7 @@ function Messages() {
} }
/> />
</SequenceCard> </SequenceCard>
<SequenceCard <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
className={`${SequenceCardStyle} ${SettingTileGroup}`}
variant="SurfaceVariant"
direction="Column"
>
<SettingTile <SettingTile
title="Translate Messages Into" title="Translate Messages Into"
description={ description={
+2 -14
View File
@@ -1,18 +1,6 @@
import { globalStyle, style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
import { color, config } from 'folds'; import { config } from 'folds';
export const SequenceCardStyle = style({ export const SequenceCardStyle = style({
padding: config.space.S300, padding: config.space.S300,
}); });
/**
* [Gitea #273] Several settings share one card; without a separator the next
* tile's title read like part of the previous description. A thin line goes
* between tiles only (not between a tile and its own slider or note).
*/
export const SettingTileGroup = style({});
// globalStyle: vanilla-extract's `selectors` may only target the element itself.
globalStyle(`${SettingTileGroup} > [data-setting-tile] ~ [data-setting-tile]`, {
borderTop: `1px solid color-mix(in srgb, ${color.SurfaceVariant.OnContainer} 14%, transparent)`,
paddingTop: config.space.S400,
});
+9
View File
@@ -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 -38
View File
@@ -1,11 +1,6 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import { isValidServerPattern, globToRegExp, matchesAnyGlob } from './serverAcl';
isValidServerPattern,
globToRegExp,
matchesAnyGlob,
normalizeServerAcl,
} from './serverAcl';
test('isValidServerPattern: accepts hostnames, IPs, and globs', () => { test('isValidServerPattern: accepts hostnames, IPs, and globs', () => {
for (const v of [ for (const v of [
@@ -47,35 +42,3 @@ test('matchesAnyGlob: true if any glob matches (self-ban detection)', () => {
assert.equal(matchesAnyGlob('lotusguild.org', ['*.evil.com']), false); assert.equal(matchesAnyGlob('lotusguild.org', ['*.evil.com']), false);
assert.equal(matchesAnyGlob('lotusguild.org', []), 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, []);
});
-26
View File
@@ -50,29 +50,3 @@ 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,
};
}
-72
View File
@@ -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, /&lt;b&gt;hi&lt;\/b&gt; &amp; 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" });
});
-63
View File
@@ -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> = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
};
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 };
}