Compare commits

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

Fixes #263

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 14:14:26 -04:00
11 changed files with 59 additions and 104 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',
+7 -12
View File
@@ -94,15 +94,10 @@ export function OutboxFeature() {
OFFLINE.has(mx.getSyncState()) ||
(typeof navigator !== 'undefined' && navigator.onLine === false);
// [Gitea #268] Read-modify-write: another tab may have queued or cleared
// entries since we last looked, and saving our own copy over the key
// would drop theirs. Entries are added/removed by txnId, so applying our
// change to the stored list merges cleanly.
const persist = (update: (list: OutboxEntry[]) => OutboxEntry[]) => {
const stored = loadOutbox(userId);
const next = update(stored);
const persist = (next: OutboxEntry[]) => {
if (next === entries) return;
entries = next;
if (next !== stored) saveOutbox(userId, next);
saveOutbox(userId, entries);
};
/**
@@ -128,8 +123,8 @@ export function OutboxFeature() {
if (!shouldKeepInOutbox(event.getType(), event.getContent())) return;
live.set(txnId, { event, room });
if (event.status === EventStatus.NOT_SENT) scheduleBlipRetry(event);
persist((list) =>
withEntry(list, {
persist(
withEntry(entries, {
txnId,
roomId: room.roomId,
threadId: event.threadRootId ?? null,
@@ -143,7 +138,7 @@ export function OutboxFeature() {
// SENT, CANCELLED, or the remote echo replaced it (status null).
live.delete(txnId);
autoRetries.delete(txnId);
persist((list) => withoutEntry(list, txnId));
persist(withoutEntry(entries, txnId));
};
/** Re-send network failures, oldest first; a room stops at its first failure. */
@@ -196,7 +191,7 @@ export function OutboxFeature() {
return !!room && isDelivered(room, entry.txnId);
},
);
plan.drop.forEach((entry) => persist((list) => withoutEntry(list, entry.txnId)));
plan.drop.forEach((entry) => persist(withoutEntry(entries, entry.txnId)));
plan.restore.forEach((entry) => {
const room = mx.getRoom(entry.roomId);
if (!room || live.has(entry.txnId)) return;
@@ -32,12 +32,24 @@ 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, normalizeServerAcl } from '../../utils/serverAcl';
import { isValidServerPattern, matchesAnyGlob } 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 = {
@@ -173,20 +185,20 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
// Read current ACL from room state
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
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,
() => currentAcl.allow_ip_literals ?? false,
);
// 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;
JSON.stringify(allowList) !== JSON.stringify(currentAcl.allow ?? ['*']) ||
JSON.stringify(denyList) !== JSON.stringify(currentAcl.deny ?? []) ||
allowIpLiterals !== (currentAcl.allow_ip_literals ?? false);
// Save handler
const [saveState, save] = useAsyncCallback(
@@ -324,10 +336,7 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
id="allow-ip-literals"
checked={allowIpLiterals}
disabled={!canEdit}
// folds' Checkbox has no onChange; readOnly tells React the
// click handler controls it (no "checked without onChange").
readOnly
onClick={(e) => setAllowIpLiterals(e.currentTarget.checked)}
onClick={() => setAllowIpLiterals(!allowIpLiterals)}
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',
@@ -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);
+1 -38
View File
@@ -1,11 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
isValidServerPattern,
globToRegExp,
matchesAnyGlob,
normalizeServerAcl,
} from './serverAcl';
import { isValidServerPattern, globToRegExp, matchesAnyGlob } from './serverAcl';
test('isValidServerPattern: accepts hostnames, IPs, and globs', () => {
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', []), false);
});
test('no ACL event: every server allowed, IP literals allowed (#261)', () => {
assert.deepEqual(normalizeServerAcl(undefined), {
allow: ['*'],
deny: [],
allow_ip_literals: true,
});
});
test('a missing allow_ip_literals defaults to true, per the spec (#261)', () => {
assert.equal(normalizeServerAcl({ allow: ['*'], deny: ['bad.example'] }).allow_ip_literals, true);
});
test('an explicit allow_ip_literals is kept', () => {
assert.equal(
normalizeServerAcl({ allow: ['*'], allow_ip_literals: false }).allow_ip_literals,
false,
);
assert.equal(
normalizeServerAcl({ allow: ['*'], allow_ip_literals: true }).allow_ip_literals,
true,
);
});
test('missing or malformed lists become empty, non-strings are dropped', () => {
assert.deepEqual(normalizeServerAcl({ allow: ['a.example', 3, null] }), {
allow: ['a.example'],
deny: [],
allow_ip_literals: true,
});
assert.deepEqual(normalizeServerAcl({ allow: 'a.example', deny: {} }).allow, []);
});
-26
View File
@@ -50,29 +50,3 @@ export function matchesAnyGlob(domain: string, globs: string[]): boolean {
}
});
}
/**
* `m.room.server_acl` content with the spec's defaults filled in (#261).
* `allow_ip_literals` defaults to **true** when absent; `allow` and `deny`
* default to empty lists, but with no ACL event at all every server is allowed,
* so the editor starts from `allow: ['*']` in that case.
* https://spec.matrix.org/v1.12/client-server-api/#mroomserver_acl
*/
export type ServerAclContent = {
allow: string[];
deny: string[];
allow_ip_literals: boolean;
};
const stringList = (value: unknown): string[] | undefined =>
Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : undefined;
export function normalizeServerAcl(content: Record<string, unknown> | undefined): ServerAclContent {
if (!content) return { allow: ['*'], deny: [], allow_ip_literals: true };
return {
allow: stringList(content.allow) ?? [],
deny: stringList(content.deny) ?? [],
allow_ip_literals:
typeof content.allow_ip_literals === 'boolean' ? content.allow_ip_literals : true,
};
}