Keep the menu open when adding a background, and choose it

- The picker moved into the menu: a native file dialog takes the focus,
  which the menu read as a click elsewhere and closed behind it
- Close requests are ignored while the picker is up, and a cancelled
  pick releases that through the input's own `cancel` event, which React
  does not type
- Adding answers with the background it kept, so the footer can choose
  it straight away rather than asking the user to pick it twice

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
fkwp
2026-09-17 23:01:54 +02:00
co-authored by Claude Opus 5
parent 81fe5d355f
commit 1af968748b
3 changed files with 95 additions and 70 deletions
+41 -61
View File
@@ -5,15 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { import { type FC, type JSX, type Ref, useCallback, useMemo } from "react";
Fragment,
type FC,
type JSX,
type Ref,
useCallback,
useMemo,
useRef,
} from "react";
import classNames from "classnames"; import classNames from "classnames";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
@@ -180,29 +172,27 @@ export const CallFooter: FC<FooterProps> = ({
const selectBackgroundEffect = useBehavior(vm.selectBackgroundEffect$); const selectBackgroundEffect = useBehavior(vm.selectBackgroundEffect$);
const { added, addBackground } = useAddedBackgrounds(); const { added, addBackground } = useAddedBackgrounds();
const chooseFile = useRef<HTMLInputElement>(null); const onAddBackgroundImage = useCallback(
const onAddBackgroundImage = useCallback((): void => { (file: File): void => {
chooseFile.current?.click(); // Chosen for the user straight away: they picked this picture to use it,
}, []); // and leaving it unselected would ask them to pick it twice.
const onFileChosen = useCallback( addBackground(file)
(event: React.ChangeEvent<HTMLInputElement>): void => { .then((id) =>
const file = event.target.files?.[0]; selectBackgroundEffect?.(serializeEffect({ kind: "added", id })),
// Cleared so choosing the same file twice in a row still counts. )
event.target.value = ""; .catch((e) => {
if (!file) return; // TODO: FR-021 wants the user told what went wrong. There is no
addBackground(file).catch((e) => { // surface for that in the menu yet, and inventing one is design's
// TODO: FR-021 wants the user told what went wrong. There is no // call, so for now this is only logged.
// surface for that in the menu yet, and inventing one is design's logger.warn(
// call, so for now this is only logged. e instanceof UnusableImage
logger.warn( ? `Cannot use that file as a background: ${e.reason}`
e instanceof UnusableImage : "Could not keep that background",
? `Cannot use that file as a background: ${e.reason}` e,
: "Could not keep that background", );
e, });
);
});
}, },
[addBackground], [addBackground, selectBackgroundEffect],
); );
// The catalogue is named here rather than in the view model: the names are // The catalogue is named here rather than in the view model: the names are
@@ -282,36 +272,26 @@ export const CallFooter: FC<FooterProps> = ({
if ((videoOptions?.length ?? 0) > 0) { if ((videoOptions?.length ?? 0) > 0) {
buttons.push( buttons.push(
<Fragment key="video"> <MediaMuteAndSwitchButton
{/* The picker the add tile opens. Hidden, and driven from the tile, key="video"
because a file input cannot be styled into one. */} iconsAndLabels="video"
<input enabled={videoEnabled ?? false}
ref={chooseFile} busy={videoBusy ?? false}
type="file" onMuteClick={toggleVideo}
accept="image/*" options={videoOptions}
hidden selectedOption={selectedVideo}
onChange={onFileChosen} onSelect={selectVideoButtonOption}
/> backgroundEffects={backgroundEffects}
<MediaMuteAndSwitchButton selectedBackgroundEffect={backgroundEffect}
iconsAndLabels="video" onSelectBackgroundEffect={selectBackgroundEffect}
enabled={videoEnabled ?? false} // Withheld once the device keeps as many as it will, which is what
busy={videoBusy ?? false} // renders the add tile unavailable rather than letting it fail.
onMuteClick={toggleVideo} onAddBackgroundImage={
options={videoOptions} selectBackgroundEffect && added.length < maxAddedBackgrounds
selectedOption={selectedVideo} ? onAddBackgroundImage
onSelect={selectVideoButtonOption} : undefined
backgroundEffects={backgroundEffects} }
selectedBackgroundEffect={backgroundEffect} />,
onSelectBackgroundEffect={selectBackgroundEffect}
// Withheld once the device keeps as many as it will, which is what
// renders the add tile unavailable rather than letting it fail.
onAddBackgroundImage={
selectBackgroundEffect && added.length < maxAddedBackgrounds
? onAddBackgroundImage
: undefined
}
/>
</Fragment>,
); );
} else { } else {
buttons.push( buttons.push(
+44 -4
View File
@@ -11,6 +11,7 @@ import {
type CSSProperties, type CSSProperties,
type FC, type FC,
useEffect, useEffect,
useRef,
type ReactElement, type ReactElement,
} from "react"; } from "react";
import { import {
@@ -104,8 +105,12 @@ export interface MediaMuteAndSwitchButtonProps {
* wherever background processing is unavailable. * wherever background processing is unavailable.
*/ */
onSelectBackgroundEffect?: (id: string) => void; onSelectBackgroundEffect?: (id: string) => void;
/** Called when the add tile is chosen. Omit to leave that tile out. */ /**
onAddBackgroundImage?: () => void; * Called with the file the user chose from the add tile. Omit to leave that
* tile out. The picker lives here rather than with the caller because
* opening it takes the focus, which would otherwise dismiss the menu.
*/
onAddBackgroundImage?: (file: File) => void;
/** /**
* For any toggle and option this method will be called. * For any toggle and option this method will be called.
* So toggles need to be implemented by listening here and setting the right toggle item to `enabled` * So toggles need to be implemented by listening here and setting the right toggle item to `enabled`
@@ -162,6 +167,21 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
// Which device we have asked for but not yet been given. Carries the kind as // Which device we have asked for but not yet been given. Carries the kind as
// well as the id, because an input and an output can share an id: "default" // well as the id, because an input and an output can share an id: "default"
// names both on Chrome. // names both on Chrome.
// Held open across the file picker: a native dialog takes the focus, and the
// menu would take that as a click elsewhere and close behind it.
const [choosingFile, setChoosingFile] = useState(false);
const chooseFile = useRef<HTMLInputElement>(null);
useEffect(() => {
const input = chooseFile.current;
if (!input) return;
// Dismissing the picker without choosing fires `cancel`, which React does
// not type, so it is listened for directly. Without it the menu would
// stay pinned open after a cancelled pick.
const done = (): void => setChoosingFile(false);
input.addEventListener("cancel", done);
return (): void => input.removeEventListener("cancel", done);
}, [onAddBackgroundImage]);
const [plannedSelection, setPlannedSelection] = useState<{ const [plannedSelection, setPlannedSelection] = useState<{
kind: "input" | "output"; kind: "input" | "output";
id: string; id: string;
@@ -528,7 +548,8 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
} }
onSelect={(e) => { onSelect={(e) => {
e.preventDefault(); e.preventDefault();
onAddBackgroundImage(); setChoosingFile(true);
chooseFile.current?.click();
}} }}
key="add-background-image" key="add-background-image"
/>, />,
@@ -545,6 +566,21 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
> >
{/* The mute button lives inside */} {/* The mute button lives inside */}
{button} {button}
{onAddBackgroundImage !== undefined && (
<input
ref={chooseFile}
type="file"
accept="image/*"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
// Cleared so the same file can be chosen twice in a row.
e.target.value = "";
setChoosingFile(false);
if (file) onAddBackgroundImage(file);
}}
/>
)}
<Menu <Menu
className={styles.menu} className={styles.menu}
title={title ?? defaultMenuTitle} title={title ?? defaultMenuTitle}
@@ -552,7 +588,11 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
// sit on top of the first one. Kept for the accessible name only. // sit on top of the first one. Kept for the accessible name only.
showTitle={false} showTitle={false}
open={menuOpen} open={menuOpen}
onOpenChange={setMenuOpen} onOpenChange={(open) => {
// Ignore the close the file picker provokes by taking the focus.
if (!open && choosingFile) return;
setMenuOpen(open);
}}
side="top" side="top"
trigger={ trigger={
<Button <Button
+10 -5
View File
@@ -74,7 +74,7 @@ export interface AddedBackgrounds {
* Keeps a file as a background. Rejects with `UnusableImage` for a file that * Keeps a file as a background. Rejects with `UnusableImage` for a file that
* cannot be used, and `RangeError` once the device keeps as many as it will. * cannot be used, and `RangeError` once the device keeps as many as it will.
*/ */
addBackground: (file: Blob) => Promise<void>; addBackground: (file: Blob) => Promise<string>;
removeBackground: (id: string) => Promise<void>; removeBackground: (id: string) => Promise<void>;
} }
@@ -189,7 +189,9 @@ function switchOptionsFor(
): SwitchBackgroundProcessorOptions => ): SwitchBackgroundProcessorOptions =>
// A background the device no longer has — removed, or storage cleared — // A background the device no longer has — removed, or storage cleared —
// leaves the user with no effect rather than a pipeline drawing nothing. // leaves the user with no effect rather than a pipeline drawing nothing.
imagePath ? { mode: "virtual-background", imagePath } : { mode: "disabled" }; imagePath
? { mode: "virtual-background", imagePath }
: { mode: "disabled" };
switch (effect.kind) { switch (effect.kind) {
case "blur": case "blur":
@@ -230,7 +232,9 @@ export const ProcessorProvider: FC<Props> = ({ children }) => {
const reread = useCallback(async (): Promise<void> => { const reread = useCallback(async (): Promise<void> => {
const kept = await store.list(); const kept = await store.list();
urls.current.forEach((url) => URL.revokeObjectURL(url)); urls.current.forEach((url) => URL.revokeObjectURL(url));
urls.current = kept.map((background) => URL.createObjectURL(background.image)); urls.current = kept.map((background) =>
URL.createObjectURL(background.image),
);
setAdded(kept.map(({ id }, i) => ({ id, url: urls.current[i] }))); setAdded(kept.map(({ id }, i) => ({ id, url: urls.current[i] })));
}, [store]); }, [store]);
@@ -244,9 +248,10 @@ export const ProcessorProvider: FC<Props> = ({ children }) => {
}, [reread]); }, [reread]);
const addBackground = useCallback( const addBackground = useCallback(
async (file: Blob): Promise<void> => { async (file: Blob): Promise<string> => {
await store.add(file); const kept = await store.add(file);
await reread(); await reread();
return kept.id;
}, },
[store, reread], [store, reread],
); );