Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed494d2ae5 |
@@ -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, '&')
|
||||||
|
.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;
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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={
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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,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, []);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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