diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 047b2199e1b..010e3182cb5 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -13,18 +13,20 @@ import { getDisplayMediaCallback, setDisplayMediaCallback } from "./displayMedia import Store, { clearDataAndRelaunch } from "./store.js"; import { getConfig } from "./config.js"; -let focusHandlerAttached = false; +// Flash the taskbar entry until the window is next focused. Best-effort attention +// cue where a programmatic raise/focus is refused (Wayland compositors, Windows +// foreground-lock). The once("focus") listener auto-removes when it fires; repeated +// calls while unfocused just stack idempotent flashFrame handlers, all cleared on +// the next focus — no module-level state to wedge if the window is destroyed mid-flash. +function flashFrameUntilFocused(): void { + if (process.platform !== "win32" && process.platform !== "linux") return; + if (!global.mainWindow || global.mainWindow.isFocused()) return; + global.mainWindow.flashFrame(true); + global.mainWindow.once("focus", () => global.mainWindow?.flashFrame(false)); +} + ipcMain.on("loudNotification", function (): void { - if (process.platform === "win32" || process.platform === "linux") { - if (global.mainWindow && !global.mainWindow.isFocused() && !focusHandlerAttached) { - global.mainWindow.flashFrame(true); - global.mainWindow.once("focus", () => { - global.mainWindow?.flashFrame(false); - focusHandlerAttached = false; - }); - focusHandlerAttached = true; - } - } + flashFrameUntilFocused(); }); let powerSaveBlockerId: number | null = null; @@ -64,12 +66,17 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) { ret = app.getVersion(); break; case "focusWindow": - if (global.mainWindow.isMinimized()) { - global.mainWindow.restore(); - } else { - global.mainWindow.show(); - global.mainWindow.focus(); - } + // Reliably bring the window to the foreground regardless of its + // current state (minimized to tray, hidden, or merely unfocused). + // Mirrors the tray toggleWin() raise path. + if (global.mainWindow.isMinimized()) global.mainWindow.restore(); + if (!global.mainWindow.isVisible()) global.mainWindow.show(); + global.mainWindow.focus(); + // Best-effort attention fallback: Wayland compositors (and Windows' + // foreground-lock) often refuse a programmatic raise/focus. If we + // still don't have focus, flash the taskbar entry until the user + // interacts (guarded so repeated calls don't leak focus listeners). + flashFrameUntilFocused(); break; case "navigateBack": diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index f1496fd5952..09b271eac8d 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -63,6 +63,7 @@ @import "./structures/_FilePanel.pcss"; @import "./structures/_GenericDropdownMenu.pcss"; @import "./structures/_HomePage.pcss"; +@import "./structures/_IncomingCallPopup.pcss"; @import "./structures/_LargeLoader.pcss"; @import "./structures/_LeftPanel.pcss"; @import "./structures/_MainSplit.pcss"; diff --git a/apps/web/res/css/structures/_IncomingCallPopup.pcss b/apps/web/res/css/structures/_IncomingCallPopup.pcss new file mode 100644 index 00000000000..58d005f582c --- /dev/null +++ b/apps/web/res/css/structures/_IncomingCallPopup.pcss @@ -0,0 +1,102 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +/* Prominent, Slack-huddle-style full-screen incoming-call surface, rendered by + {@link IncomingCallPopup} using the bespoke {@link IncomingCallView}. */ +.mx_IncomingCallPopup { + position: fixed; + inset: 0; + /* Above the corner toasts (101) and the PiP. */ + z-index: 4000; + display: flex; + align-items: center; + justify-content: center; + background-color: rgb(0, 0, 0, 0.72); + backdrop-filter: blur(6px); + + .mx_IncomingCallPopup_card { + box-sizing: border-box; + min-width: 360px; + max-width: min(92vw, 440px); + padding: var(--cpd-space-12x) var(--cpd-space-10x); + background-color: var(--cpd-color-bg-canvas-default); + border-radius: var(--cpd-space-8x); + box-shadow: 0 12px 48px rgb(0, 0, 0, 0.55); + } +} + +.mx_IncomingCallView { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: var(--cpd-space-4x); + + .mx_IncomingCallView_title { + margin: 0; + font: var(--cpd-font-heading-md-semibold); + color: var(--cpd-color-text-primary); + } + + .mx_IncomingCallView_subtitle { + margin-top: calc(-1 * var(--cpd-space-2x)); + font: var(--cpd-font-body-md-regular); + color: var(--cpd-color-text-secondary); + } + + .mx_IncomingCallView_videoToggle { + display: flex; + justify-content: center; + } + + .mx_IncomingCallView_buttons { + display: flex; + gap: var(--cpd-space-16x); + margin-top: var(--cpd-space-4x); + } + + /* Large round icon-only accept/decline buttons, Slack-style. */ + .mx_IncomingCallView_button { + display: flex; + align-items: center; + justify-content: center; + width: 64px; + height: 64px; + border-radius: 50%; + transition: filter 0.15s; + + svg { + width: 28px; + height: 28px; + color: var(--cpd-color-icon-on-solid-primary); + } + + &:hover { + filter: brightness(1.08); + } + } + + .mx_IncomingCallView_button_decline { + background-color: var(--cpd-color-bg-critical-primary); + } + + .mx_IncomingCallView_button_accept { + background-color: var(--cpd-color-bg-accent-rest); + } + + .mx_IncomingCallView_button_silence { + background-color: var(--cpd-color-bg-subtle-secondary); + + svg { + color: var(--cpd-color-icon-primary); + } + + &[aria-disabled="true"] { + opacity: 0.5; + } + } +} diff --git a/apps/web/src/BasePlatform.ts b/apps/web/src/BasePlatform.ts index f95767fa00b..31c635ba5ad 100644 --- a/apps/web/src/BasePlatform.ts +++ b/apps/web/src/BasePlatform.ts @@ -245,6 +245,21 @@ export default abstract class BasePlatform { public loudNotification(ev: MatrixEvent, room: Room): void {} + /** + * Bring the application window to the foreground, restoring it from a + * minimized/tray state if necessary. No-op on platforms (e.g. browser) + * that cannot control their own window. + */ + public focusWindow(): void {} + + /** + * Whether {@link focusWindow} can actually bring the app window to the + * foreground. False on platforms (e.g. browser) where it is a no-op. + */ + public supportsWindowFocus(): boolean { + return false; + } + /** * Returns true if the platform requires URL previews in tooltips, otherwise false. * @returns {boolean} whether the platform requires URL previews in tooltips diff --git a/apps/web/src/LegacyCallHandler.tsx b/apps/web/src/LegacyCallHandler.tsx index 48627d59ad0..f73bca42e7a 100644 --- a/apps/web/src/LegacyCallHandler.tsx +++ b/apps/web/src/LegacyCallHandler.tsx @@ -30,6 +30,7 @@ import { _t } from "./languageHandler"; import dis from "./dispatcher/dispatcher"; import WidgetUtils from "./utils/WidgetUtils"; import SettingsStore from "./settings/SettingsStore"; +import PlatformPeg from "./PlatformPeg"; import { WidgetType } from "./widgets/WidgetType"; import { SettingLevel } from "./settings/SettingLevel"; import QuestionDialog from "./components/views/dialogs/QuestionDialog"; @@ -620,11 +621,19 @@ export default class LegacyCallHandler extends TypedEventEmitter; +} + +/** + * A prominent, full-screen incoming-call surface (Skype/Slack-huddle style). + * + * Driven by the same {@link ToastStore} entries and lifecycle hook as the compact + * {@link IncomingCallToast}. When the "raiseWindowOnCall" setting is enabled, + * incoming-call toasts are rendered here full-screen instead of in the corner + * {@link ToastContainer} when the "fullScreenCallNotification" setting is enabled, + * so exactly one lifecycle instance exists and the ring is not started twice. Both + * surfaces read the setting directly in render (no cached watcher state) so they + * always agree on ownership within a render pass. + * + * Only the single incoming-call toast is tracked in state (not the whole toast + * list), so unrelated toast churn doesn't re-render this always-mounted component. + */ +export default class IncomingCallPopup extends React.Component { + public constructor(props: EmptyObject) { + super(props); + this.state = { callToast: ToastStore.sharedInstance().getToasts().find(isIncomingCallToast) }; + } + + public componentDidMount(): void { + ToastStore.sharedInstance().on("update", this.onToastStoreUpdate); + } + + public componentWillUnmount(): void { + ToastStore.sharedInstance().removeListener("update", this.onToastStoreUpdate); + } + + private onToastStoreUpdate = (): void => { + const callToast = ToastStore.sharedInstance().getToasts().find(isIncomingCallToast); + if (callToast !== this.state.callToast) this.setState({ callToast }); + }; + + public render(): React.ReactNode { + if (!SettingsStore.getValue("fullScreenCallNotification")) return null; + + const callToast = this.state.callToast; + if (!callToast) return null; + + // Each view owns its own full-screen overlay, so a view that renders null + // (e.g. an unresolved legacy call) produces nothing — not an empty, + // un-dismissable backdrop covering the whole app. + const { key, callKind, props } = callToast; + switch (callKind) { + case "ec": + return ( + + ); + case "legacy": + return ; + default: + return null; + } + } +} diff --git a/apps/web/src/components/structures/LoggedInView.tsx b/apps/web/src/components/structures/LoggedInView.tsx index 1b49bf528ff..243fd20fec8 100644 --- a/apps/web/src/components/structures/LoggedInView.tsx +++ b/apps/web/src/components/structures/LoggedInView.tsx @@ -49,6 +49,7 @@ import { OwnProfileStore } from "../../stores/OwnProfileStore"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; import { RoomView } from "./RoomView"; import ToastContainer from "./ToastContainer"; +import IncomingCallPopup from "./IncomingCallPopup"; import UserView from "./UserView"; import { mediaFromMxc } from "../../customisations/Media"; import { UserTab } from "../views/dialogs/UserTab"; @@ -738,6 +739,7 @@ class LoggedInView extends React.Component {
{content}
+ {audioFeedArraysForCalls} diff --git a/apps/web/src/components/structures/ToastContainer.tsx b/apps/web/src/components/structures/ToastContainer.tsx index 16275d9d8ea..9caafda0d10 100644 --- a/apps/web/src/components/structures/ToastContainer.tsx +++ b/apps/web/src/components/structures/ToastContainer.tsx @@ -14,6 +14,8 @@ import { CloseIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import ToastStore, { type IToast } from "../../stores/ToastStore"; import { _t } from "../../languageHandler"; +import SettingsStore from "../../settings/SettingsStore"; +import { isIncomingCallToast } from "../../toasts/incomingCallToasts"; interface IState { toasts: IToast[]; @@ -43,12 +45,20 @@ export default class ToastContainer extends React.Component }; public render(): React.ReactNode { - const totalCount = this.state.toasts.length; + // Read the setting directly (rather than caching it in state via a + // watcher) so this container and {@link IncomingCallPopup} always agree + // on who owns incoming-call toasts within a render pass — avoids a + // split-brain window where the call could render in both or neither. + const popupEnabled = SettingsStore.getValue("fullScreenCallNotification"); + const visibleToasts = popupEnabled + ? this.state.toasts.filter((t) => !isIncomingCallToast(t)) + : this.state.toasts; + const totalCount = visibleToasts.length; const isStacked = totalCount > 1; let toast; let containerClasses; if (totalCount !== 0) { - const topToast = this.state.toasts[0]; + const topToast = visibleToasts[0]; const { title, icon, key, component, className, bodyClassName, onCloseButtonClicked, props } = topToast; const bodyClasses = classNames("mx_Toast_body", bodyClassName); const toastClasses = classNames("mx_Toast_toast", className, { diff --git a/apps/web/src/components/views/settings/Notifications.tsx b/apps/web/src/components/views/settings/Notifications.tsx index c52753907df..2ba8bd18ff1 100644 --- a/apps/web/src/components/views/settings/Notifications.tsx +++ b/apps/web/src/components/views/settings/Notifications.tsx @@ -33,6 +33,7 @@ import { } from "../../../notifications"; import { _t, type TranslatedString } from "../../../languageHandler"; import SettingsStore from "../../../settings/SettingsStore"; +import PlatformPeg from "../../../PlatformPeg"; import StyledRadioButton from "../elements/StyledRadioButton"; import { SettingLevel } from "../../../settings/SettingLevel"; import Modal from "../../../Modal"; @@ -680,6 +681,12 @@ export default class Notifications extends React.PureComponent + {PlatformPeg.get()?.supportsWindowFocus() && ( + <> + + + + )} )} diff --git a/apps/web/src/components/views/settings/notifications/NotificationSettings2.tsx b/apps/web/src/components/views/settings/notifications/NotificationSettings2.tsx index 7619e5acea1..43b19667149 100644 --- a/apps/web/src/components/views/settings/notifications/NotificationSettings2.tsx +++ b/apps/web/src/components/views/settings/notifications/NotificationSettings2.tsx @@ -34,6 +34,7 @@ import { NotificationPusherSettings } from "./NotificationPusherSettings"; import SettingsFlag from "../../elements/SettingsFlag"; import { SettingsSubsectionHeading } from "../shared/SettingsSubsectionHeading"; import { onSubmitPreventDefault } from "../../../../utils/form.ts"; +import PlatformPeg from "../../../../PlatformPeg"; enum NotificationDefaultLevels { AllMessages = "all_messages", @@ -132,6 +133,12 @@ export default function NotificationSettings2(): JSX.Element { level={SettingLevel.DEVICE} /> + {PlatformPeg.get()?.supportsWindowFocus() && ( + <> + + + + )} void; +} + +interface LayoutProps { + room?: Room; + title: string; + isVoice: boolean; + onAccept: (e: ButtonEvent) => void; + onDecline: (e: ButtonEvent) => void; + /** Optional silence/mute toggle (legacy 1:1 calls). */ + silence?: SilenceControl; + /** Optional extra content between subtitle and buttons (e.g. a video toggle). */ + children?: ReactNode; +} + +/** + * Presentational Slack-huddle-style incoming-call surface: full-screen dimmed + * backdrop, large avatar, caller name, subtitle, and round decline/accept + * buttons. Owns the overlay so that a view returning `null` (e.g. an unresolved + * legacy call) renders nothing at all — never an empty, un-dismissable backdrop. + */ +function IncomingCallLayout({ + room, + title, + isVoice, + onAccept, + onDecline, + silence, + children, +}: LayoutProps): JSX.Element { + const AcceptIcon = isVoice ? VoiceCallSolidIcon : VideoCallSolidIcon; + const subtitle = isVoice ? _t("voip|voice_call_incoming") : _t("voip|video_call_incoming"); + return ( +
+
+
+ +

{title}

+ {subtitle} + {children} +
+ {silence && ( + + {silence.silenced ? : } + + )} + + + + + + +
+
+
+
+ ); +} + +/** Bespoke full-screen view for an incoming Element Call (MatrixRTC) notification. */ +export function IncomingCallViewEC({ + notificationEvent, + toastKey, +}: { + notificationEvent: MatrixEvent; + toastKey: string; +}): JSX.Element { + // Accept joins immediately (onJoin = skipLobby: true) so "accept = in the call", + // rather than dropping the user into Element Call's own lobby with a second + // "Join" button. Per-device (mic/speaker/camera) selection is owned by Element + // Call and cannot be driven from here; the only pre-join choice we can pass is + // the audio/video intent, exposed via the video toggle below. + const { room, isVoice, videoToggle, setVideoToggle, onJoin, onDecline } = useIncomingCallToast( + notificationEvent, + toastKey, + ); + const videoToggleId = useId(); + + return ( + void onDecline(e)} + > + {!isVoice && ( + { + evt.preventDefault(); + evt.stopPropagation(); + }} + > + setVideoToggle(e.target.checked)} + /> + } + > + + + + )} + + ); +} + +/** Bespoke full-screen view for an incoming legacy 1:1 (MatrixCall) call. */ +export function IncomingCallViewLegacy({ call }: { call: MatrixCall }): JSX.Element | null { + const { legacyCallHandler, client } = useContext(SDKContext); + // Hooks must run unconditionally, before any early return. + const silenced = useTypedEventEmitterState(legacyCallHandler, LegacyCallHandlerEvent.SilencedCallsChanged, () => + legacyCallHandler.isCallSilenced(call.callId), + ); + + const roomId = legacyCallHandler.roomIdForCall(call); + if (!roomId) return null; + const room = client?.getRoom(roomId) ?? undefined; + const isVoice = call.type === CallType.Voice; + + const onAccept = (e: ButtonEvent): void => { + e.stopPropagation(); + legacyCallHandler.answerCall(roomId); + }; + const onDecline = (e: ButtonEvent): void => { + e.stopPropagation(); + legacyCallHandler.hangupOrReject(roomId, true); + }; + const onSilence = (e: ButtonEvent): void => { + e.stopPropagation(); + if (silenced) { + legacyCallHandler.unSilenceCall(call.callId); + } else { + legacyCallHandler.silenceCall(call.callId); + } + }; + + return ( + + ); +} diff --git a/apps/web/src/hooks/useIncomingCallToast.tsx b/apps/web/src/hooks/useIncomingCallToast.tsx new file mode 100644 index 00000000000..f33dc920237 --- /dev/null +++ b/apps/web/src/hooks/useIncomingCallToast.tsx @@ -0,0 +1,247 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { useCallback, useContext, useEffect, useRef, useState } from "react"; +import { + type Room, + type MatrixEvent, + type RoomMember, + RoomEvent, + EventType, + MatrixEventEvent, +} from "matrix-js-sdk/src/matrix"; +import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc"; + +import { SDKContext } from "../contexts/SDKContext"; +import defaultDispatcher from "../dispatcher/dispatcher"; +import { type ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload"; +import { Action } from "../dispatcher/actions"; +import ToastStore from "../stores/ToastStore"; +import { useCall, useParticipatingMembers } from "./useCall"; +import { type ButtonEvent } from "../components/views/elements/AccessibleButton"; +import { type Call, CallEvent } from "../models/Call"; +import { AudioID } from "../LegacyCallHandler"; +import { useEventEmitter, useTypedEventEmitter } from "./useEventEmitter"; +import { CallStore, CallStoreEvent } from "../stores/CallStore"; +import DMRoomMap from "../utils/DMRoomMap"; +import { useDispatcher } from "./useDispatcher"; +import { type ActionPayload } from "../dispatcher/payloads"; + +/** + * Get the ts when the notification event was sent (see {@link IncomingCallToast}). + */ +export const getNotificationEventSendTs = (event: MatrixEvent): number => { + const content = event.getContent() as Partial; + const sendTs = content.sender_ts; + if (sendTs && Math.abs(sendTs - event.getTs()) >= 15000) { + return event.getTs(); + } + return sendTs ?? event.getTs(); +}; + +const MAX_RING_TIME_MS = 90 * 1000; + +export interface IncomingCallToastState { + /** The room the call is in, if known. */ + room?: Room; + /** Whether this is an audio-only call. */ + isVoice: boolean; + /** Whether the notification is a "ring" (1:1/explicit) rather than a group "notify". */ + isRing: boolean; + /** The 1:1 counterparty's user id, if this is a DM call. */ + otherUserId?: string; + /** Members currently participating in the call. */ + members: RoomMember[]; + /** True if the user is already connected to a different call. */ + otherCallIsOngoing: boolean; + /** Join-with-video toggle state (video calls only). */ + videoToggle: boolean; + setVideoToggle: (value: boolean) => void; + /** Join the call (skipping the lobby). */ + onJoin: (e?: ButtonEvent) => void; + /** Open the call in the room (showing the lobby). */ + onExpand: (e?: ButtonEvent) => void; + /** Decline the incoming call and dismiss. */ + onDecline: (e: ButtonEvent) => Promise; + /** Dismiss the toast/popup and stop ringing. */ + dismiss: () => void; +} + +/** + * All lifecycle and actions for an incoming Element Call (MatrixRTC) notification: + * ringing, the ring timeout, auto-dismissal (remote decline, answered elsewhere, + * session ended, redaction, viewing the call), and the join/decline actions. + * + * Shared between the compact {@link IncomingCallToast} and the prominent + * full-screen {@link IncomingCallView} so both surfaces behave identically. + */ +export function useIncomingCallToast(notificationEvent: MatrixEvent, toastKey: string): IncomingCallToastState { + const sdkContext = useContext(SDKContext); + const legacyCallHandler = sdkContext.legacyCallHandler; + const roomId = notificationEvent.getRoomId()!; + const notificationContent = notificationEvent.getContent() as Partial; + const room = sdkContext.client?.getRoom(roomId) ?? undefined; + const call = useCall(roomId); + + const otherRoomHasCall = (): boolean => + Array.from(CallStore.instance.connectedCalls).some((c) => c.roomId !== roomId); + const [otherCallIsOngoing, setOtherCallIsOngoing] = useState(otherRoomHasCall); + useEventEmitter(CallStore.instance, CallStoreEvent.ConnectedCalls, () => { + setOtherCallIsOngoing(otherRoomHasCall()); + }); + + const isRing = notificationContent.notification_type === "ring"; + + // Start ringing for a "ring" notification, and stop on unmount as a backstop + // for surfaces that disappear without going through dismiss() (e.g. the toast + // being filtered out when the full-screen setting toggles, or a store reset). + const soundHasStarted = useRef(false); + useEffect(() => { + if (isRing && !soundHasStarted.current && !legacyCallHandler.isPlaying(AudioID.Ring)) { + soundHasStarted.current = true; + void legacyCallHandler.play(AudioID.Ring); + } + return () => { + if (soundHasStarted.current) legacyCallHandler.pause(AudioID.Ring); + }; + }, [isRing, legacyCallHandler]); + + const dismiss = useCallback((): void => { + ToastStore.sharedInstance().dismissToast(toastKey); + legacyCallHandler.pause(AudioID.Ring); + }, [toastKey, legacyCallHandler]); + + // Dismiss if the notification/call event is redacted. + // NOTE: this condition is effectively always-true (pre-existing behaviour + // carried over from IncomingCallToast): the array is built from `ev` and then + // tested against `ev.getId()`. It dismisses on any redaction in the room. Left + // as-is to preserve behaviour and existing tests; tracked for a separate fix. + useTypedEventEmitter(room, MatrixEventEvent.BeforeRedaction, (ev: MatrixEvent) => { + if ([ev.getId(), ev.getRelation()?.event_id].includes(ev.getId())) { + dismiss(); + } + }); + + // Dismiss if the session ended remotely. + const onCall = useCallback( + (c: Call, callRoomId: string): void => { + if (callRoomId !== notificationEvent.getRoomId()) return; + if (c === null || c.participants.size === 0) { + dismiss(); + } + }, + [dismiss, notificationEvent], + ); + + // Dismiss if we declined from this device (our own RTCDecline). + const onTimelineChange = useCallback( + (ev: MatrixEvent) => { + const userId = room?.client.getUserId(); + if ( + ev.getType() === EventType.RTCDecline && + userId !== undefined && + ev.getSender() === userId && + ev.relationEventId === notificationEvent.getId() + ) { + dismiss(); + } + }, + [dismiss, notificationEvent, room?.client], + ); + + // Dismiss if another of our devices joins. + const onParticipantChange = useCallback( + (participants: Map>) => { + if (Array.from(participants.keys()).some((p) => p.userId == room?.client.getUserId())) { + dismiss(); + } + }, + [dismiss, room?.client], + ); + + // Dismiss on timeout. + useEffect(() => { + const lifetime = notificationContent.lifetime ?? MAX_RING_TIME_MS; + const timeout = setTimeout(dismiss, getNotificationEventSendTs(notificationEvent) + lifetime - Date.now()); + return () => clearTimeout(timeout); + }, [dismiss, notificationEvent, notificationContent.lifetime]); + + const [videoToggle, setVideoToggle] = useState(true); + const isVoice = notificationContent["m.call.intent"] === "audio"; + + const viewCall = useCallback( + (skipLobby: boolean) => { + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: room?.roomId, + view_call: true, + skipLobby, + voiceOnly: isVoice || !videoToggle, + metricsTrigger: undefined, + }); + }, + [room, isVoice, videoToggle], + ); + + // Dismiss (stop the ring, remove the toast) up front so accepting is instant + // and reliable even if the ViewRoom dispatch below no-ops (e.g. the room isn't + // loaded yet); the dispatcher watcher would otherwise be the only dismiss path. + const onJoin = useCallback(() => { + dismiss(); + viewCall(true); + }, [dismiss, viewCall]); + const onExpand = useCallback(() => viewCall(false), [viewCall]); + + // Dismiss once the call is being viewed. + useDispatcher( + defaultDispatcher, + useCallback( + (payload: ActionPayload) => { + if (payload.action === Action.ViewRoom && payload.room_id === roomId && payload.view_call) { + dismiss(); + } + }, + [roomId, dismiss], + ), + ); + + // Guard against a double-click sending two RTCDecline events for one ring: the + // toast stays interactive until the async send resolves. + const declining = useRef(false); + const onDecline = useCallback( + async (e: ButtonEvent): Promise => { + e.stopPropagation(); + if (declining.current) return; + declining.current = true; + await room?.client.sendRtcDecline(room.roomId, notificationEvent.getId() ?? ""); + dismiss(); + }, + [room, notificationEvent, dismiss], + ); + + useEventEmitter(CallStore.instance, CallStoreEvent.Call, onCall); + useEventEmitter(call ?? undefined, CallEvent.Participants, onParticipantChange); + useEventEmitter(room, RoomEvent.Timeline, onTimelineChange); + + const otherUserId = DMRoomMap.shared().getUserIdForRoomId(roomId) ?? undefined; + const members = useParticipatingMembers(call); + + return { + room, + isVoice, + isRing, + otherUserId, + members, + otherCallIsOngoing, + videoToggle, + setVideoToggle, + onJoin, + onExpand, + onDecline, + dismiss, + }; +} diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index 1421425956a..4659dad1b12 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -2740,6 +2740,7 @@ "error_saving_detail": "An error occurred whilst saving your notification preferences.", "error_title": "Unable to enable Notifications", "error_updating": "An error occurred when updating your notification preferences. Please try to toggle your option again.", + "full_screen_call_notification": "Show incoming calls full-screen", "invites": "Invited to a room", "keywords": "Show a badge when keywords are used in a room.", "keywords_prompt": "Enter keywords here, or use for spelling variations or nicknames", @@ -2760,6 +2761,7 @@ "quick_actions_mark_all_read": "Mark all messages as read", "quick_actions_reset": "Reset to default settings", "quick_actions_section": "Quick Actions", + "raise_window_on_call": "Bring the window to the front on an incoming call", "room_activity": "New room activity, upgrades and status messages occur", "rule_call": "Call invitation", "rule_contains_display_name": "Messages containing my display name", diff --git a/apps/web/src/settings/Settings.tsx b/apps/web/src/settings/Settings.tsx index 4b137ae9016..b368667d4e8 100644 --- a/apps/web/src/settings/Settings.tsx +++ b/apps/web/src/settings/Settings.tsx @@ -312,6 +312,8 @@ export interface Settings { "notificationSound": IBaseSetting; "notificationBodyEnabled": IBaseSetting; "audioNotificationsEnabled": IBaseSetting; + "fullScreenCallNotification": IBaseSetting; + "raiseWindowOnCall": IBaseSetting; "enableWidgetScreenshots": IBaseSetting; "promptBeforeInviteUnknownUsers": IBaseSetting; "widgetOpenIDPermissions": IBaseSetting<{ @@ -1161,6 +1163,16 @@ export const SETTINGS: Settings = { default: true, displayName: _td("settings|notifications|enable_audible_notifications_session"), }, + "fullScreenCallNotification": { + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, + default: false, + displayName: _td("settings|notifications|full_screen_call_notification"), + }, + "raiseWindowOnCall": { + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, + default: false, + displayName: _td("settings|notifications|raise_window_on_call"), + }, "enableWidgetScreenshots": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, displayName: _td("devtools|widget_screenshots"), diff --git a/apps/web/src/stores/ToastStore.ts b/apps/web/src/stores/ToastStore.ts index 971e49be581..67ee67da095 100644 --- a/apps/web/src/stores/ToastStore.ts +++ b/apps/web/src/stores/ToastStore.ts @@ -23,6 +23,13 @@ export interface IToast { className?: string; bodyClassName?: string; + /** + * Marks an incoming-call toast and its call type, so the prominent + * {@link IncomingCallPopup} can identify and route it without depending on + * the (presentational) bodyClassName. See {@link isIncomingCallToast}. + */ + callKind?: "ec" | "legacy"; + /** * What to do if the user clicks the close button. If this is undefined, the * close button is not displayed. diff --git a/apps/web/src/toasts/IncomingCallToast.tsx b/apps/web/src/toasts/IncomingCallToast.tsx index 14f253c5224..36d063db94e 100644 --- a/apps/web/src/toasts/IncomingCallToast.tsx +++ b/apps/web/src/toasts/IncomingCallToast.tsx @@ -6,29 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { - type JSX, - type ReactNode, - type ComponentType, - type SVGAttributes, - useCallback, - useEffect, - useRef, - useState, - useId, - useContext, -} from "react"; -import { - type Room, - type MatrixEvent, - type RoomMember, - RoomEvent, - EventType, - MatrixEventEvent, -} from "matrix-js-sdk/src/matrix"; +import React, { type JSX, type ReactNode, type ComponentType, type SVGAttributes, useId } from "react"; import { AvatarStack, Button, Form, Heading, InlineField, Label, ToggleInput, Tooltip } from "@vector-im/compound-web"; -import { logger } from "matrix-js-sdk/src/logger"; -import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc"; +import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { CheckIcon, CloseIcon, @@ -40,51 +20,18 @@ import { AvatarWithDetails } from "@element-hq/web-shared-components"; import { _t } from "../languageHandler"; import RoomAvatar from "../components/views/avatars/RoomAvatar"; -import defaultDispatcher from "../dispatcher/dispatcher"; -import { type ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload"; -import { Action } from "../dispatcher/actions"; -import ToastStore from "../stores/ToastStore"; -import { useCall, useParticipatingMembers } from "../hooks/useCall"; import AccessibleButton, { type ButtonEvent } from "../components/views/elements/AccessibleButton"; -import { useDispatcher } from "../hooks/useDispatcher"; -import { type ActionPayload } from "../dispatcher/payloads"; -import { type Call, CallEvent } from "../models/Call"; -import { AudioID } from "../LegacyCallHandler"; -import { useEventEmitter, useTypedEventEmitter } from "../hooks/useEventEmitter"; -import { CallStore, CallStoreEvent } from "../stores/CallStore"; -import DMRoomMap from "../utils/DMRoomMap"; +import { useIncomingCallToast } from "../hooks/useIncomingCallToast"; import MemberAvatar from "../components/views/avatars/MemberAvatar"; -import { SDKContext } from "../contexts/SDKContext.ts"; + +// Re-exported for backwards compatibility with existing importers. +export { getNotificationEventSendTs } from "../hooks/useIncomingCallToast"; /** * Get the key for the incoming call toast. A combination of the call ID and room ID. - * @param callId The ID of the call. - * @param roomId The ID of the room. - * @returns The key for the incoming call toast. */ export const getIncomingCallToastKey = (callId: string, roomId: string): string => `call_${callId}_${roomId}`; -/** - * Get the ts when the notification event was sent. - * This can be either the origin_server_ts or a ts the sender of this event claims as - * the time they sent it (sender_ts). - * The origin_server_ts is the fallback if sender_ts seems wrong. - * @param event The RTCNotification event. - * @returns The timestamp to use as the expect start time to apply the `lifetime` to. - */ -export const getNotificationEventSendTs = (event: MatrixEvent): number => { - const content = event.getContent() as Partial; - const sendTs = content.sender_ts; - if (sendTs && Math.abs(sendTs - event.getTs()) >= 15000) { - logger.warn( - "Received RTCNotification event. With large sender_ts origin_server_ts offset -> using origin_server_ts", - ); - return event.getTs(); - } - return sendTs ?? event.getTs(); -}; -const MAX_RING_TIME_MS = 90 * 1000; - interface JoinCallButtonWithCallProps { onClick: (e: ButtonEvent) => void; disabledTooltip: string | undefined; @@ -104,46 +51,7 @@ function JoinCallButtonWithCall({ onClick, disabledTooltip }: JoinCallButtonWith ); - return disabledTooltip === undefined ? ( - button - ) : ( - {button} - ); -} - -interface DeclineCallButtonWithNotificationEventProps { - onDeclined: (e: ButtonEvent) => void; - notificationEvent: MatrixEvent; - room?: Room; -} - -function DeclineCallButtonWithNotificationEvent({ - notificationEvent, - room, - onDeclined, -}: DeclineCallButtonWithNotificationEventProps): JSX.Element { - const [declining, setDeclining] = useState(false); - const onClick = useCallback( - async (e: ButtonEvent) => { - e.stopPropagation(); - setDeclining(true); - await room?.client.sendRtcDecline(room.roomId, notificationEvent.getId() ?? ""); - onDeclined(e); - }, - [notificationEvent, onDeclined, room?.client, room?.roomId], - ); - return ( - - ); + return disabledTooltip === undefined ? button : {button}; } interface Props { @@ -159,139 +67,21 @@ interface Props { } export function IncomingCallToast({ notificationEvent, toastKey }: Props): JSX.Element { - const sdkContext = useContext(SDKContext); - const roomId = notificationEvent.getRoomId()!; - // Use a partial type so ts still helps us to not miss any type checks. - const notificationContent = notificationEvent.getContent() as Partial; - const room = sdkContext.client?.getRoom(roomId) ?? undefined; - const call = useCall(roomId); - const [connectedCalls, setConnectedCalls] = useState(Array.from(CallStore.instance.connectedCalls)); - useEventEmitter(CallStore.instance, CallStoreEvent.ConnectedCalls, () => { - setConnectedCalls(Array.from(CallStore.instance.connectedCalls)); - }); - const otherCallIsOngoing = connectedCalls.find((call) => call.roomId !== roomId); - const soundHasStarted = useRef(false); - useEffect(() => { - // This section can race, so we use a ref to keep track of whether we have started trying to play. - // This is because `LegacyCallHandler.play` tries to load the sound and then play it asynchonously - // and `LegacyCallHandler.isPlaying` will not be `true` until the sound starts playing. - const isRingToast = notificationContent.notification_type === "ring"; - if (isRingToast && !soundHasStarted.current && !sdkContext.legacyCallHandler.isPlaying(AudioID.Ring)) { - // Start ringing if not already. - soundHasStarted.current = true; - void sdkContext.legacyCallHandler.play(AudioID.Ring); - } - }, [notificationContent.notification_type, soundHasStarted, sdkContext.legacyCallHandler]); - - // Stop ringing on dismiss. - const dismissToast = useCallback((): void => { - ToastStore.sharedInstance().dismissToast(toastKey); - sdkContext.legacyCallHandler.pause(AudioID.Ring); - }, [toastKey, sdkContext.legacyCallHandler]); - - // Dismiss if the notification event or call event is redacted - useTypedEventEmitter(room, MatrixEventEvent.BeforeRedaction, (ev: MatrixEvent) => { - if ([ev.getId(), ev.getRelation()?.event_id].includes(ev.getId())) { - dismissToast(); - } - }); - - // Dismiss if session got ended remotely. - const onCall = useCallback( - (call: Call, callRoomId: string): void => { - const roomId = notificationEvent.getRoomId(); - if (!roomId && roomId !== callRoomId) return; - if (call === null || call.participants.size === 0) { - dismissToast(); - } - }, - [dismissToast, notificationEvent], - ); - - // Dismiss if session got declined remotely. - const onTimelineChange = useCallback( - (ev: MatrixEvent) => { - const userId = room?.client.getUserId(); - if ( - ev.getType() === EventType.RTCDecline && - userId !== undefined && - ev.getSender() === userId && // It is our decline not someone elses - ev.relationEventId === notificationEvent.getId() // The event declines this ringing toast. - ) { - dismissToast(); - } - }, - [dismissToast, notificationEvent, room?.client], - ); - - // Dismiss if another device from this user joins. - const onParticipantChange = useCallback( - (participants: Map>) => { - if (Array.from(participants.keys()).some((p) => p.userId == room?.client.getUserId())) { - dismissToast(); - } - }, - [dismissToast, room?.client], - ); - - // Dismiss on timeout. - useEffect(() => { - const lifetime = notificationContent.lifetime ?? MAX_RING_TIME_MS; - const timeout = setTimeout(dismissToast, getNotificationEventSendTs(notificationEvent) + lifetime - Date.now()); - return () => clearTimeout(timeout); - }); - - // Dismiss on viewing call. - useDispatcher( - defaultDispatcher, - useCallback( - (payload: ActionPayload) => { - if (payload.action === Action.ViewRoom && payload.room_id === roomId && payload.view_call) { - dismissToast(); - } - }, - [roomId, dismissToast], - ), - ); - - const [videoToggle, setVideoToggle] = useState(true); + const { + room, + isVoice, + isRing, + otherUserId, + members, + otherCallIsOngoing, + videoToggle, + setVideoToggle, + onJoin, + onExpand, + onDecline, + } = useIncomingCallToast(notificationEvent, toastKey); const videoToggleId = useId(); - const isVoice = notificationContent["m.call.intent"] === "audio"; - - const viewCall = useCallback( - (skipLobby: boolean) => { - // The toast will be automatically dismissed by the dispatcher callback above - defaultDispatcher.dispatch({ - action: Action.ViewRoom, - room_id: room?.roomId, - view_call: true, - skipLobby, - voiceOnly: isVoice || !videoToggle, - metricsTrigger: undefined, - }); - }, - [room, isVoice, videoToggle], - ); - - const onJoinClick = useCallback(() => viewCall(true), [viewCall]); - const onExpandClick = useCallback(() => viewCall(false), [viewCall]); - - // Dismiss on closing toast. - const onCloseClick = useCallback( - (e: ButtonEvent): void => { - e.stopPropagation(); - dismissToast(); - }, - [dismissToast], - ); - - useEventEmitter(CallStore.instance, CallStoreEvent.Call, onCall); - useEventEmitter(call ?? undefined, CallEvent.Participants, onParticipantChange); - useEventEmitter(room, RoomEvent.Timeline, onTimelineChange); - - const otherUserId = DMRoomMap.shared().getUserIdForRoomId(roomId); - const members = useParticipatingMembers(call); const avatars = (): ReactNode => ( {members.slice(0, 3).map((m) => ( @@ -301,7 +91,7 @@ export function IncomingCallToast({ notificationEvent, toastKey }: Props): JSX.E ); let detailsInformation: ReactNode; - if (notificationContent.notification_type === "ring") { + if (isRing) { detailsInformation = {otherUserId}; } else if (members.length > 0) { detailsInformation = @@ -338,7 +128,7 @@ export function IncomingCallToast({ notificationEvent, toastKey }: Props): JSX.E @@ -372,13 +162,19 @@ export function IncomingCallToast({ notificationEvent, toastKey }: Props): JSX.E )}
- +
diff --git a/apps/web/src/toasts/incomingCallToasts.ts b/apps/web/src/toasts/incomingCallToasts.ts new file mode 100644 index 00000000000..ea4dc0f4177 --- /dev/null +++ b/apps/web/src/toasts/incomingCallToasts.ts @@ -0,0 +1,18 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type IToast } from "../stores/ToastStore"; + +/** + * Whether a toast is an incoming-call toast ({@link IncomingCallToast} or + * {@link IncomingLegacyCallToast}), identified by its typed `callKind` marker. + * Used to route these toasts to the prominent full-screen {@link IncomingCallPopup} + * instead of the corner {@link ToastContainer}. + */ +export function isIncomingCallToast(toast: IToast): boolean { + return toast.callKind !== undefined; +} diff --git a/apps/web/src/vector/platform/ElectronPlatform.tsx b/apps/web/src/vector/platform/ElectronPlatform.tsx index b81ff409174..89ea735adbf 100644 --- a/apps/web/src/vector/platform/ElectronPlatform.tsx +++ b/apps/web/src/vector/platform/ElectronPlatform.tsx @@ -351,7 +351,7 @@ export default class ElectronPlatform extends BasePlatform { const handler = notification.onclick as () => void; notification.onclick = (): void => { handler?.(); - void this.ipc.call("focusWindow"); + this.focusWindow(); }; return notification; @@ -361,6 +361,14 @@ export default class ElectronPlatform extends BasePlatform { this.electron.send("loudNotification"); } + public focusWindow(): void { + void this.ipc.call("focusWindow"); + } + + public supportsWindowFocus(): boolean { + return true; + } + public needsUrlTooltips(): boolean { return true; } diff --git a/apps/web/test/unit-tests/LegacyCallHandler-test.ts b/apps/web/test/unit-tests/LegacyCallHandler-test.ts index 02992d54283..70d54d83ab0 100644 --- a/apps/web/test/unit-tests/LegacyCallHandler-test.ts +++ b/apps/web/test/unit-tests/LegacyCallHandler-test.ts @@ -31,6 +31,7 @@ import LegacyCallHandler, { PROTOCOL_PSTN_PREFIXED, } from "../../src/LegacyCallHandler"; import { mkStubRoom, stubClient, untilDispatch } from "../test-utils"; +import { mockPlatformPeg } from "../test-utils/platform"; import { MatrixClientPeg } from "../../src/MatrixClientPeg"; import DMRoomMap from "../../src/utils/DMRoomMap"; import SdkConfig from "../../src/SdkConfig"; @@ -527,6 +528,47 @@ describe("LegacyCallHandler without third party protocols", () => { await waitFor(() => expect(mockAudioBufferSourceNode.start).toHaveBeenCalled()); }); + it("brings the window to the front on an incoming ringing call when raiseWindowOnCall is enabled", () => { + // remove local notification silencing mock so the call isn't force-silenced + jest.spyOn(MatrixClientPeg.safeGet(), "getAccountData").mockReturnValue(undefined); + jest.spyOn(SettingsStore, "getValue").mockImplementation( + (setting) => setting === UIFeature.Voip || setting === "raiseWindowOnCall", + ); + const platform = mockPlatformPeg(); + const focusWindow = jest.spyOn(platform, "focusWindow"); + const call = new MatrixCall({ client: MatrixClientPeg.safeGet(), roomId }); + MatrixClientPeg.safeGet().emit(CallEventHandlerEvent.Incoming, call); + call.emit(CallEvent.State, CallState.Ringing, CallState.Connected, fakeCall!); + + expect(focusWindow).toHaveBeenCalled(); + }); + + it("does not bring the window to the front when local notifications are silenced", () => { + // beforeEach mocks getAccountData to a silenced state, so the call is + // force-silenced and the window must not be yanked to the foreground. + jest.spyOn(SettingsStore, "getValue").mockImplementation( + (setting) => setting === UIFeature.Voip || setting === "raiseWindowOnCall", + ); + const platform = mockPlatformPeg(); + const focusWindow = jest.spyOn(platform, "focusWindow"); + const call = new MatrixCall({ client: MatrixClientPeg.safeGet(), roomId }); + MatrixClientPeg.safeGet().emit(CallEventHandlerEvent.Incoming, call); + call.emit(CallEvent.State, CallState.Ringing, CallState.Connected, fakeCall!); + + expect(focusWindow).not.toHaveBeenCalled(); + }); + + it("does not bring the window to the front when raiseWindowOnCall is disabled", () => { + // beforeEach only enables UIFeature.Voip, so raiseWindowOnCall is false + const platform = mockPlatformPeg(); + const focusWindow = jest.spyOn(platform, "focusWindow"); + const call = new MatrixCall({ client: MatrixClientPeg.safeGet(), roomId }); + MatrixClientPeg.safeGet().emit(CallEventHandlerEvent.Incoming, call); + call.emit(CallEvent.State, CallState.Ringing, CallState.Connected, fakeCall!); + + expect(focusWindow).not.toHaveBeenCalled(); + }); + it("does not ring when incoming call state is ringing but local notifications are silenced", () => { const call = new MatrixCall({ client: MatrixClientPeg.safeGet(), diff --git a/apps/web/test/unit-tests/components/structures/IncomingCallPopup-test.tsx b/apps/web/test/unit-tests/components/structures/IncomingCallPopup-test.tsx new file mode 100644 index 00000000000..ff5e6e0c346 --- /dev/null +++ b/apps/web/test/unit-tests/components/structures/IncomingCallPopup-test.tsx @@ -0,0 +1,93 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React from "react"; +import { render, screen, act } from "jest-matrix-react"; + +import IncomingCallPopup from "../../../../src/components/structures/IncomingCallPopup"; +import ToastStore from "../../../../src/stores/ToastStore"; +import SettingsStore from "../../../../src/settings/SettingsStore"; + +// The bespoke call views pull in the full call/matrix stack; their behaviour is +// covered by the toast + hook tests. Here we only verify the popup's gating and +// EC-vs-legacy branching, so stub the views out. +jest.mock("../../../../src/components/views/voip/IncomingCallView", () => ({ + IncomingCallViewEC: () =>
, + IncomingCallViewLegacy: () =>
, +})); + +const Dummy: React.FC = () => null; + +const addECCallToast = (): void => + ToastStore.sharedInstance().addOrReplaceToast({ + key: "call_1", + priority: 100, + component: Dummy, + callKind: "ec", + props: { notificationEvent: {} } as any, + }); + +const addLegacyCallToast = (): void => + ToastStore.sharedInstance().addOrReplaceToast({ + key: "call_legacy", + priority: 100, + component: Dummy, + callKind: "legacy", + props: { call: {} } as any, + }); + +describe("", () => { + beforeEach(() => { + ToastStore.sharedInstance().reset(); + jest.restoreAllMocks(); + jest.spyOn(SettingsStore, "watchSetting").mockReturnValue("ref"); + jest.spyOn(SettingsStore, "unwatchSetting").mockImplementation(() => {}); + }); + + it("renders nothing when the setting is disabled", () => { + jest.spyOn(SettingsStore, "getValue").mockReturnValue(false); + addECCallToast(); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing when enabled but there is no incoming-call toast", () => { + jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); + ToastStore.sharedInstance().addOrReplaceToast({ + key: "other", + priority: 10, + component: Dummy, + bodyClassName: "mx_SomeOtherToast", + props: {}, + }); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders the bespoke Element Call view full-screen when enabled", () => { + jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); + addECCallToast(); + render(); + // The view owns its own overlay; the popup just selects and renders it. + expect(screen.getByTestId("call-view-ec")).toBeInTheDocument(); + }); + + it("renders the legacy call view for a legacy toast", () => { + jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); + addLegacyCallToast(); + render(); + expect(screen.getByTestId("call-view-legacy")).toBeInTheDocument(); + }); + + it("appears when a call toast is added after mount", () => { + jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); + render(); + expect(screen.queryByTestId("call-view-ec")).not.toBeInTheDocument(); + act(() => addECCallToast()); + expect(screen.getByTestId("call-view-ec")).toBeInTheDocument(); + }); +});