Compare commits

...
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 b6f9af3f58 fix(dialogs): Enter in Poll, Schedule, Report dialogs no longer discards input (#315)
CI / Build & Quality Checks (pull_request) Successful in 3m58s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 11s
CI / Playwright smoke (e2e) (pull_request) Failing after 18m27s
Several dialogs are <Box as="form"> with the header ✕ (folds IconButton,
no type attribute, so a submit button) as the form's first button. On
implicit submission, Enter in a text field "clicked" ✕, closing the dialog
and throwing away what was typed (a poll's question and answers, a
schedule, a report reason).

The header close/cancel IconButtons now have type="button": Create Poll,
Schedule Message, Report User, Report Room, Account Data editor, State
Event editor, device verification setup, and the message delete/report
dialogs.

Verified headless: in Create Poll, typing a question and pressing Enter
used to close the dialog; now it stays open with the question kept.

Fixes #315

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 21:58:05 -04:00
jared 400ee76b63 Merge pull request 'fix(calls): no "Missed call" in voice rooms or space channels' (#363) from fix/missed-call-voice-rooms into lotus
CI / Build & Quality Checks (push) Successful in 1m44s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 13s
CI / Trigger Desktop Build (push) Successful in 3s
CI / Playwright smoke (e2e) (push) Successful in 10m58s
2026-10-16 21:06:57 -04:00
jared 47c1e435b7 Merge pull request 'perf(backgrounds): animated chat backgrounds cost ~5% CPU instead of 50 to 70% (#337)' (#361) from fix/animated-bg-compositor into lotus
CI / Build & Quality Checks (push) Successful in 1m42s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Canceled after 8s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-16 20:52:34 -04:00
Lotus CIandClaude Opus 5.5 4650796120 fix(calls): no "Missed call" in voice rooms or space channels
CI / Build & Quality Checks (pull_request) Successful in 1m39s
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 10m48s
Joining a voice channel alone sends a call notification (m.rtc.notification)
to the room, and the timeline rendered every unanswered one as "Missed call
from X", so everyone in the channel saw a missed call from someone who had
just joined it. Ringing already excluded these rooms; the missed-call line
didn't.

utils/callRing.ts (unit-tested) holds the one rule for both: only DMs and
invite-only groups outside any space ring or show "Missed call"; voice
rooms, space channels, restricted and public rooms don't.

Verified headless with the same two notifications from Bob: the Voice
Lounge chat showed 2 "Missed call" lines before, 0 after; a small
invite-only group still shows its missed call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 20:29:50 -04:00
jared d55a8ba8a5 Merge branch 'lotus' into fix/animated-bg-compositor
CI / Build & Quality Checks (pull_request) Successful in 1m52s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m11s
2026-10-16 19:59:31 -04:00
jared 4b8c3219e9 Merge pull request 'fix(security): link warning no longer bypassed by a sentence or shared hosting (#335)' (#360) from fix/link-safety-bypasses 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 9s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 12m20s
2026-10-16 19:59:19 -04:00
Lotus CIandClaude Opus 5.5 2700aad209 perf(backgrounds): animated chat backgrounds cost ~5% CPU instead of 50 to 70% (#337)
CI / Build & Quality Checks (pull_request) Successful in 1m57s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Failing after 9m56s
background-position can't run on the compositor, so every frame of the
animated chat backgrounds repainted the whole chat area: 51 to 72% main-thread
busy while idle in a room at 60 fps. The drifts are slow (3 to 22 px/s,
under a pixel per frame), so each animation now uses steps(): about 12
updates/s for fireflies, stars and aurora and about 24/s for rain and
pulse.

Measured headless (20 s idle in a room, CDP TaskDuration):
  none 0.7% → 0.9%; rain 50.7 → 5.9; stars 71.2 → 4.5;
  pulse 64.9 → 5.8; aurora 58.9 → 4.2; fireflies 71.7 → 3.8.

Aurora and pulse used ease-in-out; with steps() their motion is now
even-paced. Pause animations / reduced motion still strip the animation.

Fixes #337

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-16 19:05:52 -04:00
18 changed files with 91 additions and 28 deletions
+1 -1
View File
@@ -297,7 +297,7 @@ export function AccountDataEditor({
</Chip> </Chip>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton onClick={requestClose} variant="Surface" aria-label="Close"> <IconButton type="button" onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>
+3 -8
View File
@@ -20,7 +20,6 @@ import {
import { import {
EventTimelineSetHandlerMap, EventTimelineSetHandlerMap,
EventType, EventType,
JoinRule,
RelationType, RelationType,
Room, Room,
RoomEvent, RoomEvent,
@@ -72,7 +71,8 @@ import { useReducedMotion } from '../hooks/useReducedMotion';
import { useSetting } from '../state/hooks/settings'; import { useSetting } from '../state/hooks/settings';
import { useCallPreferences } from '../state/hooks/callPreferences'; import { useCallPreferences } from '../state/hooks/callPreferences';
import { settingsAtom } from '../state/settings'; import { settingsAtom } from '../state/settings';
import { getStateEvent, getStateEvents, getMemberName } from '../utils/room'; import { getStateEvent, getMemberName } from '../utils/room';
import { isRingableRoom } from '../utils/callRing';
import { StateEvent } from '../../types/matrix/room'; import { StateEvent } from '../../types/matrix/room';
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels'; import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators'; import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
@@ -581,12 +581,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
// Only ring in rooms where the call button is visible: DMs or invite-only rooms // Only ring in rooms where the call button is visible: DMs or invite-only rooms
// with no space parent. Persistent voice rooms (call rooms), space channels, // with no space parent. Persistent voice rooms (call rooms), space channels,
// restricted rooms, and public rooms must never trigger ringing. // restricted rooms, and public rooms must never trigger ringing.
if (room.isCallRoom()) return; if (!isRingableRoom(room, directs.has(room.roomId))) return;
const isDirect = directs.has(room.roomId);
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
const joinRule = room.getJoinRule();
const isPrivateInviteGroup = !isSpaceChild && joinRule === JoinRule.Invite;
if (!isDirect && !isPrivateInviteGroup) return;
const info: IncomingCallInfo = { const info: IncomingCallInfo = {
room, room,
@@ -315,7 +315,7 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
Setup Device Verification Setup Device Verification
</Text> </Text>
</Box> </Box>
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel"> <IconButton type="button" size="300" radii="300" onClick={onCancel} aria-label="Cancel">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>
@@ -361,7 +361,7 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
Reset Device Verification Reset Device Verification
</Text> </Text>
</Box> </Box>
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel"> <IconButton type="button" size="300" radii="300" onClick={onCancel} aria-label="Cancel">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>
@@ -275,7 +275,7 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi
</Chip> </Chip>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton onClick={requestClose} variant="Surface" aria-label="Close"> <IconButton type="button" onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>
@@ -51,7 +51,7 @@ const dark: CSSProperties = {
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%', backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%', backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
backgroundRepeat: 'no-repeat', backgroundRepeat: 'no-repeat',
animation: `${auroraFlow} 60s ease-in-out infinite`, animation: `${auroraFlow} 60s steps(180) infinite`,
}; };
const light: CSSProperties = { const light: CSSProperties = {
@@ -75,7 +75,7 @@ const light: CSSProperties = {
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%', backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%', backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
backgroundRepeat: 'no-repeat', backgroundRepeat: 'no-repeat',
animation: `${auroraFlow} 60s ease-in-out infinite`, animation: `${auroraFlow} 60s steps(180) infinite`,
}; };
export const animAurora: ChatBgVariants = { dark, light }; export const animAurora: ChatBgVariants = { dark, light };
@@ -55,7 +55,7 @@ export const animFireflies: ChatBgVariants = {
'0 0', // wash A (static) '0 0', // wash A (static)
'0 0', // wash B (static) '0 0', // wash B (static)
].join(','), ].join(','),
animation: `${firefliesDrift} 44s linear infinite`, animation: `${firefliesDrift} 44s steps(528) infinite`,
}, },
// Light: a cozy warm dim-dusk. No harsh dots on white — soft amber motes with // Light: a cozy warm dim-dusk. No harsh dots on white — soft amber motes with
@@ -93,6 +93,6 @@ export const animFireflies: ChatBgVariants = {
'0 0', // wash A (static) '0 0', // wash A (static)
'0 0', // wash B (static) '0 0', // wash B (static)
].join(','), ].join(','),
animation: `${firefliesDrift} 44s linear infinite`, animation: `${firefliesDrift} 44s steps(528) infinite`,
}, },
}; };
@@ -76,7 +76,7 @@ export const animPulse: ChatBgVariants = {
'0 0', // base wash '0 0', // base wash
'0 0', // vignette '0 0', // vignette
].join(','), ].join(','),
animation: `${gridPulse} 22s ease-in-out infinite`, animation: `${gridPulse} 22s steps(528) infinite`,
}, },
// Light: soft slate-blue grid on pale cool-white, a gentle luminance breathe. // Light: soft slate-blue grid on pale cool-white, a gentle luminance breathe.
@@ -116,6 +116,6 @@ export const animPulse: ChatBgVariants = {
'0 0', // base wash '0 0', // base wash
'0 0', // vignette '0 0', // vignette
].join(','), ].join(','),
animation: `${gridPulse} 22s ease-in-out infinite`, animation: `${gridPulse} 22s steps(528) infinite`,
}, },
}; };
@@ -97,7 +97,7 @@ export const animRain: ChatBgVariants = {
].join(','), ].join(','),
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','), backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','), backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
animation: `${rainFall} 12s linear infinite`, animation: `${rainFall} 12s steps(288) infinite`,
}, },
// Light: soft teal-grey streaks on a pale cool base — elegant, never neon. // Light: soft teal-grey streaks on a pale cool base — elegant, never neon.
@@ -118,6 +118,6 @@ export const animRain: ChatBgVariants = {
].join(','), ].join(','),
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','), backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','), backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
animation: `${rainFall} 12s linear infinite`, animation: `${rainFall} 12s steps(288) infinite`,
}, },
}; };
@@ -68,7 +68,7 @@ export const animStars: ChatBgVariants = {
'0 0', // nebula A '0 0', // nebula A
'0 0', // nebula B '0 0', // nebula B
].join(','), ].join(','),
animation: `${starDrift} 90s linear infinite`, animation: `${starDrift} 90s steps(1080) infinite`,
}, },
// Light: an airy pre-dawn sky. No literal white-on-white stars — instead very // Light: an airy pre-dawn sky. No literal white-on-white stars — instead very
@@ -112,6 +112,6 @@ export const animStars: ChatBgVariants = {
'0 0', // wash A '0 0', // wash A
'0 0', // wash B '0 0', // wash B
].join(','), ].join(','),
animation: `${starDrift} 100s linear infinite`, animation: `${starDrift} 100s steps(1200) infinite`,
}, },
}; };
+5
View File
@@ -149,6 +149,11 @@ export const getChatBg = (
} }
// For animated backgrounds, promote the element to its own compositor layer so // For animated backgrounds, promote the element to its own compositor layer so
// background-position keyframes don't trigger repaints on descendant elements. // background-position keyframes don't trigger repaints on descendant elements.
// [Gitea #337] background-position can't run on the compositor, so every
// animation frame repaints the whole chat area (45 to 70% main-thread busy
// at 60 fps). The drifts are slow (3–22 px/s), so each animation uses steps():
// ~12 updates/s for the slow ones, ~24/s for rain and pulse: visually the
// same, a fraction of the repaints.
if (style.animation) { if (style.animation) {
return { ...style, willChange: 'background-position', contain: 'paint' }; return { ...style, willChange: 'background-position', contain: 'paint' };
} }
+1 -1
View File
@@ -143,7 +143,7 @@ export function PollCreator({ roomId, threadRootId, onClose }: PollCreatorProps)
Create Poll Create Poll
</Text> </Text>
</Box> </Box>
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close"> <IconButton type="button" size="300" radii="300" onClick={onClose} aria-label="Close">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>
+1 -1
View File
@@ -118,7 +118,7 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
Report Room Report Room
</Text> </Text>
</Box> </Box>
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close"> <IconButton type="button" size="300" onClick={onClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>
+1 -1
View File
@@ -124,7 +124,7 @@ export function ReportUserModal({ userId, onClose }: ReportUserModalProps) {
Report User Report User
</Text> </Text>
</Box> </Box>
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close"> <IconButton type="button" size="300" onClick={onClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>
+4
View File
@@ -138,6 +138,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
import { useImagePackRooms } from '../../hooks/useImagePackRooms'; import { useImagePackRooms } from '../../hooks/useImagePackRooms';
import { useIsDirectRoom } from '../../hooks/useRoom'; import { useIsDirectRoom } from '../../hooks/useRoom';
import { isRingableRoom } from '../../utils/callRing';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../hooks/useSpace'; import { useSpaceOptionally } from '../../hooks/useSpace';
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
@@ -2030,6 +2031,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
[EventType.RTCNotification]: (mEventId, mEvent, item) => { [EventType.RTCNotification]: (mEventId, mEvent, item) => {
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
if (!senderId || senderId === mx.getUserId()) return null; if (!senderId || senderId === mx.getUserId()) return null;
// Same rule as ringing: no "Missed call" in voice rooms or space
// channels, where starting a call is just joining the channel.
if (!isRingableRoom(room, direct)) return null;
const content = mEvent.getContent(); const content = mEvent.getContent();
const mention = const mention =
content['m.mentions']?.room || content['m.mentions']?.room ||
@@ -202,7 +202,7 @@ export function ScheduleMessageModal({
{title} {title}
</Text> </Text>
</Box> </Box>
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close"> <IconButton type="button" size="300" radii="300" onClick={onClose} aria-label="Close">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>
+14 -2
View File
@@ -710,7 +710,13 @@ export const MessageDeleteItem = as<
Delete Message Delete Message
</Text> </Text>
</Box> </Box>
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close"> <IconButton
type="button"
size="300"
onClick={handleClose}
radii="300"
aria-label="Close"
>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>
@@ -850,7 +856,13 @@ export const MessageReportItem = as<
Report Message Report Message
</Text> </Text>
</Box> </Box>
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close"> <IconButton
type="button"
size="300"
onClick={handleClose}
radii="300"
aria-label="Close"
>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>
+29
View File
@@ -0,0 +1,29 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { Room } from 'matrix-js-sdk';
import { isRingableRoom } from './callRing';
const fakeRoom = (opts: { callRoom?: boolean; joinRule?: string; spaceParent?: boolean }): Room =>
({
isCallRoom: () => !!opts.callRoom,
getJoinRule: () => opts.joinRule ?? 'invite',
getLiveTimeline: () => ({
getState: () => ({ getStateEvents: () => (opts.spaceParent ? [{}] : []) }),
}),
}) as unknown as Room;
test('voice rooms never ring or show "missed call", even as DMs', () => {
assert.equal(isRingableRoom(fakeRoom({ callRoom: true }), false), false);
assert.equal(isRingableRoom(fakeRoom({ callRoom: true }), true), false);
});
test('DMs and invite-only groups outside a space ring', () => {
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'public' }), true), true);
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'invite' }), false), true);
});
test('space channels, public and restricted rooms do not ring', () => {
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'invite', spaceParent: true }), false), false);
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'public' }), false), false);
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'restricted' }), false), false);
});
+18
View File
@@ -0,0 +1,18 @@
import { JoinRule, Room } from 'matrix-js-sdk';
import { StateEvent } from '../../types/matrix/room';
import { getStateEvents } from './room';
/**
* Whether a call notification in this room may ring — and, by the same rule,
* whether an unanswered one may show as "Missed call" (Gitea #162 follow-up).
* Only DMs and invite-only groups outside any space ring, i.e. rooms where the
* call button is shown. Persistent voice rooms, space channels, restricted and
* public rooms never do: joining a voice channel alone is normal and isn't a
* "call" anyone missed.
*/
export const isRingableRoom = (room: Room, isDirect: boolean): boolean => {
if (room.isCallRoom()) return false;
if (isDirect) return true;
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
return !isSpaceChild && room.getJoinRule() === JoinRule.Invite;
};