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:
515
src/reactions/ReactionsReader.test.tsx
Normal file
515
src/reactions/ReactionsReader.test.tsx
Normal 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: {},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
339
src/reactions/ReactionsReader.ts
Normal file
339
src/reactions/ReactionsReader.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
174
src/reactions/useReactionsSender.tsx
Normal file
174
src/reactions/useReactionsSender.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user