Refactor reactions / hand raised to use rxjs and start ordering tiles based on hand raised. (#2885)

* Add support for using CallViewModel for reactions sounds.

* Drop setting

* Convert reaction sounds to call view model / rxjs

* Use call view model for hand raised reactions

* Support raising reactions for matrix rtc members.

* Tie up last bits of useReactions

* linting

* Update calleventaudiorenderer

* Update reaction audio renderer

* more test bits

* All the test bits and pieces

* More refactors

* Refactor reactions into a sender and receiver.

* Fixup reaction toggle button

* Adapt reactions test

* Tests all pass.

* lint

* fix a couple of bugs

* remove unused helper file

* lint

* finnish notation

* Add tests for useReactionsReader

* remove mistaken vitest file

* fix

* filter

* invert

* fixup tests with fake timers

* Port useReactionsReader hook to ReactionsReader class.

* lint

* exclude some files from coverage

* Add screen share sound effect.

* cancel sub on destroy

* tidy tidy
This commit is contained in:
Will Hunt
2024-12-19 15:54:28 +00:00
committed by GitHub
parent 7d00f85abc
commit abf2ecd521
28 changed files with 1835 additions and 1184 deletions

View File

@@ -0,0 +1,515 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { renderHook } from "@testing-library/react";
import { afterEach, test, vitest } from "vitest";
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
import {
RoomEvent as MatrixRoomEvent,
MatrixEvent,
type IRoomTimelineData,
EventType,
MatrixEventEvent,
} from "matrix-js-sdk/src/matrix";
import { ReactionsReader, REACTION_ACTIVE_TIME_MS } from "./ReactionsReader";
import {
alice,
aliceRtcMember,
local,
localRtcMember,
} from "../utils/test-fixtures";
import { getBasicRTCSession } from "../utils/test-viewmodel";
import { withTestScheduler } from "../utils/test";
import { ElementCallReactionEventType, ReactionSet } from ".";
afterEach(() => {
vitest.useRealTimers();
});
test("handles a hand raised reaction", () => {
const { rtcSession } = getBasicRTCSession([local, alice]);
const reactionEventId = "$my_event_id:example.org";
const localTimestamp = new Date();
withTestScheduler(({ schedule, expectObservable }) => {
renderHook(() => {
const { raisedHands$ } = new ReactionsReader(
rtcSession as unknown as MatrixRTCSession,
);
schedule("ab", {
a: () => {},
b: () => {
rtcSession.room.emit(
MatrixRoomEvent.Timeline,
new MatrixEvent({
room_id: rtcSession.room.roomId,
event_id: reactionEventId,
sender: localRtcMember.sender,
type: EventType.Reaction,
origin_server_ts: localTimestamp.getTime(),
content: {
"m.relates_to": {
event_id: localRtcMember.eventId,
key: "🖐️",
},
},
}),
rtcSession.room,
undefined,
false,
{} as IRoomTimelineData,
);
},
});
expectObservable(raisedHands$).toBe("ab", {
a: {},
b: {
[`${localRtcMember.sender}:${localRtcMember.deviceId}`]: {
reactionEventId,
membershipEventId: localRtcMember.eventId,
time: localTimestamp,
},
},
});
});
});
});
test("handles a redaction", () => {
const { rtcSession } = getBasicRTCSession([local, alice]);
const reactionEventId = "$my_event_id:example.org";
const localTimestamp = new Date();
withTestScheduler(({ schedule, expectObservable }) => {
renderHook(() => {
const { raisedHands$ } = new ReactionsReader(
rtcSession as unknown as MatrixRTCSession,
);
schedule("abc", {
a: () => {},
b: () => {
rtcSession.room.emit(
MatrixRoomEvent.Timeline,
new MatrixEvent({
room_id: rtcSession.room.roomId,
event_id: reactionEventId,
sender: localRtcMember.sender,
type: EventType.Reaction,
origin_server_ts: localTimestamp.getTime(),
content: {
"m.relates_to": {
event_id: localRtcMember.eventId,
key: "🖐️",
},
},
}),
rtcSession.room,
undefined,
false,
{} as IRoomTimelineData,
);
},
c: () => {
rtcSession.room.emit(
MatrixRoomEvent.Redaction,
new MatrixEvent({
room_id: rtcSession.room.roomId,
event_id: reactionEventId,
sender: localRtcMember.sender,
type: EventType.RoomRedaction,
redacts: reactionEventId,
}),
rtcSession.room,
undefined,
);
},
});
expectObservable(raisedHands$).toBe("abc", {
a: {},
b: {
[`${localRtcMember.sender}:${localRtcMember.deviceId}`]: {
reactionEventId,
membershipEventId: localRtcMember.eventId,
time: localTimestamp,
},
},
c: {},
});
});
});
});
test("handles waiting for event decryption", () => {
const { rtcSession } = getBasicRTCSession([local, alice]);
const reactionEventId = "$my_event_id:example.org";
const localTimestamp = new Date();
withTestScheduler(({ schedule, expectObservable }) => {
renderHook(() => {
const { raisedHands$ } = new ReactionsReader(
rtcSession as unknown as MatrixRTCSession,
);
schedule("abc", {
a: () => {},
b: () => {
const encryptedEvent = new MatrixEvent({
room_id: rtcSession.room.roomId,
event_id: reactionEventId,
sender: localRtcMember.sender,
type: EventType.Reaction,
origin_server_ts: localTimestamp.getTime(),
content: {
"m.relates_to": {
event_id: localRtcMember.eventId,
key: "🖐️",
},
},
});
// Should ignore encrypted events that are still encrypting
encryptedEvent["decryptionPromise"] = Promise.resolve();
rtcSession.room.emit(
MatrixRoomEvent.Timeline,
encryptedEvent,
rtcSession.room,
undefined,
false,
{} as IRoomTimelineData,
);
},
c: () => {
rtcSession.room.client.emit(
MatrixEventEvent.Decrypted,
new MatrixEvent({
room_id: rtcSession.room.roomId,
event_id: reactionEventId,
sender: localRtcMember.sender,
type: EventType.Reaction,
origin_server_ts: localTimestamp.getTime(),
content: {
"m.relates_to": {
event_id: localRtcMember.eventId,
key: "🖐️",
},
},
}),
);
},
});
expectObservable(raisedHands$).toBe("a-c", {
a: {},
c: {
[`${localRtcMember.sender}:${localRtcMember.deviceId}`]: {
reactionEventId,
membershipEventId: localRtcMember.eventId,
time: localTimestamp,
},
},
});
});
});
});
test("hands rejecting events without a proper membership", () => {
const { rtcSession } = getBasicRTCSession([local, alice]);
const reactionEventId = "$my_event_id:example.org";
const localTimestamp = new Date();
withTestScheduler(({ schedule, expectObservable }) => {
renderHook(() => {
const { raisedHands$ } = new ReactionsReader(
rtcSession as unknown as MatrixRTCSession,
);
schedule("ab", {
a: () => {},
b: () => {
rtcSession.room.emit(
MatrixRoomEvent.Timeline,
new MatrixEvent({
room_id: rtcSession.room.roomId,
event_id: reactionEventId,
sender: localRtcMember.sender,
type: EventType.Reaction,
origin_server_ts: localTimestamp.getTime(),
content: {
"m.relates_to": {
event_id: "$not-this-one:example.org",
key: "🖐️",
},
},
}),
rtcSession.room,
undefined,
false,
{} as IRoomTimelineData,
);
},
});
expectObservable(raisedHands$).toBe("a-", {
a: {},
});
});
});
});
test("handles a reaction", () => {
const { rtcSession } = getBasicRTCSession([local, alice]);
const reactionEventId = "$my_event_id:example.org";
const reaction = ReactionSet[1];
vitest.useFakeTimers();
vitest.setSystemTime(0);
withTestScheduler(({ schedule, time, expectObservable }) => {
renderHook(() => {
const { reactions$ } = new ReactionsReader(
rtcSession as unknown as MatrixRTCSession,
);
schedule(`abc`, {
a: () => {},
b: () => {
rtcSession.room.emit(
MatrixRoomEvent.Timeline,
new MatrixEvent({
room_id: rtcSession.room.roomId,
event_id: reactionEventId,
sender: localRtcMember.sender,
type: ElementCallReactionEventType,
content: {
emoji: reaction.emoji,
name: reaction.name,
"m.relates_to": {
event_id: localRtcMember.eventId,
},
},
}),
rtcSession.room,
undefined,
false,
{} as IRoomTimelineData,
);
},
c: () => {
vitest.advanceTimersByTime(REACTION_ACTIVE_TIME_MS);
},
});
expectObservable(reactions$).toBe(
`ab ${REACTION_ACTIVE_TIME_MS - 1}ms c`,
{
a: {},
b: {
[`${localRtcMember.sender}:${localRtcMember.deviceId}`]: {
reactionOption: reaction,
expireAfter: new Date(REACTION_ACTIVE_TIME_MS),
},
},
// Expect reaction to expire.
c: {},
},
);
});
});
});
test("ignores bad reaction events", () => {
const { rtcSession } = getBasicRTCSession([local, alice]);
const reactionEventId = "$my_event_id:example.org";
const reaction = ReactionSet[1];
vitest.setSystemTime(0);
withTestScheduler(({ schedule, expectObservable }) => {
renderHook(() => {
const { reactions$ } = new ReactionsReader(
rtcSession as unknown as MatrixRTCSession,
);
schedule("ab", {
a: () => {},
b: () => {
// Missing content
rtcSession.room.emit(
MatrixRoomEvent.Timeline,
new MatrixEvent({
room_id: rtcSession.room.roomId,
event_id: reactionEventId,
sender: localRtcMember.sender,
type: ElementCallReactionEventType,
content: {},
}),
rtcSession.room,
undefined,
false,
{} as IRoomTimelineData,
);
// Wrong relates event
rtcSession.room.emit(
MatrixRoomEvent.Timeline,
new MatrixEvent({
room_id: rtcSession.room.roomId,
event_id: reactionEventId,
sender: localRtcMember.sender,
type: ElementCallReactionEventType,
content: {
emoji: reaction.emoji,
name: reaction.name,
"m.relates_to": {
event_id: "wrong-event",
},
},
}),
rtcSession.room,
undefined,
false,
{} as IRoomTimelineData,
);
// Wrong rtc member event
rtcSession.room.emit(
MatrixRoomEvent.Timeline,
new MatrixEvent({
room_id: rtcSession.room.roomId,
event_id: reactionEventId,
sender: aliceRtcMember.sender,
type: ElementCallReactionEventType,
content: {
emoji: reaction.emoji,
name: reaction.name,
"m.relates_to": {
event_id: localRtcMember.eventId,
},
},
}),
rtcSession.room,
undefined,
false,
{} as IRoomTimelineData,
);
// No emoji
rtcSession.room.emit(
MatrixRoomEvent.Timeline,
new MatrixEvent({
room_id: rtcSession.room.roomId,
event_id: reactionEventId,
sender: localRtcMember.sender,
type: ElementCallReactionEventType,
content: {
name: reaction.name,
"m.relates_to": {
event_id: localRtcMember.eventId,
},
},
}),
rtcSession.room,
undefined,
false,
{} as IRoomTimelineData,
);
// Invalid emoji
rtcSession.room.emit(
MatrixRoomEvent.Timeline,
new MatrixEvent({
room_id: rtcSession.room.roomId,
event_id: reactionEventId,
sender: localRtcMember.sender,
type: ElementCallReactionEventType,
content: {
emoji: " ",
name: reaction.name,
"m.relates_to": {
event_id: localRtcMember.eventId,
},
},
}),
rtcSession.room,
undefined,
false,
{} as IRoomTimelineData,
);
},
});
expectObservable(reactions$).toBe("a-", {
a: {},
});
});
});
});
test("that reactions cannot be spammed", () => {
const { rtcSession } = getBasicRTCSession([local, alice]);
const reactionEventId = "$my_event_id:example.org";
const reactionA = ReactionSet[1];
const reactionB = ReactionSet[2];
vitest.useFakeTimers();
vitest.setSystemTime(0);
withTestScheduler(({ schedule, expectObservable }) => {
renderHook(() => {
const { reactions$ } = new ReactionsReader(
rtcSession as unknown as MatrixRTCSession,
);
schedule("abcd", {
a: () => {},
b: () => {
rtcSession.room.emit(
MatrixRoomEvent.Timeline,
new MatrixEvent({
room_id: rtcSession.room.roomId,
event_id: reactionEventId,
sender: localRtcMember.sender,
type: ElementCallReactionEventType,
content: {
emoji: reactionA.emoji,
name: reactionA.name,
"m.relates_to": {
event_id: localRtcMember.eventId,
},
},
}),
rtcSession.room,
undefined,
false,
{} as IRoomTimelineData,
);
},
c: () => {
rtcSession.room.emit(
MatrixRoomEvent.Timeline,
new MatrixEvent({
room_id: rtcSession.room.roomId,
event_id: reactionEventId,
sender: localRtcMember.sender,
type: ElementCallReactionEventType,
content: {
emoji: reactionB.emoji,
name: reactionB.name,
"m.relates_to": {
event_id: localRtcMember.eventId,
},
},
}),
rtcSession.room,
undefined,
false,
{} as IRoomTimelineData,
);
},
d: () => {
vitest.advanceTimersByTime(REACTION_ACTIVE_TIME_MS);
},
});
expectObservable(reactions$).toBe(
`ab- ${REACTION_ACTIVE_TIME_MS - 2}ms d`,
{
a: {},
b: {
[`${localRtcMember.sender}:${localRtcMember.deviceId}`]: {
reactionOption: reactionA,
expireAfter: new Date(REACTION_ACTIVE_TIME_MS),
},
},
d: {},
},
);
});
});
});

View File

@@ -0,0 +1,339 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import {
type CallMembership,
MatrixRTCSessionEvent,
type MatrixRTCSession,
} from "matrix-js-sdk/src/matrixrtc";
import { logger } from "matrix-js-sdk/src/logger";
import { type MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
import { type ReactionEventContent } from "matrix-js-sdk/src/types";
import {
RelationType,
EventType,
RoomEvent as MatrixRoomEvent,
} from "matrix-js-sdk/src/matrix";
import { BehaviorSubject, delay, type Subscription } from "rxjs";
import {
ElementCallReactionEventType,
type ECallReactionEventContent,
GenericReaction,
ReactionSet,
type RaisedHandInfo,
type ReactionInfo,
} from ".";
export const REACTION_ACTIVE_TIME_MS = 3000;
/**
* Listens for reactions from a RTCSession and populates subjects
* for consumption by the CallViewModel.
* @param rtcSession
*/
export class ReactionsReader {
private readonly raisedHandsSubject$ = new BehaviorSubject<
Record<string, RaisedHandInfo>
>({});
private readonly reactionsSubject$ = new BehaviorSubject<
Record<string, ReactionInfo>
>({});
/**
* The latest set of raised hands.
*/
public readonly raisedHands$ = this.raisedHandsSubject$.asObservable();
/**
* The latest set of reactions.
*/
public readonly reactions$ = this.reactionsSubject$.asObservable();
private readonly reactionsSub: Subscription;
public constructor(private readonly rtcSession: MatrixRTCSession) {
// Hide reactions after a given time.
this.reactionsSub = this.reactionsSubject$
.pipe(delay(REACTION_ACTIVE_TIME_MS))
.subscribe((reactions) => {
const date = new Date();
const nextEntries = Object.fromEntries(
Object.entries(reactions).filter(([_, hr]) => hr.expireAfter > date),
);
if (Object.keys(reactions).length === Object.keys(nextEntries).length) {
return;
}
this.reactionsSubject$.next(nextEntries);
});
this.rtcSession.room.on(MatrixRoomEvent.Timeline, this.handleReactionEvent);
this.rtcSession.room.on(
MatrixRoomEvent.Redaction,
this.handleReactionEvent,
);
this.rtcSession.room.client.on(
MatrixEventEvent.Decrypted,
this.handleReactionEvent,
);
// We listen for a local echo to get the real event ID, as timeline events
// may still be sending.
this.rtcSession.room.on(
MatrixRoomEvent.LocalEchoUpdated,
this.handleReactionEvent,
);
rtcSession.on(
MatrixRTCSessionEvent.MembershipsChanged,
this.onMembershipsChanged,
);
// Run this once to ensure we have fetched the state from the call.
this.onMembershipsChanged([]);
}
/**
* Fetchest any hand wave reactions by the given sender on the given
* membership event.
* @param membershipEventId
* @param expectedSender
* @returns A MatrixEvent if one was found.
*/
private getLastReactionEvent(
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 === "🖐️",
);
}
/**
* Will remove any hand raises by old members, and look for any
* existing hand raises by new members.
* @param oldMemberships Any members who have left the call.
*/
private onMembershipsChanged = (oldMemberships: CallMembership[]): void => {
// Remove any raised hands for users no longer joined to the call.
for (const identifier of Object.keys(this.raisedHandsSubject$.value).filter(
(rhId) => oldMemberships.find((u) => u.sender == rhId),
)) {
this.removeRaisedHand(identifier);
}
// For each member in the call, check to see if a reaction has
// been raised and adjust.
for (const m of this.rtcSession.memberships) {
if (!m.sender || !m.eventId) {
continue;
}
const identifier = `${m.sender}:${m.deviceId}`;
if (
this.raisedHandsSubject$.value[identifier] &&
this.raisedHandsSubject$.value[identifier].membershipEventId !==
m.eventId
) {
// Membership event for sender has changed since the hand
// was raised, reset.
this.removeRaisedHand(identifier);
}
const reaction = this.getLastReactionEvent(m.eventId, m.sender);
if (reaction) {
const eventId = reaction?.getId();
if (!eventId) {
continue;
}
this.addRaisedHand(`${m.sender}:${m.deviceId}`, {
membershipEventId: m.eventId,
reactionEventId: eventId,
time: new Date(reaction.localTimestamp),
});
}
}
};
/**
* Add a raised hand
* @param identifier A userId:deviceId combination.
* @param info The event information.
*/
private addRaisedHand(identifier: string, info: RaisedHandInfo): void {
this.raisedHandsSubject$.next({
...this.raisedHandsSubject$.value,
[identifier]: info,
});
}
/**
* Remove a raised hand
* @param identifier A userId:deviceId combination.
*/
private removeRaisedHand(identifier: string): void {
this.raisedHandsSubject$.next(
Object.fromEntries(
Object.entries(this.raisedHandsSubject$.value).filter(
([uId]) => uId !== identifier,
),
),
);
}
/**
* Handle a new reaction event, validating it's contents and potentially
* updating the hand raise or reaction observers.
* @param event The incoming matrix event, which may or may not be decrypted.
*/
private handleReactionEvent = (event: MatrixEvent): void => {
const room = this.rtcSession.room;
// Decrypted events might come from a different room
if (event.getRoomId() !== room.roomId) return;
// Skip any events that are still sending.
if (event.isSending()) return;
const sender = event.getSender();
const reactionEventId = event.getId();
// Skip any event without a sender or event ID.
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() === ElementCallReactionEventType) {
const content: ECallReactionEventContent = event.getContent();
const membershipEventId = content?.["m.relates_to"]?.event_id;
const membershipEvent = this.rtcSession.memberships.find(
(e) => e.eventId === membershipEventId && e.sender === sender,
);
// Check to see if this reaction was made to a membership event (and the
// sender of the reaction matches the membership)
if (!membershipEvent) {
logger.warn(
`Reaction target was not a membership event for ${sender}, ignoring`,
);
return;
}
const identifier = `${membershipEvent.sender}:${membershipEvent.deviceId}`;
if (!content.emoji) {
logger.warn(`Reaction had no emoji from ${reactionEventId}`);
return;
}
const segment = new Intl.Segmenter(undefined, {
granularity: "grapheme",
})
.segment(content.emoji)
[Symbol.iterator]();
const emoji = segment.next().value?.segment;
if (!emoji?.trim()) {
logger.warn(
`Reaction had no emoji from ${reactionEventId} after splitting`,
);
return;
}
// One of our custom reactions
const reaction = {
...GenericReaction,
emoji,
// If we don't find a reaction, we can fallback to the generic sound.
...ReactionSet.find((r) => r.name === content.name),
};
const currentReactions = this.reactionsSubject$.value;
if (currentReactions[identifier]) {
// We've still got a reaction from this user, ignore it to prevent spamming
logger.warn(`Got reaction from ${identifier} but one is still playing`);
return;
}
this.reactionsSubject$.next({
...currentReactions,
[identifier]: {
reactionOption: reaction,
expireAfter: new Date(Date.now() + REACTION_ACTIVE_TIME_MS),
},
});
} else if (event.getType() === EventType.Reaction) {
const content = event.getContent() as ReactionEventContent;
const membershipEventId = content["m.relates_to"].event_id;
// Check to see if this reaction was made to a membership event (and the
// sender of the reaction matches the membership)
const membershipEvent = this.rtcSession.memberships.find(
(e) => e.eventId === membershipEventId && e.sender === sender,
);
if (!membershipEvent) {
logger.warn(
`Reaction target was not a membership event for ${sender}, ignoring`,
);
return;
}
if (content?.["m.relates_to"].key === "🖐️") {
this.addRaisedHand(
`${membershipEvent.sender}:${membershipEvent.deviceId}`,
{
reactionEventId,
membershipEventId,
time: new Date(event.localTimestamp),
},
);
}
} else if (event.getType() === EventType.RoomRedaction) {
const targetEvent = event.event.redacts;
const targetUser = Object.entries(this.raisedHandsSubject$.value).find(
([_u, r]) => r.reactionEventId === targetEvent,
)?.[0];
if (!targetUser) {
// Reaction target was not for us, ignoring
return;
}
this.removeRaisedHand(targetUser);
}
};
/**
* Stop listening for events.
*/
public destroy(): void {
this.rtcSession.off(
MatrixRTCSessionEvent.MembershipsChanged,
this.onMembershipsChanged,
);
this.rtcSession.room.off(
MatrixRoomEvent.Timeline,
this.handleReactionEvent,
);
this.rtcSession.room.off(
MatrixRoomEvent.Redaction,
this.handleReactionEvent,
);
this.rtcSession.room.client.off(
MatrixEventEvent.Decrypted,
this.handleReactionEvent,
);
this.rtcSession.room.off(
MatrixRoomEvent.LocalEchoUpdated,
this.handleReactionEvent,
);
this.reactionsSub.unsubscribe();
}
}

View File

@@ -181,3 +181,23 @@ export const ReactionSet: ReactionOption[] = [
},
},
];
export interface RaisedHandInfo {
/**
* Call membership event that was reacted to.
*/
membershipEventId: string;
/**
* Event ID of the reaction itself.
*/
reactionEventId: string;
/**
* The time when the reaction was raised.
*/
time: Date;
}
export interface ReactionInfo {
expireAfter: Date;
reactionOption: ReactionOption;
}

View File

@@ -0,0 +1,174 @@
/*
Copyright 2024 Milton Moura <miltonmoura@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { EventType, RelationType } from "matrix-js-sdk/src/matrix";
import {
createContext,
useContext,
type ReactNode,
useCallback,
useMemo,
} from "react";
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { logger } from "matrix-js-sdk/src/logger";
import { useObservableEagerState } from "observable-hooks";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import { useClientState } from "../ClientContext";
import { ElementCallReactionEventType, type ReactionOption } from ".";
import { type CallViewModel } from "../state/CallViewModel";
interface ReactionsSenderContextType {
supportsReactions: boolean;
toggleRaisedHand: () => Promise<void>;
sendReaction: (reaction: ReactionOption) => Promise<void>;
}
const ReactionsSenderContext = createContext<
ReactionsSenderContextType | undefined
>(undefined);
export const useReactionsSender = (): ReactionsSenderContextType => {
const context = useContext(ReactionsSenderContext);
if (!context) {
throw new Error("useReactions must be used within a ReactionsProvider");
}
return context;
};
/**
* Provider that handles sending a reaction or hand raised event to a call.
*/
export const ReactionsSenderProvider = ({
children,
rtcSession,
vm,
}: {
children: ReactNode;
rtcSession: MatrixRTCSession;
vm: CallViewModel;
}): JSX.Element => {
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const clientState = useClientState();
const supportsReactions =
clientState?.state === "valid" && clientState.supportedFeatures.reactions;
const room = rtcSession.room;
const myUserId = room.client.getUserId();
const myDeviceId = room.client.getDeviceId();
const myMembershipEvent = useMemo(
() =>
memberships.find(
(m) => m.sender === myUserId && m.deviceId === myDeviceId,
)?.eventId,
[memberships, myUserId, myDeviceId],
);
const myMembershipIdentifier = useMemo(() => {
const membership = memberships.find((m) => m.sender === myUserId);
return membership
? `${membership.sender}:${membership.deviceId}`
: undefined;
}, [memberships, myUserId]);
const reactions = useObservableEagerState(vm.reactions$);
const myReaction = useMemo(
() =>
myMembershipIdentifier !== undefined
? reactions[myMembershipIdentifier]
: undefined,
[myMembershipIdentifier, reactions],
);
const handsRaised = useObservableEagerState(vm.handsRaised$);
const myRaisedHand = useMemo(
() =>
myMembershipIdentifier !== undefined
? handsRaised[myMembershipIdentifier]
: undefined,
[myMembershipIdentifier, handsRaised],
);
const toggleRaisedHand = useCallback(async () => {
if (!myMembershipIdentifier) {
return;
}
const myReactionId = myRaisedHand?.reactionEventId;
if (!myReactionId) {
try {
if (!myMembershipEvent) {
throw new Error("Cannot find own membership event");
}
const reaction = await room.client.sendEvent(
rtcSession.room.roomId,
EventType.Reaction,
{
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: myMembershipEvent,
key: "🖐️",
},
},
);
logger.debug("Sent raise hand event", reaction.event_id);
} catch (ex) {
logger.error("Failed to send raised hand", ex);
}
} else {
try {
await room.client.redactEvent(rtcSession.room.roomId, myReactionId);
logger.debug("Redacted raise hand event");
} catch (ex) {
logger.error("Failed to redact reaction event", myReactionId, ex);
throw ex;
}
}
}, [
myMembershipEvent,
myMembershipIdentifier,
myRaisedHand,
rtcSession,
room,
]);
const sendReaction = useCallback(
async (reaction: ReactionOption) => {
if (!myMembershipIdentifier || myReaction) {
// We're still reacting
return;
}
if (!myMembershipEvent) {
throw new Error("Cannot find own membership event");
}
await room.client.sendEvent(
rtcSession.room.roomId,
ElementCallReactionEventType,
{
"m.relates_to": {
rel_type: RelationType.Reference,
event_id: myMembershipEvent,
},
emoji: reaction.emoji,
name: reaction.name,
},
);
},
[myMembershipEvent, myReaction, room, myMembershipIdentifier, rtcSession],
);
return (
<ReactionsSenderContext.Provider
value={{
supportsReactions,
toggleRaisedHand,
sendReaction,
}}
>
{children}
</ReactionsSenderContext.Provider>
);
};