Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53ac87befe | ||
|
|
f5a184e428 | ||
|
|
a5471a0576 | ||
|
|
adf94d7bc1 |
@@ -34,7 +34,8 @@ export function SettingTile({ title, description, before, after, children }: Set
|
||||
});
|
||||
|
||||
return (
|
||||
<Box alignItems="Center" gap="300">
|
||||
// data-setting-tile: lets a group card draw dividers between tiles only (#273).
|
||||
<Box alignItems="Center" gap="300" data-setting-tile="">
|
||||
{before && <Box shrink="No">{before}</Box>}
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
{title && (
|
||||
|
||||
@@ -401,16 +401,19 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
|
||||
{displayName}
|
||||
</Text>
|
||||
<Box alignItems="Center" gap="200">
|
||||
<div
|
||||
style={{
|
||||
height: 6,
|
||||
width: barWidth,
|
||||
background: color.Primary.Main,
|
||||
borderRadius: config.radii.R300,
|
||||
transition: 'width 0.3s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{/* [Gitea #270] The bar's % width is of its own track,
|
||||
not of the row, so the count always fits beside it. */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
height: 6,
|
||||
width: barWidth,
|
||||
background: color.Primary.Main,
|
||||
borderRadius: config.radii.R300,
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Text size="T200" priority="300" style={{ flexShrink: 0 }}>
|
||||
{count}
|
||||
</Text>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -1137,25 +1137,31 @@ export const Message = React.memo(
|
||||
</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 = (
|
||||
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
|
||||
{reply}
|
||||
{edit && onEditId ? (
|
||||
<MessageEditor
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
width: '100vw',
|
||||
}}
|
||||
roomId={room.roomId}
|
||||
room={room}
|
||||
mEvent={mEvent}
|
||||
imagePackRooms={imagePackRooms}
|
||||
onCancel={() => onEditId()}
|
||||
/>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
{reactions}
|
||||
<Box direction="Column" style={{ maxWidth: '100%', flexGrow: 1, minWidth: 0 }}>
|
||||
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
|
||||
{reply}
|
||||
{edit && onEditId ? (
|
||||
<MessageEditor
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
width: '100vw',
|
||||
}}
|
||||
roomId={room.roomId}
|
||||
room={room}
|
||||
mEvent={mEvent}
|
||||
imagePackRooms={imagePackRooms}
|
||||
onCancel={() => onEditId()}
|
||||
/>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
{reactions}
|
||||
</Box>
|
||||
{readReceiptUsers.length > 0 && (
|
||||
<ReadReceiptAvatars
|
||||
room={room}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import React, { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Box, Button, Chip, Icon, Icons, Input, Text, Spinner, color } from 'folds';
|
||||
import { Avatar, Box, Button, Chip, Icon, Icons, Input, Text, Spinner, color } from 'folds';
|
||||
import { Method } from 'matrix-js-sdk';
|
||||
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 { SettingTile } from '../../../components/setting-tile';
|
||||
import {
|
||||
@@ -14,6 +19,8 @@ import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
|
||||
|
||||
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
||||
const CELL_SIZE = 72;
|
||||
// Matches AvatarDecoration's default inset, so the preview looks like the timeline.
|
||||
const PREVIEW_INSET = 8;
|
||||
|
||||
function DecorationPreviewCell({
|
||||
slug,
|
||||
@@ -86,6 +93,12 @@ function categoryOf(slug: string | null): string {
|
||||
export function ProfileDecoration() {
|
||||
const mx = useMatrixClient();
|
||||
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 [selected, setSelected] = useState<string | null>(null);
|
||||
@@ -198,6 +211,10 @@ export function ProfileDecoration() {
|
||||
<Box direction="Column" gap="300">
|
||||
{/* Current selection preview */}
|
||||
<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
|
||||
style={{
|
||||
position: 'relative',
|
||||
@@ -205,10 +222,29 @@ export function ProfileDecoration() {
|
||||
height: CELL_SIZE,
|
||||
flexShrink: 0,
|
||||
borderRadius: '50%',
|
||||
background: color.SurfaceVariant.Container,
|
||||
overflow: 'hidden',
|
||||
boxSizing: 'border-box',
|
||||
border: selected
|
||||
? '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 && (
|
||||
<img
|
||||
src={decorationUrl(selected)}
|
||||
@@ -220,6 +256,7 @@ export function ProfileDecoration() {
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -111,7 +111,8 @@ import {
|
||||
translationUnavailableText,
|
||||
} from '../../../utils/translation/langUtils';
|
||||
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SequenceCardStyle, SettingTileGroup } from '../styles.css';
|
||||
import { ScreenSize, useScreenSize } from '../../../hooks/useScreenSize';
|
||||
import { UpdateProgress, useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
||||
import {
|
||||
describeUpdateError,
|
||||
@@ -1598,6 +1599,7 @@ function Privacy() {
|
||||
// [Gitea #104] Sync toggle + the two manual actions. The toggle is itself
|
||||
// device-local (never synced) so switching it off here sticks.
|
||||
function SettingsSyncSection() {
|
||||
const isMobile = useScreenSize() === ScreenSize.Mobile;
|
||||
const [settingsSync, setSettingsSync] = useSetting(settingsAtom, 'settingsSync');
|
||||
const { pushNow, clearRemote } = useSettingsSyncActions();
|
||||
const [busy, setBusy] = useState<'push' | 'clear' | null>(null);
|
||||
@@ -1621,6 +1623,34 @@ 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 (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Sync</Text>
|
||||
@@ -1636,34 +1666,12 @@ function SettingsSyncSection() {
|
||||
<SettingTile
|
||||
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."
|
||||
after={
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
// [Gitea #273] On phones the two buttons squeezed the text into a
|
||||
// narrow column; put them under the description there.
|
||||
after={isMobile ? undefined : syncButtons}
|
||||
>
|
||||
{isMobile && <Box style={{ paddingTop: config.space.S100 }}>{syncButtons}</Box>}
|
||||
</SettingTile>
|
||||
{note && (
|
||||
<Text
|
||||
size="T200"
|
||||
@@ -1800,7 +1808,8 @@ function Calls() {
|
||||
title="Noise Suppression"
|
||||
description={
|
||||
<Box direction="Column" gap="200">
|
||||
<Text>
|
||||
{/* [Gitea #271] T200 like every other setting description. */}
|
||||
<Text size="T200">
|
||||
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.
|
||||
</Text>
|
||||
@@ -2033,7 +2042,7 @@ function Calls() {
|
||||
)}
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
className={`${SequenceCardStyle} ${SettingTileGroup}`}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
@@ -2130,7 +2139,7 @@ function Calls() {
|
||||
)}
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
className={`${SequenceCardStyle} ${SettingTileGroup}`}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
@@ -2264,7 +2273,11 @@ function Calls() {
|
||||
/>
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SequenceCard
|
||||
className={`${SequenceCardStyle} ${SettingTileGroup}`}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
>
|
||||
<SettingTile
|
||||
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."
|
||||
@@ -2751,7 +2764,11 @@ function Messages() {
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SequenceCard
|
||||
className={`${SequenceCardStyle} ${SettingTileGroup}`}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
>
|
||||
<SettingTile
|
||||
title="Translate Messages Into"
|
||||
description={
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { config } from 'folds';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
import { color, config } from 'folds';
|
||||
|
||||
export const SequenceCardStyle = style({
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user