mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-22 22:29:30 +00:00
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:
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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],
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user