diff --git a/locales/en/app.json b/locales/en/app.json index f273f503f..f6265c4f2 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -96,6 +96,10 @@ }, "disconnected_banner": "Connectivity to the server has been lost.", "error": { + "background_animated": "Animated images cannot be used as a background", + "background_not_an_image": "That file is not an image", + "background_not_kept": "That background could not be saved", + "background_undecodable": "That image could not be opened", "call_is_not_supported": "Call is not supported", "call_not_found": "Call not found", "call_not_found_description": "<0>That link doesn't appear to belong to any existing call. Check that you have the right link, or <2>create a new one.", diff --git a/src/components/CallFooter.tsx b/src/components/CallFooter.tsx index 5a62443ba..884cf9b2f 100644 --- a/src/components/CallFooter.tsx +++ b/src/components/CallFooter.tsx @@ -5,7 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { type FC, type JSX, type Ref, useCallback, useMemo } from "react"; +import { + type FC, + type JSX, + type Ref, + useCallback, + useMemo, + useState, +} from "react"; import classNames from "classnames"; import { useTranslation } from "react-i18next"; import { logger } from "matrix-js-sdk/lib/logger"; @@ -172,6 +179,27 @@ export const CallFooter: FC = ({ const backgroundEffect = useBehavior(vm.backgroundEffect$); const selectBackgroundEffect = useBehavior(vm.selectBackgroundEffect$); const { added, addBackground, removeBackground } = useAddedBackgrounds(); + const [backgroundEffectError, setBackgroundEffectError] = useState< + string | undefined + >(undefined); + + // Spelled out rather than built from the reason, so the extractor can find + // every string it has to translate. + const whyRefused = useCallback( + (e: unknown): string => { + if (e instanceof UnusableImage) + switch (e.reason) { + case "not-an-image": + return t("error.background_not_an_image"); + case "animated": + return t("error.background_animated"); + case "undecodable": + return t("error.background_undecodable"); + } + return t("error.background_not_kept"); + }, + [t], + ); const onRemoveBackgroundEffect = useCallback( (id: string): void => { @@ -188,6 +216,7 @@ export const CallFooter: FC = ({ (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. + setBackgroundEffectError(undefined); addBackground(file) .then((id) => selectBackgroundEffect?.(serializeEffect({ kind: "added", id })), @@ -196,6 +225,7 @@ export const CallFooter: FC = ({ // 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. + setBackgroundEffectError(whyRefused(e)); logger.warn( e instanceof UnusableImage ? `Cannot use that file as a background: ${e.reason}` @@ -204,7 +234,7 @@ export const CallFooter: FC = ({ ); }); }, - [addBackground, selectBackgroundEffect], + [addBackground, selectBackgroundEffect, whyRefused], ); // The catalogue is named here rather than in the view model: the names are @@ -305,6 +335,7 @@ export const CallFooter: FC = ({ : undefined } onRemoveBackgroundEffect={onRemoveBackgroundEffect} + backgroundEffectError={backgroundEffectError} />, ); } else { diff --git a/src/components/MediaMuteAndSwitchButton.module.css b/src/components/MediaMuteAndSwitchButton.module.css index 47c349991..eb86b316b 100644 --- a/src/components/MediaMuteAndSwitchButton.module.css +++ b/src/components/MediaMuteAndSwitchButton.module.css @@ -369,3 +369,8 @@ Please see LICENSE in the repository root for full details. flex: 1; min-inline-size: 0; } + +/* Sits under the tiles, inside the same margin they keep from the frame. */ +.effectError { + padding: 0 var(--cpd-space-4x) var(--cpd-space-4x); +} diff --git a/src/components/MediaMuteAndSwitchButton.stories.tsx b/src/components/MediaMuteAndSwitchButton.stories.tsx index 8bb945424..50f8c2e30 100644 --- a/src/components/MediaMuteAndSwitchButton.stories.tsx +++ b/src/components/MediaMuteAndSwitchButton.stories.tsx @@ -1014,3 +1014,30 @@ export const RemovingWithLiveSelection: Story = { await expect(none).toHaveAttribute("aria-checked", "true"); }, }; + +/** A file that could not be used, said where the user chose it. */ +export const BackgroundImageRefused: Story = { + args: { + ...BackgroundEffects.args, + backgroundEffectError: "Animated images cannot be used as a background", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button", { name: "Camera" })); + const body = within(document.body); + + // Scoped to the open menu: a closed one leaves its own markup behind, and + // the first match in the document belongs to that rather than to this. + const menu = await body.findByRole("menu"); + await waitFor(async () => + expect( + menu.querySelector(`.${styles.effectError}`), + ).toHaveTextContent("Animated images cannot be used as a background"), + ); + // The grid is still there to choose from: being refused a file changes + // nothing about the background in force. + await expect( + await body.findByRole("menuitemradio", { name: "None" }), + ).toHaveAttribute("aria-checked", "true"); + }, +}; diff --git a/src/components/MediaMuteAndSwitchButton.tsx b/src/components/MediaMuteAndSwitchButton.tsx index 8139bb801..45cffe8f9 100644 --- a/src/components/MediaMuteAndSwitchButton.tsx +++ b/src/components/MediaMuteAndSwitchButton.tsx @@ -15,6 +15,7 @@ import { type ReactElement, } from "react"; import { + Alert, Button, Menu, MenuItem, @@ -120,6 +121,11 @@ export interface MediaMuteAndSwitchButtonProps { * wearing, and taking it away would leave them with nothing chosen. */ onRemoveBackgroundEffect?: (id: string) => void; + /** + * Why the last file the user offered could not be used, if it could not. + * Shown with the grid, where they chose it. + */ + backgroundEffectError?: string; /** * 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` @@ -172,6 +178,7 @@ export const MediaMuteAndSwitchButton: FC = ({ onSelectBackgroundEffect, onAddBackgroundImage, onRemoveBackgroundEffect, + backgroundEffectError, onSelect, }) => { // Which device we have asked for but not yet been given. Carries the kind as @@ -760,6 +767,13 @@ export const MediaMuteAndSwitchButton: FC = ({
{effectTiles()}
+ {backgroundEffectError !== undefined && ( + // Beside the grid rather than over the call: the user is + // looking here, having just chosen the file this is about. +
+ +
+ )} )}