Compare commits

...
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 d223f847c6 fix(schedule): limit to 24 hours ahead with a clear message (#275)
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 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m4s
Production Synapse caps delayed events at 24h, but the dialog let any
later time be picked and then showed the raw server error. The date
input now has min/max (today .. now+24h), a too-late time shows "You can
schedule up to 24 hours ahead." as soon as it is picked (and blocks
Schedule), and ORG.MATRIX.MSC4140_DELAY_TOO_LARGE maps to the same text.

Fixes #275

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 15:20:08 -04:00
jared e1168f47bb Merge pull request 'fix(room): one width for every right-hand panel (#263)' (#428) from fix/side-panel-width into lotus
CI / Build & Quality Checks (push) Successful in 1m48s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-19 14:54:18 -04:00
jared f5a184e428 Merge pull request 'fix(topic): keep URLs, aliases and user IDs intact in room topics (#262)' (#427) from fix/topic-markdown into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-19 14:41:04 -04:00
jared a5471a0576 Merge pull request 'fix(acl): a missing allow_ip_literals means allowed (#261)' (#426) from fix/acl-ip-literals-default into lotus
CI / Build & Quality Checks (push) Successful in 1m49s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-19 14:21:17 -04:00
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
Lotus CIandClaude Opus 5.5 adf94d7bc1 fix(acl): a missing allow_ip_literals means allowed (#261)
CI / Build & Quality Checks (pull_request) Successful in 1m54s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m45s
The Server ACL editor treated an absent allow_ip_literals as false, so the
first save in a room with no ACL (or one omitting the field) silently banned
IP-literal servers. The spec default is true. normalizeServerAcl() fills the
spec defaults once and feeds the initial state, the dirty check and the save.
It also stops showing an existing ACL with no allow list as allow-all: per
spec that allows no servers, and the /acl command already keeps it as-is.

The checkbox is driven by onClick (folds has no onChange), now readOnly so
React stops warning about a checked input without onChange.

Fixes #261

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 14:07:03 -04:00
13 changed files with 156 additions and 42 deletions
@@ -1,10 +1,11 @@
import { style } from '@vanilla-extract/css';
import { config, toRem } from 'folds';
import { config } from 'folds';
import { SIDE_PANEL_WIDTH } from '../../styles/sidePanel';
// Mirrors MembersDrawer: a 266px side panel on desktop that becomes a
// Mirrors MembersDrawer: a side panel (shared width) on desktop that becomes a
// full-screen fixed panel on narrow viewports — the app's canonical drawer.
export const BookmarksPanel = style({
width: toRem(266),
width: SIDE_PANEL_WIDTH,
'@media': {
'(max-width: 750px)': {
position: 'fixed',
@@ -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"
/>
+5 -5
View File
@@ -1,13 +1,13 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
import { SIDE_PANEL_WIDTH } from '../../styles/sidePanel';
// Right-side drawer DOCKED into the room layout row (a flex sibling of the
// timeline), exactly like MembersDrawer — not a floating overlay. 320px is a
// little wider than the 266px member/bookmark drawers because it hosts a media
// grid. On narrow viewports it becomes a full-screen fixed panel, matching the
// app's other drawers.
// timeline), exactly like MembersDrawer — not a floating overlay. Same width as
// every other right-hand panel (styles/sidePanel.ts, #263). On narrow viewports
// it becomes a full-screen fixed panel, matching the app's other drawers.
export const MediaGalleryDrawer = style({
width: toRem(320),
width: SIDE_PANEL_WIDTH,
overflow: 'hidden',
'@media': {
'(max-width: 750px)': {
+3 -2
View File
@@ -1,8 +1,9 @@
import { keyframes, style } from '@vanilla-extract/css';
import { config, toRem } from 'folds';
import { config } from 'folds';
import { SIDE_PANEL_WIDTH } from '../../styles/sidePanel';
export const MembersDrawer = style({
width: toRem(266),
width: SIDE_PANEL_WIDTH,
'@media': {
'(max-width: 750px)': {
position: 'fixed',
+22 -4
View File
@@ -20,7 +20,12 @@ import { IContent } from 'matrix-js-sdk';
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
import { useMatrixClient } from '../../hooks/useMatrixClient';
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 {
toLocalDate,
@@ -121,7 +126,13 @@ export function ScheduleMessageModal({
return;
}
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);
return;
}
@@ -149,6 +160,10 @@ export function ScheduleMessageModal({
setError('Scheduled time must be at least 1 minute in the future.');
return;
}
if (diffMs > MAX_SCHEDULE_AHEAD_MS) {
setError(SCHEDULE_TOO_FAR_TEXT);
return;
}
if (!messageText.trim()) {
setError('Please enter a message to schedule.');
return;
@@ -162,7 +177,7 @@ export function ScheduleMessageModal({
onScheduled(delayId, sendAt.getTime(), content);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to schedule message.');
setError(describeScheduleError(err));
setSubmitting(false);
}
};
@@ -247,6 +262,8 @@ export function ScheduleMessageModal({
<input
id="schedule-date"
type="date"
min={toLocalDate(new Date())}
max={toLocalDate(new Date(Date.now() + MAX_SCHEDULE_AHEAD_MS))}
value={dateValue}
onChange={(e) => setDateValue(e.target.value)}
style={pickerInputStyle(color, config)}
@@ -294,7 +311,8 @@ export function ScheduleMessageModal({
</Box>
</Box>
) : (
(dateValue || timeValue) && (
(dateValue || timeValue) &&
error !== SCHEDULE_TOO_FAR_TEXT && (
<Text size="T200" style={{ color: color.Critical.Main }}>
Must be at least 1 minute in the future
</Text>
@@ -1,8 +1,9 @@
import { style } from '@vanilla-extract/css';
import { config, toRem } from 'folds';
import { config } from 'folds';
import { SIDE_PANEL_WIDTH } from '../../../styles/sidePanel';
export const ThreadPanel = style({
width: toRem(360),
width: SIDE_PANEL_WIDTH,
'@media': {
'(max-width: 750px)': {
position: 'fixed',
@@ -1,8 +1,9 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
import { color, config } from 'folds';
import { SIDE_PANEL_WIDTH } from '../../../styles/sidePanel';
export const ThreadsListPanel = style({
width: toRem(340),
width: SIDE_PANEL_WIDTH,
'@media': {
'(max-width: 750px)': {
position: 'fixed',
@@ -1,8 +1,9 @@
import { style } from '@vanilla-extract/css';
import { config, toRem } from 'folds';
import { config } from 'folds';
import { SIDE_PANEL_WIDTH } from '../../../styles/sidePanel';
export const WidgetsPanel = style({
width: toRem(360),
width: SIDE_PANEL_WIDTH,
'@media': {
'(max-width: 750px)': {
position: 'fixed',
+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);
+12
View File
@@ -179,3 +179,15 @@ test('groupScheduledByRoom: groups and sorts by sendAt', () => {
);
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.');
});
+16
View File
@@ -1,6 +1,22 @@
import { IContent, MatrixClient, Method } from 'matrix-js-sdk';
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).
* Synapse implementation: pass org.matrix.msc4140.delay on the standard
+38 -1
View File
@@ -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, []);
});
+26
View File
@@ -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,
};
}