Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 53ac87befe fix(ui): receipt pill, insights counts, settings dividers, decoration preview (#269-#273)
CI / Build & Quality Checks (pull_request) Successful in 2m0s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 6s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m45s
#269 read-receipt pill sits at the message row's right edge, not where
     the text ends (outside the shrink-to-fit content column; grows in
     Compact). Bubble layout unchanged.
#270 Most Active Members: the bar's % is of its own track, so every row
     shows its count.
#271 Noise Suppression description at T200 like the rest.
#272 decoration preview shows your own avatar under the decoration; the
     empty state is a visible dashed circle.
#273 grouped settings get a divider between tiles (SettingTileGroup, via
     globalStyle; SettingTile roots carry data-setting-tile), and on phones
     the Sync tile's buttons sit under its description.

Fixes #269
Fixes #270
Fixes #271
Fixes #272
Fixes #273

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 15:06:20 -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 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
9 changed files with 220 additions and 90 deletions
@@ -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 && (
+13 -10
View File
@@ -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"
/>
+24 -18
View File
@@ -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,
}}
/>
)}
+51 -34
View File
@@ -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={
+14 -2
View File
@@ -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,
});
+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,
};
}