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.
*/
import {
Fragment,
type FC,
type JSX,
type Ref,
useCallback,
useMemo,
useRef,
} from "react";
import { type FC, type JSX, type Ref, useCallback, useMemo } from "react";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/lib/logger";
@@ -180,29 +172,27 @@ export const CallFooter: FC<FooterProps> = ({
const selectBackgroundEffect = useBehavior(vm.selectBackgroundEffect$);
const { added, addBackground } = useAddedBackgrounds();
const chooseFile = useRef<HTMLInputElement>(null);
const onAddBackgroundImage = useCallback((): void => {
chooseFile.current?.click();
}, []);
const onFileChosen = useCallback(
(event: React.ChangeEvent<HTMLInputElement>): void => {
const file = event.target.files?.[0];
// Cleared so choosing the same file twice in a row still counts.
event.target.value = "";
if (!file) return;
addBackground(file).catch((e) => {
// TODO: FR-021 wants the user told what went wrong. There is no
// surface for that in the menu yet, and inventing one is design's
// call, so for now this is only logged.
logger.warn(
e instanceof UnusableImage
? `Cannot use that file as a background: ${e.reason}`
: "Could not keep that background",
e,
);
});
const onAddBackgroundImage = useCallback(
(file: File): void => {
// Chosen for the user straight away: they picked this picture to use it,
// and leaving it unselected would ask them to pick it twice.
addBackground(file)
.then((id) =>
selectBackgroundEffect?.(serializeEffect({ kind: "added", id })),
)
.catch((e) => {
// TODO: FR-021 wants the user told what went wrong. There is no
// surface for that in the menu yet, and inventing one is design's
// call, so for now this is only logged.
logger.warn(
e instanceof UnusableImage
? `Cannot use that file as a background: ${e.reason}`
: "Could not keep that background",
e,
);
});
},
[addBackground],
[addBackground, selectBackgroundEffect],
);
// 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) {
buttons.push(
<Fragment key="video">
{/* The picker the add tile opens. Hidden, and driven from the tile,
because a file input cannot be styled into one. */}
<input
ref={chooseFile}
type="file"
accept="image/*"
hidden
onChange={onFileChosen}
/>
<MediaMuteAndSwitchButton
iconsAndLabels="video"
enabled={videoEnabled ?? false}
busy={videoBusy ?? false}
onMuteClick={toggleVideo}
options={videoOptions}
selectedOption={selectedVideo}
onSelect={selectVideoButtonOption}
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>,
<MediaMuteAndSwitchButton
key="video"
iconsAndLabels="video"
enabled={videoEnabled ?? false}
busy={videoBusy ?? false}
onMuteClick={toggleVideo}
options={videoOptions}
selectedOption={selectedVideo}
onSelect={selectVideoButtonOption}
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
}
/>,
);
} else {
buttons.push(
+44 -4
View File
@@ -11,6 +11,7 @@ import {
type CSSProperties,
type FC,
useEffect,
useRef,
type ReactElement,
} from "react";
import {
@@ -104,8 +105,12 @@ export interface MediaMuteAndSwitchButtonProps {
* wherever background processing is unavailable.
*/
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.
* 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
// well as the id, because an input and an output can share an id: "default"
// 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<{
kind: "input" | "output";
id: string;
@@ -528,7 +548,8 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
}
onSelect={(e) => {
e.preventDefault();
onAddBackgroundImage();
setChoosingFile(true);
chooseFile.current?.click();
}}
key="add-background-image"
/>,
@@ -545,6 +566,21 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
>
{/* The mute button lives inside */}
{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
className={styles.menu}
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.
showTitle={false}
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"
trigger={
<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
* 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>;
}
@@ -189,7 +189,9 @@ function switchOptionsFor(
): SwitchBackgroundProcessorOptions =>
// A background the device no longer has — removed, or storage cleared —
// 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) {
case "blur":
@@ -230,7 +232,9 @@ export const ProcessorProvider: FC<Props> = ({ children }) => {
const reread = useCallback(async (): Promise<void> => {
const kept = await store.list();
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] })));
}, [store]);
@@ -244,9 +248,10 @@ export const ProcessorProvider: FC<Props> = ({ children }) => {
}, [reread]);
const addBackground = useCallback(
async (file: Blob): Promise<void> => {
await store.add(file);
async (file: Blob): Promise<string> => {
const kept = await store.add(file);
await reread();
return kept.id;
},
[store, reread],
);