Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 f7bdd2855a fix(composer): Escape closes the emoji and sticker boards (#304)
CI / Build & Quality Checks (pull_request) Successful in 2m46s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 13s
CI / Playwright smoke (e2e) (pull_request) Successful in 15m19s
The board opens with focus in its search field, and its FocusTrap used
the shared stopPropagation helper, which ignores Escape while an input
is focused, so Escape never closed it (Chromium, Firefox, WebKit).
Always close on Escape here, still stopping the key from closing the
room or modal underneath. The GIF picker already uses the default.

Fixes #304

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:06:41 -04:00
19 changed files with 57 additions and 156 deletions
+4 -4
View File
@@ -81,7 +81,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
"@lotusguild/element-call-embedded": "0.25.0-lotus.22",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
@@ -2695,9 +2695,9 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@lotusguild/element-call-embedded": {
"version": "0.25.0-lotus.23",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.23/element-call-embedded-0.25.0-lotus.23.tgz",
"integrity": "sha512-vRmcWeJcU0Accyl2x3mqNEzrIe8djE5BekyYbYOSCsMflC98rZgD1mO7KOe7bA1cX7gparRf6sAt1fotHoU+bA==",
"version": "0.25.0-lotus.22",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.22/element-call-embedded-0.25.0-lotus.22.tgz",
"integrity": "sha512-AimDKZA4xUKEEyl2XUFUSUDl03tBKFv88T/3igKyH2VqNSPMGnqcQJC5HXIkHLTWrOmg8kLcJ//FFbqlUZiLcA==",
"dev": true
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
+1 -1
View File
@@ -108,7 +108,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
"@lotusguild/element-call-embedded": "0.25.0-lotus.22",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
+1 -1
View File
@@ -297,7 +297,7 @@ export function AccountDataEditor({
</Chip>
</Box>
<Box shrink="No">
<IconButton type="button" onClick={requestClose} variant="Surface" aria-label="Close">
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
-2
View File
@@ -86,7 +86,6 @@ import { CallAvatarAnimation } from '../styles/Animations.css';
import { webRTCSupported } from '../utils/rtc';
import { CallsUnavailableMessage } from '../features/call/CallsUnavailable';
import { zIndices } from '../styles/zIndex';
import { useDenoiseNotices } from '../hooks/useDenoiseNotices';
const PIP_MIN_W = 200;
const PIP_MIN_H = 112;
@@ -794,7 +793,6 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
useCallPolicyRevokedToast(embed, joined);
useCallEndedToast(embed);
useScreenshareNotices(embed);
useDenoiseNotices(embed);
// [Gitea #43] The in-frame screenshare button follows the same room policy
// as the host bar's (hidden where the server would refuse the share).
const { allowScreenshare } = useRoomCallPolicy(embed.room);
@@ -315,7 +315,7 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
Setup Device Verification
</Text>
</Box>
<IconButton type="button" size="300" radii="300" onClick={onCancel} aria-label="Cancel">
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@@ -361,7 +361,7 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
Reset Device Verification
</Text>
</Box>
<IconButton type="button" size="300" radii="300" onClick={onCancel} aria-label="Cancel">
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
+2 -5
View File
@@ -139,11 +139,8 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
data.allowFederation,
data.additionalCreators,
),
// [Gitea #328] Every non-space room gets call.member at user level, not just
// voice rooms: the call button is shown in private group rooms too, and with
// Synapse's state_default (50) only admins could start or join a call there
// ("You don't have permission to join!").
power_level_content_override: createVoiceRoomPowerLevelsOverride(),
power_level_content_override:
data.type === RoomType.Call ? createVoiceRoomPowerLevelsOverride() : undefined,
initial_state: initialState,
};
@@ -19,7 +19,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji';
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
import { useEmojiGroupIcons } from './useEmojiGroupIcons';
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
import { preventScrollWithArrowKey } from '../../utils/keyboard';
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
@@ -612,7 +612,13 @@ export function EmojiBoard({
!editableActiveElement() && isKeyHotkey(['arrowdown', 'arrowright'], evt),
isKeyBackward: (evt: KeyboardEvent) =>
!editableActiveElement() && isKeyHotkey(['arrowup', 'arrowleft'], evt),
escapeDeactivates: stopPropagation,
// [Gitea #304] The board opens with focus in its search field, where
// the shared stopPropagation helper ignores Escape. Always close here,
// without letting the key also close the room/modal underneath.
escapeDeactivates: (evt: KeyboardEvent) => {
evt.stopPropagation();
return true;
},
}}
>
<EmojiBoardLayout
@@ -275,7 +275,7 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi
</Chip>
</Box>
<Box shrink="No">
<IconButton type="button" onClick={requestClose} variant="Surface" aria-label="Close">
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
+1 -1
View File
@@ -143,7 +143,7 @@ export function PollCreator({ roomId, threadRootId, onClose }: PollCreatorProps)
Create Poll
</Text>
</Box>
<IconButton type="button" size="300" radii="300" onClick={onClose} aria-label="Close">
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
+1 -1
View File
@@ -118,7 +118,7 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
Report Room
</Text>
</Box>
<IconButton type="button" size="300" onClick={onClose} radii="300" aria-label="Close">
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
+1 -1
View File
@@ -124,7 +124,7 @@ export function ReportUserModal({ userId, onClose }: ReportUserModalProps) {
Report User
</Text>
</Box>
<IconButton type="button" size="300" onClick={onClose} radii="300" aria-label="Close">
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@@ -202,7 +202,7 @@ export function ScheduleMessageModal({
{title}
</Text>
</Box>
<IconButton type="button" size="300" radii="300" onClick={onClose} aria-label="Close">
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
+2 -14
View File
@@ -725,13 +725,7 @@ export const MessageDeleteItem = as<
Delete Message
</Text>
</Box>
<IconButton
type="button"
size="300"
onClick={handleClose}
radii="300"
aria-label="Close"
>
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
@@ -871,13 +865,7 @@ export const MessageReportItem = as<
Report Message
</Text>
</Box>
<IconButton
type="button"
size="300"
onClick={handleClose}
radii="300"
aria-label="Close"
>
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
+4 -24
View File
@@ -69,15 +69,8 @@ export const Reactions = as<'div', ReactionsProps>(
const measure = useCallback(() => {
const container = containerRef.current;
if (!container) return;
// [Gitea #305] The width the chips actually get (content box). The border
// box grows by 4 px while collapsed (margin -2 / padding 2), and measuring
// it made a row within 4 px of fitting flip collapsed↔expanded every frame.
const cs = getComputedStyle(container);
const rowWidth =
container.clientWidth -
(parseFloat(cs.paddingLeft) || 0) -
(parseFloat(cs.paddingRight) || 0);
const gap = parseFloat(cs.columnGap) || 8;
const rowWidth = container.getBoundingClientRect().width;
const gap = parseFloat(getComputedStyle(container).columnGap) || 8;
const chips = keys ? keys.split('\u0000').map((k) => chipRefs.current.get(k)) : [];
const widths = chips.map((el) => el?.getBoundingClientRect().width ?? 0);
const moreWidth = moreRef.current?.getBoundingClientRect().width ?? 48;
@@ -117,21 +110,8 @@ export const Reactions = as<'div', ReactionsProps>(
style={{
...props.style,
...(collapsed && rowHeight
? // 2px breathing room so row-1 focus outlines are not clipped. Longhand
// margins so the timeline's marginTop survives (Gitea #305: the
// `margin` shorthand overrode it and, once removed, cleared it).
{
maxHeight: rowHeight + 4,
overflow: 'hidden',
padding: 2,
marginLeft: -2,
marginRight: -2,
marginBottom: -2,
marginTop:
props.style?.marginTop !== undefined
? `calc(${typeof props.style.marginTop === 'number' ? `${props.style.marginTop}px` : props.style.marginTop} - 2px)`
: -2,
}
? // 2px breathing room so row-1 focus outlines are not clipped.
{ maxHeight: rowHeight + 4, overflow: 'hidden', padding: 2, margin: -2 }
: {}),
}}
ref={(el) => {
-38
View File
@@ -1,38 +0,0 @@
import { useEffect, useRef } from 'react';
import { useSetAtom } from 'jotai';
import { Icons } from 'folds';
import { CallEmbed } from '../plugins/call';
import { toastQueueAtom } from '../state/toast';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { denoiseNotice, DenoiseStateReport } from '../utils/denoiseNotice';
/**
* [Gitea #260] Tell the user when ML noise suppression didn't attach (raw mic)
* or fell back to another model. The fork's `io.lotus.denoise_state` used to
* go unanswered ("unsupported action"), so failures were silent.
*/
export function useDenoiseNotices(embed: CallEmbed): void {
const setToast = useSetAtom(toastQueueAtom);
const [mode] = useSetting(settingsAtom, 'callNoiseSuppression');
const [model] = useSetting(settingsAtom, 'callDenoiseModel');
const latest = useRef({ mode, model });
latest.current = { mode, model };
useEffect(
() =>
embed.listenAction<{ data?: DenoiseStateReport }>('io.lotus.denoise_state', (evt) => {
if (latest.current.mode !== 'ml') return;
const body = denoiseNotice(latest.current.model, evt.detail?.data);
if (!body) return;
setToast({
id: `denoise-state-${Date.now()}`,
iconSrc: Icons.Mic,
displayName: 'Noise suppression',
body,
roomName: embed.room.name ?? 'Voice call',
roomId: embed.roomId,
});
}),
[embed, setToast],
);
}
@@ -1060,6 +1060,32 @@ function TauriUpdateFeature() {
return null;
}
function LotusDenoiseFeature() {
const setToast = useSetAtom(toastQueueAtom);
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === 'lotus-denoise-status') {
const { active, error } = event.data;
if (!active) {
setToast({
id: `denoise-fail-${Date.now()}`,
displayName: 'Audio Quality',
body: `ML Noise Suppression failed: ${error || 'Unknown error'}. Falling back to raw mic.`,
roomName: 'System',
roomId: '',
});
}
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [setToast]);
return null;
}
// Registers the global `?` shortcut (ignored while typing) and renders the
// keyboard-shortcuts help dialog. Headless — the dialog self-gates on its atom.
function KeyboardShortcutsFeature() {
@@ -1141,6 +1167,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<CloseBehaviorPrompt />
<ConsentRequiredPrompt />
<OutboxFeature />
<LotusDenoiseFeature />
<DeepLinkNavigator />
<KeyboardShortcutsFeature />
{children}
+1 -12
View File
@@ -31,7 +31,6 @@ import { verifyDenoiseAssets } from './denoiseSmokeCheck';
import { canDelegateCapability } from './utils';
import { getCallPageUrl } from './callPageUrl';
import { restrictWidgetMessages } from '../widgetTransport';
import { isRingableRoom } from '../../utils/callRing';
// Maximum time to wait for the embedded Element Call iframe to progress from
// initial load to a ready/joined state. If it hasn't by then, we assume the
@@ -252,18 +251,8 @@ export class CallEmbed {
verifyDenoiseAssets(denoiseModel).catch(() => undefined);
}
// [Gitea #295] Only ask Element Call to notify where a call can ring at all
// (DMs, invite-only groups outside a space). In a voice channel or space
// room the notification carries an @room mention, so every member got a
// highlighted ping (and phone push) whenever a moderator hopped in, and
// other clients showed it as a missed call.
// `none` needs element-call-embedded ≥ 0.25.0-lotus.23 (older builds ignore it).
if (CallEmbed.startingCall(intent)) {
let notify = 'none';
if (isRingableRoom(room, CallEmbed.dmCall(intent))) {
notify = CallEmbed.dmCall(intent) ? 'ring' : 'notification';
}
params.append('sendNotificationType', notify);
params.append('sendNotificationType', CallEmbed.dmCall(intent) ? 'ring' : 'notification');
}
// [Gitea #43] On its own origin when config.json sets elementCallUrl.
-17
View File
@@ -1,17 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { denoiseNotice } from './denoiseNotice';
test('denoiseNotice: says when ML is off or fell back (#260)', () => {
assert.equal(denoiseNotice('deepfilternet', { active: true, model: 'deepfilternet' }), undefined);
assert.match(
denoiseNotice('deepfilternet', { active: true, model: 'rnnoise' }) ?? '',
/^DeepFilterNet 3 couldn't start, so noise suppression is using RNNoise instead\.$/,
);
assert.match(
denoiseNotice('rnnoise', { active: false, error: 'boom' }) ?? '',
/unfiltered audio/,
);
assert.equal(denoiseNotice('rnnoise', undefined), undefined);
assert.equal(denoiseNotice('rnnoise', { model: 'x' }), undefined);
});
-29
View File
@@ -1,29 +0,0 @@
/**
* [Gitea #260] The fork reports what noise suppression actually runs as
* `io.lotus.denoise_state { active, model, error? }`. Pure: the toast text for
* a report, or undefined when what runs is what the user picked.
*/
export type DenoiseStateReport = { active?: unknown; model?: unknown; error?: unknown };
const MODEL_NAMES: Record<string, string> = {
rnnoise: 'RNNoise',
speex: 'Speex',
dtln: 'DTLN',
deepfilternet: 'DeepFilterNet 3',
};
const modelName = (m: unknown): string =>
typeof m === 'string' ? (MODEL_NAMES[m] ?? m) : 'the selected model';
export function denoiseNotice(
requestedModel: string,
report: DenoiseStateReport | undefined,
): string | undefined {
if (!report || typeof report.active !== 'boolean') return undefined;
if (!report.active) {
return `ML noise suppression couldn't start, so your microphone is sending unfiltered audio. You can pick another model in Settings → Calls.`;
}
if (typeof report.model === 'string' && report.model !== requestedModel) {
return `${modelName(requestedModel)} couldn't start, so noise suppression is using ${modelName(report.model)} instead.`;
}
return undefined;
}