Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 24 additions & 17 deletions apps/desktop/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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":
Expand Down
1 change: 1 addition & 0 deletions apps/web/res/css/_components.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
102 changes: 102 additions & 0 deletions apps/web/res/css/structures/_IncomingCallPopup.pcss
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
15 changes: 15 additions & 0 deletions apps/web/src/BasePlatform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,21 @@

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 {}

Check failure on line 253 in apps/web/src/BasePlatform.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected empty method 'focusWindow'.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZ885q_oyEQH70inB5JT&open=AZ885q_oyEQH70inB5JT&pullRequest=34171

/**
* 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
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/LegacyCallHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -620,11 +621,19 @@ export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandl

const toastKey = getIncomingLegacyCallToastKey(call.callId);
if (status === CallState.Ringing) {
// Bring the app window to the front for an incoming call, if opted in.
// Not for force-silenced calls (e.g. already in another call) — yanking
// focus would interrupt the active call. No-op where the OS forbids it
// (e.g. Wayland); there the native notification's click raises the window.
if (SettingsStore.getValue("raiseWindowOnCall") && !this.isForcedSilent()) {
PlatformPeg.get()?.focusWindow();
}
ToastStore.sharedInstance().addOrReplaceToast({
key: toastKey,
priority: 100,
component: IncomingLegacyCallToast,
bodyClassName: "mx_IncomingLegacyCallToast",
callKind: "legacy",
props: { call },
});
} else {
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/Notifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -662,11 +662,20 @@ export default class Notifier extends TypedEventEmitter<keyof EmittedEvents, Emi
return;
}

// Bring the app window to the front for an actual incoming (ringing) call,
// if the user opted in. No-op on platforms that can't focus their window
// (e.g. Wayland, where the existing native call notification's click is
// the only way to raise the window).
if (content.notification_type === "ring" && SettingsStore.getValue("raiseWindowOnCall")) {
PlatformPeg.get()?.focusWindow();
}

toaster.addOrReplaceToast({
key,
priority: 100,
component: IncomingCallToast,
bodyClassName: "mx_IncomingCallToast",
callKind: "ec",
props: { notificationEvent: ev },
});
}
Expand Down
78 changes: 78 additions & 0 deletions apps/web/src/components/structures/IncomingCallPopup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
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 { type EmptyObject, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { type MatrixCall } from "matrix-js-sdk/src/webrtc/call";

import ToastStore, { type IToast } from "../../stores/ToastStore";
import SettingsStore from "../../settings/SettingsStore";
import { isIncomingCallToast } from "../../toasts/incomingCallToasts";
import { IncomingCallViewEC, IncomingCallViewLegacy } from "../views/voip/IncomingCallView";

interface IState {
callToast?: IToast<any>;
}

/**
* 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<EmptyObject, IState> {
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 => {

Check warning on line 49 in apps/web/src/components/structures/IncomingCallPopup.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member 'onToastStoreUpdate' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZ885q6PyEQH70inB5JQ&open=AZ885q6PyEQH70inB5JQ&pullRequest=34171
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 (
<IncomingCallViewEC
notificationEvent={(props as { notificationEvent: MatrixEvent }).notificationEvent}
toastKey={key}
/>
);
case "legacy":
return <IncomingCallViewLegacy call={(props as { call: MatrixCall }).call} />;
default:
return null;
}
}
}
2 changes: 2 additions & 0 deletions apps/web/src/components/structures/LoggedInView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -738,6 +739,7 @@ class LoggedInView extends React.Component<IProps, IState> {
<div className={bodyClasses}>{content}</div>
</div>
<PipContainer />
<IncomingCallPopup />
<NonUrgentToastContainer />
{audioFeedArraysForCalls}
</MatrixClientContextProvider>
Expand Down
14 changes: 12 additions & 2 deletions apps/web/src/components/structures/ToastContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>[];
Expand Down Expand Up @@ -43,12 +45,20 @@ export default class ToastContainer extends React.Component<EmptyObject, IState>
};

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, {
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/components/views/settings/Notifications.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -680,6 +681,12 @@ export default class Notifications extends React.PureComponent<EmptyObject, ISta
<SettingsFlag name="notificationsEnabled" level={SettingLevel.DEVICE} />
<SettingsFlag name="notificationBodyEnabled" level={SettingLevel.DEVICE} />
<SettingsFlag name="audioNotificationsEnabled" level={SettingLevel.DEVICE} />
{PlatformPeg.get()?.supportsWindowFocus() && (
<>
<SettingsFlag name="fullScreenCallNotification" level={SettingLevel.DEVICE} />
<SettingsFlag name="raiseWindowOnCall" level={SettingLevel.DEVICE} />
</>
)}
</>
)}

Expand Down
Loading
Loading