Skip to content
Draft
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
3 changes: 3 additions & 0 deletions locales/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"username": "Username",
"video": "Video"
},
"deafen_button_label": "Deafen",
"developer_mode": {
"always_show_iphone_earpiece": "Show iPhone earpiece option on all platforms",
"crypto_version": "Crypto version: {{version}}",
Expand Down Expand Up @@ -245,13 +246,15 @@
"unauthenticated_view_body": "Not registered yet? <2>Create an account</2>",
"unauthenticated_view_login_button": "Login to your account",
"unauthenticated_view_ssla_caption": "By clicking \"Go\", you agree to our <2>Software and Services License Agreement (SSLA)</2>",
"undeafen_button_label": "Undeafen",
"unmute_microphone_button_label": "Unmute microphone",
"version": "{{productName}} version: {{version}}",
"video_tile": {
"always_show": "Always show",
"camera_starting": "Video loading...",
"change_fit_contain": "Fit to frame",
"collapse": "Collapse",
"deafened": "Deafened",
"expand": "Expand",
"mute_for_me": "Mute for me",
"muted_for_me": "Muted for me",
Expand Down
1 change: 1 addition & 0 deletions sdk/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export async function createMatrixRTCSdk(
{ encryptionSystem: { kind: E2eeType.PER_PARTICIPANT } },
of({}),
of({}),
of({}),
constant({ supported: false, processor: undefined }),
);
logger.info("CallViewModelCreated");
Expand Down
29 changes: 29 additions & 0 deletions src/button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
EndCallIcon,
ShareScreenSolidIcon,
SettingsSolidIcon,
HeadphonesSolidIcon,
HeadphonesOffSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";

import styles from "./Button.module.css";
Expand Down Expand Up @@ -45,6 +47,33 @@ export const MicButton: FC<MicButtonProps> = ({ muted, ...props }) => {
);
};

interface DeafenButtonProps extends ComponentPropsWithoutRef<"button"> {
deafened: boolean;
}

export const DeafenButton: FC<DeafenButtonProps> = ({
deafened,
...props
}) => {
const { t } = useTranslation();
const Icon = deafened ? HeadphonesOffSolidIcon : HeadphonesSolidIcon;
const label = deafened
? t("undeafen_button_label")
: t("deafen_button_label");

return (
<Tooltip label={label}>
<CpdButton
iconOnly
aria-label={label}
Icon={Icon}
kind={deafened ? "primary" : "secondary"}
{...props}
/>
</Tooltip>
);
};

interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> {
muted: boolean;
}
Expand Down
211 changes: 211 additions & 0 deletions src/reactions/DeafenReader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/*
Copyright 2026 New Vector Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

import {
type CallMembership,
MatrixRTCSessionEvent,
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger";
import { type MatrixEvent, MatrixEventEvent } from "matrix-js-sdk";
import { type ReactionEventContent } from "matrix-js-sdk/lib/types";
import {
RelationType,
EventType,
RoomEvent as MatrixRoomEvent,
} from "matrix-js-sdk";
import { BehaviorSubject } from "rxjs";

import { ElementCallDeafenedKey, type DeafenedInfo } from ".";
import { type ObservableScope } from "../state/ObservableScope";

/**
* Listens for deafen state reactions from an RTCSession and populates a subject
* for consumption by the CallViewModel.
*/
export class DeafenReader {
private readonly deafenedSubject$ = new BehaviorSubject<
Record<string, DeafenedInfo>
>({});

/**
* The latest set of deafened users.
*/
public readonly deafened$ = this.deafenedSubject$.asObservable();

public constructor(
private readonly scope: ObservableScope,
private readonly rtcSession: MatrixRTCSession,
) {
this.rtcSession.room.on(MatrixRoomEvent.Timeline, this.handleEvent);
this.scope.onEnd(() =>
this.rtcSession.room.off(MatrixRoomEvent.Timeline, this.handleEvent),
);

this.rtcSession.room.on(MatrixRoomEvent.Redaction, this.handleEvent);
this.scope.onEnd(() =>
this.rtcSession.room.off(MatrixRoomEvent.Redaction, this.handleEvent),
);

this.rtcSession.room.client.on(
MatrixEventEvent.Decrypted,
this.handleEvent,
);
this.scope.onEnd(() =>
this.rtcSession.room.client.off(
MatrixEventEvent.Decrypted,
this.handleEvent,
),
);

this.rtcSession.room.on(
MatrixRoomEvent.LocalEchoUpdated,
this.handleEvent,
);
this.scope.onEnd(() =>
this.rtcSession.room.off(
MatrixRoomEvent.LocalEchoUpdated,
this.handleEvent,
),
);

this.rtcSession.on(
MatrixRTCSessionEvent.MembershipsChanged,
this.onMembershipsChanged,
);
this.scope.onEnd(() =>
this.rtcSession.off(
MatrixRTCSessionEvent.MembershipsChanged,
this.onMembershipsChanged,
),
);

// Run this once to ensure we have fetched the state from the call.
this.onMembershipsChanged([]);
}

/**
* Fetches any deafen reactions by the given sender on the given
* membership event.
*/
private getLastDeafenEvent(
membershipEventId: string,
expectedSender: string,
): MatrixEvent | undefined {
const relations = this.rtcSession.room.relations.getChildEventsForEvent(
membershipEventId,
RelationType.Annotation,
EventType.Reaction,
);
const allEvents = relations?.getRelations() ?? [];
return allEvents.find(
(reaction) =>
reaction.event.sender === expectedSender &&
reaction.getType() === EventType.Reaction &&
reaction.getContent()?.["m.relates_to"]?.key ===
ElementCallDeafenedKey,
);
}

private onMembershipsChanged = (oldMemberships: CallMembership[]): void => {
// Remove deafen state for users no longer joined to the call.
for (const identifier of Object.keys(this.deafenedSubject$.value).filter(
(id) => oldMemberships.find((u) => u.userId == id),
)) {
this.removeDeafened(identifier);
}

// For each member in the call, check to see if a deafen reaction exists.
for (const m of this.rtcSession.memberships) {
if (!m.userId || !m.eventId) {
continue;
}
const identifier = `${m.userId}:${m.deviceId}`;
if (
this.deafenedSubject$.value[identifier] &&
this.deafenedSubject$.value[identifier].membershipEventId !== m.eventId
) {
// Membership event for sender has changed since deafen was set, reset.
this.removeDeafened(identifier);
}
const reaction = this.getLastDeafenEvent(m.eventId, m.userId);
if (reaction) {
const eventId = reaction?.getId();
if (!eventId) {
continue;
}
this.addDeafened(`${m.userId}:${m.deviceId}`, {
membershipEventId: m.eventId,
reactionEventId: eventId,
});
}
}
};

private addDeafened(identifier: string, info: DeafenedInfo): void {
this.deafenedSubject$.next({
...this.deafenedSubject$.value,
[identifier]: info,
});
}

private removeDeafened(identifier: string): void {
this.deafenedSubject$.next(
Object.fromEntries(
Object.entries(this.deafenedSubject$.value).filter(
([uId]) => uId !== identifier,
),
),
);
}

private handleEvent = (event: MatrixEvent): void => {
const room = this.rtcSession.room;
if (event.getRoomId() !== room.roomId) return;
if (event.isSending()) return;

const sender = event.getSender();
const reactionEventId = event.getId();
if (!sender || !reactionEventId) return;

room.client
.decryptEventIfNeeded(event)
.catch((e) => logger.warn(`Failed to decrypt ${event.getId()}`, e));
if (event.isBeingDecrypted() || event.isDecryptionFailure()) return;

if (event.getType() === EventType.Reaction) {
const content = event.getContent() as ReactionEventContent;
const membershipEventId = content["m.relates_to"].event_id;

const membershipEvent = this.rtcSession.memberships.find(
(e) => e.eventId === membershipEventId && e.userId === sender,
);
if (!membershipEvent) {
return;
}

if (content?.["m.relates_to"].key === ElementCallDeafenedKey) {
this.addDeafened(
`${membershipEvent.userId}:${membershipEvent.deviceId}`,
{
reactionEventId,
membershipEventId,
},
);
}
} else if (event.getType() === EventType.RoomRedaction) {
const targetEvent = event.event.redacts;
const targetUser = Object.entries(this.deafenedSubject$.value).find(
([_u, r]) => r.reactionEventId === targetEvent,
)?.[0];
if (!targetUser) {
return;
}
this.removeDeafened(targetUser);
}
};
}
13 changes: 13 additions & 0 deletions src/reactions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,19 @@ export const ReactionSet: ReactionOption[] = [
},
];

export const ElementCallDeafenedKey = "io.element.call.deafened";

export interface DeafenedInfo {
/**
* Call membership event that was reacted to.
*/
membershipEventId: string;
/**
* Event ID of the reaction itself.
*/
reactionEventId: string;
}

export interface RaisedHandInfo {
/**
* Call membership event that was reacted to.
Expand Down
Loading
Loading