Delegate the sending of call notifications to Element Call (#30507)

* Move Element Call event types to a more appropriate file

To remove the potential for import cycles in src/models/Call.ts, which I was accidentally creating when I tried to reference data from the RoomListStore in the ElementCall class.

* Make sure ElementCall tests clean up the call object

* Upgrade Element Call to v0.14.1

* Delegate the sending of call notifications to Element Call

As of Element Call version 0.14.0, the widget is now capable of sending call notifications itself if we just request this with the sendNotificationType URL parameter. This makes Element Web's group call code a little bit more succinct.

* Fix createRoom test
This commit is contained in:
Robin
2025-08-07 09:27:53 +00:00
committed by GitHub
parent 8086262e04
commit c53b17d291
18 changed files with 112 additions and 130 deletions
+2 -2
View File
@@ -36,9 +36,9 @@ import { RoomSettingsTab } from "./components/views/dialogs/RoomSettingsDialog";
import AccessibleButton from "./components/views/elements/AccessibleButton";
import RightPanelStore from "./stores/right-panel/RightPanelStore";
import { highlightEvent, isLocationEvent } from "./utils/EventUtils";
import { ElementCall } from "./models/Call";
import { getSenderName } from "./utils/event/getSenderName";
import PosthogTrackers from "./PosthogTrackers.ts";
import { ElementCallEventType } from "./call-types.ts";
function getRoomMemberDisplayname(client: MatrixClient, event: MatrixEvent, userId = event.getSender()): string {
const roomId = event.getRoomId();
@@ -922,7 +922,7 @@ for (const evType of ALL_RULE_TYPES) {
}
// Add both stable and unstable m.call events
for (const evType of ElementCall.CALL_EVENT_TYPE.names) {
for (const evType of ElementCallEventType.names) {
stateHandlers[evType] = textForCallEvent;
}
+9
View File
@@ -6,6 +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 { EventType } from "matrix-js-sdk/src/matrix";
import { NamespacedValue } from "matrix-js-sdk/src/NamespacedValue";
export const JitsiCallMemberEventType = "io.element.video.member";
export interface JitsiCallMemberContent {
@@ -14,3 +17,9 @@ export interface JitsiCallMemberContent {
// Time at which this state event should be considered stale
expires_ts: number;
}
// Element Call no longer sends this event type; it only exists to support timeline rendering of
// group calls from a previous iteration of the group VoIP MSCs (MSC3401) which used it.
export const ElementCallEventType = new NamespacedValue(null, EventType.GroupCallPrefix);
export const ElementCallMemberEventType = new NamespacedValue(null, EventType.GroupCallMemberPrefix);
+3 -3
View File
@@ -76,13 +76,13 @@ import ThreadSummary, { ThreadMessagePreview } from "./ThreadSummary";
import { ReadReceiptGroup } from "./ReadReceiptGroup";
import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom";
import { ElementCall } from "../../../models/Call";
import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge";
import { EventTileThreadToolbar } from "./EventTile/EventTileThreadToolbar";
import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper";
import PinningUtils from "../../../utils/PinningUtils";
import { PinnedMessageBadge } from "../messages/PinnedMessageBadge";
import { EventPreview } from "./EventPreview";
import { ElementCallEventType } from "../../../call-types";
export type GetRelationsForEvent = (
eventId: string,
@@ -984,7 +984,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
mx_EventTile_highlight: this.shouldHighlight(),
mx_EventTile_selected: this.props.isSelectedEvent || this.state.contextMenu,
mx_EventTile_continuation:
isContinuation || eventType === EventType.CallInvite || ElementCall.CALL_EVENT_TYPE.matches(eventType),
isContinuation || eventType === EventType.CallInvite || ElementCallEventType.matches(eventType),
mx_EventTile_last: this.props.last,
mx_EventTile_lastInSection: this.props.lastInSection,
mx_EventTile_contextual: this.props.contextual,
@@ -1037,7 +1037,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
} else if (
(this.props.continuation && this.context.timelineRenderingType !== TimelineRenderingType.File) ||
eventType === EventType.CallInvite ||
ElementCall.CALL_EVENT_TYPE.matches(eventType)
ElementCallEventType.matches(eventType)
) {
// no avatar or sender profile for continuation messages and call tiles
avatarSize = null;
@@ -26,13 +26,13 @@ import ErrorDialog from "../../../dialogs/ErrorDialog";
import PowerSelector from "../../../elements/PowerSelector";
import SettingsFieldset from "../../SettingsFieldset";
import SettingsStore from "../../../../../settings/SettingsStore";
import { ElementCall } from "../../../../../models/Call";
import SdkConfig, { DEFAULTS } from "../../../../../SdkConfig";
import { AddPrivilegedUsers } from "../../AddPrivilegedUsers";
import SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection";
import MatrixClientContext from "../../../../../contexts/MatrixClientContext";
import { PowerLevelSelector } from "../../PowerLevelSelector";
import { ElementCallEventType, ElementCallMemberEventType } from "../../../../../call-types";
interface IEventShowOpts {
isState?: boolean;
@@ -63,8 +63,8 @@ const plEventsToShow: Record<string, IEventShowOpts> = {
[EventType.RoomRedaction]: { isState: false, hideForSpace: true },
// MSC3401: Native Group VoIP signaling
[ElementCall.CALL_EVENT_TYPE.name]: { isState: true, hideForSpace: true },
[ElementCall.MEMBER_EVENT_TYPE.name]: { isState: true, hideForSpace: true },
[ElementCallEventType.name]: { isState: true, hideForSpace: true },
[ElementCallMemberEventType.name]: { isState: true, hideForSpace: true },
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
"im.vector.modular.widgets": { isState: true, hideForSpace: true },
@@ -298,8 +298,8 @@ export default class RolesRoomSettingsTab extends React.Component<IProps, RolesR
// MSC3401: Native Group VoIP signaling
if (SettingsStore.getValue("feature_group_calls")) {
plEventsToLabels[ElementCall.CALL_EVENT_TYPE.name] = _td("room_settings|permissions|m.call");
plEventsToLabels[ElementCall.MEMBER_EVENT_TYPE.name] = _td("room_settings|permissions|m.call.member");
plEventsToLabels[ElementCallEventType.name] = _td("room_settings|permissions|m.call");
plEventsToLabels[ElementCallMemberEventType.name] = _td("room_settings|permissions|m.call.member");
}
const powerLevelDescriptors: Record<string, IPowerLevelDescriptor> = {
@@ -14,10 +14,10 @@ import { _t } from "../../../../../languageHandler";
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
import { SettingsSubsection } from "../../shared/SettingsSubsection";
import SettingsTab from "../SettingsTab";
import { ElementCall } from "../../../../../models/Call";
import { useRoomState } from "../../../../../hooks/useRoomState";
import SdkConfig, { DEFAULTS } from "../../../../../SdkConfig";
import { SettingsSection } from "../../shared/SettingsSection";
import { ElementCallEventType, ElementCallMemberEventType } from "../../../../../call-types";
interface ElementCallSwitchProps {
room: Room;
@@ -42,7 +42,7 @@ const ElementCallSwitch: React.FC<ElementCallSwitchProps> = ({ room }) => {
);
const [elementCallEnabled, setElementCallEnabled] = useState<boolean>(() => {
return content.events?.[ElementCall.MEMBER_EVENT_TYPE.name] === 0;
return content.events?.[ElementCallMemberEventType.name] === 0;
});
const onChange = useCallback(
@@ -56,13 +56,13 @@ const ElementCallSwitch: React.FC<ElementCallSwitchProps> = ({ room }) => {
const userLevel = newContent.events[EventType.RoomMessage] ?? content.users_default ?? 0;
const moderatorLevel = content.kick ?? 50;
newContent.events[ElementCall.CALL_EVENT_TYPE.name] = isPublic ? moderatorLevel : userLevel;
newContent.events[ElementCall.MEMBER_EVENT_TYPE.name] = userLevel;
newContent.events[ElementCallEventType.name] = isPublic ? moderatorLevel : userLevel;
newContent.events[ElementCallMemberEventType.name] = userLevel;
} else {
const adminLevel = newContent.events[EventType.RoomPowerLevels] ?? content.state_default ?? 100;
newContent.events[ElementCall.CALL_EVENT_TYPE.name] = adminLevel;
newContent.events[ElementCall.MEMBER_EVENT_TYPE.name] = adminLevel;
newContent.events[ElementCallEventType.name] = adminLevel;
newContent.events[ElementCallMemberEventType.name] = adminLevel;
}
room.client.sendStateEvent(room.roomId, EventType.RoomPowerLevels, newContent);
+5 -4
View File
@@ -42,6 +42,7 @@ import { waitForMember } from "./utils/membership";
import { PreferredRoomVersions } from "./utils/PreferredRoomVersions";
import SettingsStore from "./settings/SettingsStore";
import { MEGOLM_ENCRYPTION_ALGORITHM } from "./utils/crypto";
import { ElementCallEventType, ElementCallMemberEventType } from "./call-types";
// we define a number of interfaces which take their names from the js-sdk
/* eslint-disable camelcase */
@@ -165,9 +166,9 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro
events: {
...DEFAULT_EVENT_POWER_LEVELS,
// Allow all users to send call membership updates
[ElementCall.MEMBER_EVENT_TYPE.name]: 0,
[ElementCallMemberEventType.name]: 0,
// Make calls immutable, even to admins
[ElementCall.CALL_EVENT_TYPE.name]: 200,
[ElementCallEventType.name]: 200,
},
users: {
// Temporarily give ourselves the power to set up a call
@@ -180,9 +181,9 @@ export default async function createRoom(client: MatrixClient, opts: IOpts): Pro
events: {
...DEFAULT_EVENT_POWER_LEVELS,
// It should always (including non video rooms) be possible to join a group call.
[ElementCall.MEMBER_EVENT_TYPE.name]: 0,
[ElementCallMemberEventType.name]: 0,
// Make sure only admins can enable it (DEPRECATED)
[ElementCall.CALL_EVENT_TYPE.name]: 100,
[ElementCallEventType.name]: 100,
},
};
}
+3 -5
View File
@@ -40,11 +40,11 @@ import { getMessageModerationState, MessageModerationState } from "../utils/Even
import HiddenBody from "../components/views/messages/HiddenBody";
import ViewSourceEvent from "../components/views/messages/ViewSourceEvent";
import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline";
import { ElementCall } from "../models/Call";
import { type IBodyProps } from "../components/views/messages/IBodyProps";
import ModuleApi from "../modules/Api";
import { TextualEventViewModel } from "../viewmodels/event-tiles/TextualEventViewModel";
import { TextualEventView } from "../shared-components/event-tiles/TextualEventView";
import { ElementCallEventType } from "../call-types";
// Subset of EventTile's IProps plus some mixins
export interface EventTileTypeProps
@@ -122,7 +122,7 @@ const STATE_EVENT_TILE_TYPES = new Map<string, Factory>([
[EventType.RoomGuestAccess, TextualEventFactory],
]);
for (const evType of ElementCall.CALL_EVENT_TYPE.names) {
for (const evType of ElementCallEventType.names) {
STATE_EVENT_TILE_TYPES.set(evType, CallEventFactory);
}
@@ -444,9 +444,7 @@ export function haveRendererForEvent(
const dynamicPredecessorsEnabled = SettingsStore.getValue("feature_dynamic_room_predecessors");
const predecessor = matrixClient.getRoom(mxEvent.getRoomId())?.findPredecessor(dynamicPredecessorsEnabled);
return Boolean(predecessor);
} else if (
ElementCall.CALL_EVENT_TYPE.names.some((eventType) => handler === STATE_EVENT_TILE_TYPES.get(eventType))
) {
} else if (ElementCallEventType.names.some((eventType) => handler === STATE_EVENT_TILE_TYPES.get(eventType))) {
const intent = mxEvent.getContent()["m.intent"];
const newlyStarted = Object.keys(mxEvent.getPrevContent()).length === 0;
// Only interested in events that mark the start of a non-room call
+3 -2
View File
@@ -18,7 +18,7 @@ import { useWidgets } from "../../utils/WidgetUtils";
import { WidgetType } from "../../widgets/WidgetType";
import { useCall, useConnectionState, useParticipantCount } from "../useCall";
import { useRoomMemberCount } from "../useRoomMembers";
import { ConnectionState, ElementCall } from "../../models/Call";
import { ConnectionState } from "../../models/Call";
import { placeCall } from "../../utils/room/placeCall";
import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
import { useRoomState } from "../useRoomState";
@@ -36,6 +36,7 @@ import { useGuestAccessInformation } from "./useGuestAccessInformation";
import { UIFeature } from "../../settings/UIFeature";
import { BetaPill } from "../../components/views/beta/BetaCard";
import { type InteractionName } from "../../PosthogTrackers";
import { ElementCallMemberEventType } from "../../call-types";
export enum PlatformCallType {
ElementCall,
@@ -135,7 +136,7 @@ export const useRoomCall = (
const [mayEditWidgets, mayCreateElementCalls] = useRoomState(room, () => [
room.currentState.mayClientSendStateEvent("im.vector.modular.widgets", room.client),
room.currentState.mayClientSendStateEvent(ElementCall.MEMBER_EVENT_TYPE.name, room.client),
room.currentState.mayClientSendStateEvent(ElementCallMemberEventType.name, room.client),
]);
// The options provided to the RoomHeader.
+13 -38
View File
@@ -10,7 +10,6 @@ import {
TypedEventEmitter,
RoomEvent,
RoomStateEvent,
EventType,
type MatrixClient,
type IMyDevice,
type Room,
@@ -20,14 +19,12 @@ import { KnownMembership, type Membership } from "matrix-js-sdk/src/types";
import { logger } from "matrix-js-sdk/src/logger";
import { secureRandomString } from "matrix-js-sdk/src/randomstring";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { NamespacedValue } from "matrix-js-sdk/src/NamespacedValue";
import { type IWidgetApiRequest, type ClientWidgetApi, type IWidgetData } from "matrix-widget-api";
import {
MatrixRTCSession,
type MatrixRTCSession,
MatrixRTCSessionEvent,
type CallMembership,
MatrixRTCSessionManagerEvents,
type ICallNotifyContent,
} from "matrix-js-sdk/src/matrixrtc";
import type EventEmitter from "events";
@@ -44,11 +41,12 @@ import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../stores/ActiveWidge
import { getCurrentLanguage } from "../languageHandler";
import { Anonymity, PosthogAnalytics } from "../PosthogAnalytics";
import { UPDATE_EVENT } from "../stores/AsyncStore";
import { getJoinedNonFunctionalMembers } from "../utils/room/getJoinedNonFunctionalMembers";
import { isVideoRoom } from "../utils/video-rooms";
import { FontWatcher } from "../settings/watchers/FontWatcher";
import { type JitsiCallMemberContent, JitsiCallMemberEventType } from "../call-types";
import SdkConfig from "../SdkConfig.ts";
import RoomListStore from "../stores/room-list/RoomListStore.ts";
import { DefaultTagID } from "../stores/room-list/models.ts";
const TIMEOUT_MS = 16000;
@@ -643,10 +641,6 @@ export class JitsiCall extends Call {
* (somewhat cheekily named)
*/
export class ElementCall extends Call {
// TODO this is only there to support backwards compatibility in timeline rendering
// this should not be part of this class since it has nothing to do with it.
public static readonly CALL_EVENT_TYPE = new NamespacedValue(null, EventType.GroupCallPrefix);
public static readonly MEMBER_EVENT_TYPE = new NamespacedValue(null, EventType.GroupCallMemberPrefix);
public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
private settingsStoreCallEncryptionWatcher?: string;
@@ -684,6 +678,14 @@ export class ElementCall extends Call {
theme: "$org.matrix.msc2873.client_theme",
});
const room = client.getRoom(roomId);
if (room !== null && !isVideoRoom(room)) {
params.append(
"sendNotificationType",
RoomListStore.instance.getTagsForRoom(room).includes(DefaultTagID.DM) ? "ring" : "notification",
);
}
const rageshakeSubmitUrl = SdkConfig.get("bug_report_endpoint_url");
if (rageshakeSubmitUrl) {
params.append("rageshakeSubmitUrl", rageshakeSubmitUrl);
@@ -858,31 +860,6 @@ export class ElementCall extends Call {
ElementCall.createOrGetCallWidget(room.roomId, room.client, skipLobby, isVideoRoom(room));
}
protected async sendCallNotify(): Promise<void> {
const room = this.room;
const existingOtherRoomCallMembers = MatrixRTCSession.callMembershipsForRoom(room).filter(
// filter all memberships where the application is m.call and the call_id is ""
(m) => {
const isRoomCallMember = m.application === "m.call" && m.callId === "";
const isThisDevice = m.deviceId === this.client.deviceId;
return isRoomCallMember && !isThisDevice;
},
);
const memberCount = getJoinedNonFunctionalMembers(room).length;
if (!isVideoRoom(room) && existingOtherRoomCallMembers.length === 0) {
// send ringing event
const content: ICallNotifyContent = {
"application": "m.call",
"m.mentions": { user_ids: [], room: true },
"notify_type": memberCount == 2 ? "ring" : "notify",
"call_id": "",
};
await room.client.sendEvent(room.roomId, EventType.CallNotify, content);
}
}
protected async performConnection(
audioInput: MediaDeviceInfo | null,
videoInput: MediaDeviceInfo | null,
@@ -891,9 +868,8 @@ export class ElementCall extends Call {
this.messaging!.once(`action:${ElementWidgetActions.Close}`, this.onClose);
this.messaging!.on(`action:${ElementWidgetActions.DeviceMute}`, this.onDeviceMute);
// TODO: if the widget informs us when the join button is clicked (widget action), so we can
// - set state to connecting
// - send call notify
// TODO: Watch for a widget action telling us that the join button was clicked, rather than
// relying on the MatrixRTC session state, to set the state to connecting
const session = this.client.matrixRTC.getActiveRoomSession(this.room);
if (session) {
await waitForEvent(
@@ -912,7 +888,6 @@ export class ElementCall extends Call {
false, // allow user to wait as long as they want (no timeout)
);
}
this.sendCallNotify();
}
protected async performDisconnection(): Promise<void> {
+2 -2
View File
@@ -20,7 +20,7 @@ import {
import SettingsStore from "../settings/SettingsStore";
import { haveRendererForEvent, JitsiEventFactory, JSONEventFactory, pickFactory } from "../events/EventTileFactory";
import { getMessageModerationState, isLocationEvent, MessageModerationState } from "./EventUtils";
import { ElementCall } from "../models/Call";
import { ElementCallEventType } from "../call-types";
const calcIsInfoMessage = (
eventType: EventType | string,
@@ -82,7 +82,7 @@ export function getEventDisplayInfo(
eventType === EventType.RoomEncryption ||
factory === JitsiEventFactory;
const isLeftAlignedBubbleMessage =
!isBubbleMessage && (eventType === EventType.CallInvite || ElementCall.CALL_EVENT_TYPE.matches(eventType));
!isBubbleMessage && (eventType === EventType.CallInvite || ElementCallEventType.matches(eventType));
let isInfoMessage = calcIsInfoMessage(eventType, content, isBubbleMessage, isLeftAlignedBubbleMessage);
// Some non-info messages want to be rendered in the appropriate bubble column but without the bubble background
const noBubbleEvent =