Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d223f847c6 | ||
|
|
e1168f47bb | ||
|
|
f5a184e428 | ||
|
|
a5471a0576 | ||
|
|
ed494d2ae5 | ||
|
|
adf94d7bc1 |
@@ -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',
|
||||||
|
|||||||
@@ -32,24 +32,12 @@ 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 } from '../../utils/serverAcl';
|
import { isValidServerPattern, matchesAnyGlob, normalizeServerAcl } 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 = {
|
||||||
@@ -185,20 +173,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 = aclEvent?.getContent<ServerAclContent>() ?? DEFAULT_ACL;
|
const currentAcl = normalizeServerAcl(aclEvent?.getContent());
|
||||||
|
|
||||||
// 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 ?? false,
|
() => currentAcl.allow_ip_literals,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 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 ?? false);
|
allowIpLiterals !== currentAcl.allow_ip_literals;
|
||||||
|
|
||||||
// Save handler
|
// Save handler
|
||||||
const [saveState, save] = useAsyncCallback(
|
const [saveState, save] = useAsyncCallback(
|
||||||
@@ -336,7 +324,10 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
|||||||
id="allow-ip-literals"
|
id="allow-ip-literals"
|
||||||
checked={allowIpLiterals}
|
checked={allowIpLiterals}
|
||||||
disabled={!canEdit}
|
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"
|
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',
|
||||||
|
|||||||
@@ -20,7 +20,12 @@ import { IContent } from 'matrix-js-sdk';
|
|||||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { scheduleMessage } from '../../utils/scheduledMessages';
|
import {
|
||||||
|
describeScheduleError,
|
||||||
|
MAX_SCHEDULE_AHEAD_MS,
|
||||||
|
scheduleMessage,
|
||||||
|
SCHEDULE_TOO_FAR_TEXT,
|
||||||
|
} from '../../utils/scheduledMessages';
|
||||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
import {
|
import {
|
||||||
toLocalDate,
|
toLocalDate,
|
||||||
@@ -121,7 +126,13 @@ export function ScheduleMessageModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const diffMs = sendAt.getTime() - Date.now();
|
const diffMs = sendAt.getTime() - Date.now();
|
||||||
if (diffMs < 60_000) {
|
// [Gitea #275] Say so as soon as a too-late time is picked, not on submit.
|
||||||
|
const tooFar = diffMs > MAX_SCHEDULE_AHEAD_MS;
|
||||||
|
setError((e) => {
|
||||||
|
if (tooFar) return SCHEDULE_TOO_FAR_TEXT;
|
||||||
|
return e === SCHEDULE_TOO_FAR_TEXT ? null : e;
|
||||||
|
});
|
||||||
|
if (diffMs < 60_000 || tooFar) {
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -149,6 +160,10 @@ export function ScheduleMessageModal({
|
|||||||
setError('Scheduled time must be at least 1 minute in the future.');
|
setError('Scheduled time must be at least 1 minute in the future.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (diffMs > MAX_SCHEDULE_AHEAD_MS) {
|
||||||
|
setError(SCHEDULE_TOO_FAR_TEXT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!messageText.trim()) {
|
if (!messageText.trim()) {
|
||||||
setError('Please enter a message to schedule.');
|
setError('Please enter a message to schedule.');
|
||||||
return;
|
return;
|
||||||
@@ -162,7 +177,7 @@ export function ScheduleMessageModal({
|
|||||||
onScheduled(delayId, sendAt.getTime(), content);
|
onScheduled(delayId, sendAt.getTime(), content);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to schedule message.');
|
setError(describeScheduleError(err));
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -247,6 +262,8 @@ export function ScheduleMessageModal({
|
|||||||
<input
|
<input
|
||||||
id="schedule-date"
|
id="schedule-date"
|
||||||
type="date"
|
type="date"
|
||||||
|
min={toLocalDate(new Date())}
|
||||||
|
max={toLocalDate(new Date(Date.now() + MAX_SCHEDULE_AHEAD_MS))}
|
||||||
value={dateValue}
|
value={dateValue}
|
||||||
onChange={(e) => setDateValue(e.target.value)}
|
onChange={(e) => setDateValue(e.target.value)}
|
||||||
style={pickerInputStyle(color, config)}
|
style={pickerInputStyle(color, config)}
|
||||||
@@ -294,7 +311,8 @@ export function ScheduleMessageModal({
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
(dateValue || timeValue) && (
|
(dateValue || timeValue) &&
|
||||||
|
error !== SCHEDULE_TOO_FAR_TEXT && (
|
||||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
Must be at least 1 minute in the future
|
Must be at least 1 minute in the future
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
import { config, toRem } from 'folds';
|
import { config } from 'folds';
|
||||||
|
import { SIDE_PANEL_WIDTH } from '../../../styles/sidePanel';
|
||||||
|
|
||||||
export const ThreadPanel = style({
|
export const ThreadPanel = style({
|
||||||
width: toRem(360),
|
width: SIDE_PANEL_WIDTH,
|
||||||
'@media': {
|
'@media': {
|
||||||
'(max-width: 750px)': {
|
'(max-width: 750px)': {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
import { color, config, toRem } from 'folds';
|
import { color, config } from 'folds';
|
||||||
|
import { SIDE_PANEL_WIDTH } from '../../../styles/sidePanel';
|
||||||
|
|
||||||
export const ThreadsListPanel = style({
|
export const ThreadsListPanel = style({
|
||||||
width: toRem(340),
|
width: SIDE_PANEL_WIDTH,
|
||||||
'@media': {
|
'@media': {
|
||||||
'(max-width: 750px)': {
|
'(max-width: 750px)': {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
import { config, toRem } from 'folds';
|
import { config } from 'folds';
|
||||||
|
import { SIDE_PANEL_WIDTH } from '../../../styles/sidePanel';
|
||||||
|
|
||||||
export const WidgetsPanel = style({
|
export const WidgetsPanel = style({
|
||||||
width: toRem(360),
|
width: SIDE_PANEL_WIDTH,
|
||||||
'@media': {
|
'@media': {
|
||||||
'(max-width: 750px)': {
|
'(max-width: 750px)': {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { toRem } from 'folds';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One width for every right-hand room panel (members, bookmarks, threads, a
|
||||||
|
* thread, media gallery, widgets). Swapping one panel for another must not
|
||||||
|
* resize the room view: when the widths differed, the header icons slid
|
||||||
|
* sideways and a second click on the same spot hit a different button (#263).
|
||||||
|
*/
|
||||||
|
export const SIDE_PANEL_WIDTH = toRem(340);
|
||||||
@@ -179,3 +179,15 @@ test('groupScheduledByRoom: groups and sorts by sendAt', () => {
|
|||||||
);
|
);
|
||||||
assert.equal(g.get('!2')?.length, 1);
|
assert.equal(g.get('!2')?.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('[#275] the server limit maps to friendly text; other errors keep theirs', async () => {
|
||||||
|
const { describeScheduleError, SCHEDULE_TOO_FAR_TEXT, MAX_SCHEDULE_AHEAD_MS } =
|
||||||
|
await import('./scheduledMessages');
|
||||||
|
assert.equal(MAX_SCHEDULE_AHEAD_MS, 86_400_000);
|
||||||
|
const tooLarge = Object.assign(new Error('The requested delay (172800000ms) exceeds…'), {
|
||||||
|
errcode: 'ORG.MATRIX.MSC4140_DELAY_TOO_LARGE',
|
||||||
|
});
|
||||||
|
assert.equal(describeScheduleError(tooLarge), SCHEDULE_TOO_FAR_TEXT);
|
||||||
|
assert.equal(describeScheduleError(new Error('Boom')), 'Boom');
|
||||||
|
assert.equal(describeScheduleError('nope'), 'Failed to schedule message.');
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
import { IContent, MatrixClient, Method } from 'matrix-js-sdk';
|
import { IContent, MatrixClient, Method } from 'matrix-js-sdk';
|
||||||
import type { ScheduledMessage } from '../state/scheduledMessages';
|
import type { ScheduledMessage } from '../state/scheduledMessages';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #275] Production Synapse caps delayed events at
|
||||||
|
* `max_event_delay_duration: 24h`; a later time is rejected with
|
||||||
|
* ORG.MATRIX.MSC4140_DELAY_TOO_LARGE. The dialog enforces the same limit and
|
||||||
|
* the server's error maps to the same text, in case the limit changes.
|
||||||
|
*/
|
||||||
|
export const MAX_SCHEDULE_AHEAD_MS = 24 * 60 * 60 * 1000;
|
||||||
|
export const SCHEDULE_TOO_FAR_TEXT = 'You can schedule up to 24 hours ahead.';
|
||||||
|
|
||||||
|
/** A user-facing sentence for a failed schedule. */
|
||||||
|
export const describeScheduleError = (err: unknown): string => {
|
||||||
|
const errcode = (err as { errcode?: unknown } | null)?.errcode;
|
||||||
|
if (errcode === 'ORG.MATRIX.MSC4140_DELAY_TOO_LARGE') return SCHEDULE_TOO_FAR_TEXT;
|
||||||
|
return err instanceof Error ? err.message : 'Failed to schedule message.';
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schedule a message via MSC4140 (delayed messages).
|
* Schedule a message via MSC4140 (delayed messages).
|
||||||
* Synapse implementation: pass org.matrix.msc4140.delay on the standard
|
* Synapse implementation: pass org.matrix.msc4140.delay on the standard
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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', () => {
|
test('isValidServerPattern: accepts hostnames, IPs, and globs', () => {
|
||||||
for (const v of [
|
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', ['*.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,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