mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-08 20:09:19 +00:00
Port useReactionsReader hook to ReactionsReader class.
This commit is contained in:
@@ -16,9 +16,7 @@ import {
|
|||||||
MatrixEventEvent,
|
MatrixEventEvent,
|
||||||
} from "matrix-js-sdk/src/matrix";
|
} from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
import useReactionsReader, {
|
import { ReactionsReader, REACTION_ACTIVE_TIME_MS } from "./ReactionsReader";
|
||||||
REACTION_ACTIVE_TIME_MS,
|
|
||||||
} from "./useReactionsReader";
|
|
||||||
import {
|
import {
|
||||||
alice,
|
alice,
|
||||||
aliceRtcMember,
|
aliceRtcMember,
|
||||||
@@ -39,7 +37,7 @@ test("handles a hand raised reaction", () => {
|
|||||||
const localTimestamp = new Date();
|
const localTimestamp = new Date();
|
||||||
withTestScheduler(({ schedule, expectObservable }) => {
|
withTestScheduler(({ schedule, expectObservable }) => {
|
||||||
renderHook(() => {
|
renderHook(() => {
|
||||||
const { raisedHands$ } = useReactionsReader(
|
const { raisedHands$ } = new ReactionsReader(
|
||||||
rtcSession as unknown as MatrixRTCSession,
|
rtcSession as unknown as MatrixRTCSession,
|
||||||
);
|
);
|
||||||
schedule("ab", {
|
schedule("ab", {
|
||||||
@@ -87,7 +85,7 @@ test("handles a redaction", () => {
|
|||||||
const localTimestamp = new Date();
|
const localTimestamp = new Date();
|
||||||
withTestScheduler(({ schedule, expectObservable }) => {
|
withTestScheduler(({ schedule, expectObservable }) => {
|
||||||
renderHook(() => {
|
renderHook(() => {
|
||||||
const { raisedHands$ } = useReactionsReader(
|
const { raisedHands$ } = new ReactionsReader(
|
||||||
rtcSession as unknown as MatrixRTCSession,
|
rtcSession as unknown as MatrixRTCSession,
|
||||||
);
|
);
|
||||||
schedule("abc", {
|
schedule("abc", {
|
||||||
@@ -150,7 +148,7 @@ test("handles waiting for event decryption", () => {
|
|||||||
const localTimestamp = new Date();
|
const localTimestamp = new Date();
|
||||||
withTestScheduler(({ schedule, expectObservable }) => {
|
withTestScheduler(({ schedule, expectObservable }) => {
|
||||||
renderHook(() => {
|
renderHook(() => {
|
||||||
const { raisedHands$ } = useReactionsReader(
|
const { raisedHands$ } = new ReactionsReader(
|
||||||
rtcSession as unknown as MatrixRTCSession,
|
rtcSession as unknown as MatrixRTCSession,
|
||||||
);
|
);
|
||||||
schedule("abc", {
|
schedule("abc", {
|
||||||
@@ -219,7 +217,7 @@ test("hands rejecting events without a proper membership", () => {
|
|||||||
const localTimestamp = new Date();
|
const localTimestamp = new Date();
|
||||||
withTestScheduler(({ schedule, expectObservable }) => {
|
withTestScheduler(({ schedule, expectObservable }) => {
|
||||||
renderHook(() => {
|
renderHook(() => {
|
||||||
const { raisedHands$ } = useReactionsReader(
|
const { raisedHands$ } = new ReactionsReader(
|
||||||
rtcSession as unknown as MatrixRTCSession,
|
rtcSession as unknown as MatrixRTCSession,
|
||||||
);
|
);
|
||||||
schedule("ab", {
|
schedule("ab", {
|
||||||
@@ -264,7 +262,7 @@ test("handles a reaction", () => {
|
|||||||
|
|
||||||
withTestScheduler(({ schedule, time, expectObservable }) => {
|
withTestScheduler(({ schedule, time, expectObservable }) => {
|
||||||
renderHook(() => {
|
renderHook(() => {
|
||||||
const { reactions$ } = useReactionsReader(
|
const { reactions$ } = new ReactionsReader(
|
||||||
rtcSession as unknown as MatrixRTCSession,
|
rtcSession as unknown as MatrixRTCSession,
|
||||||
);
|
);
|
||||||
schedule(`abc`, {
|
schedule(`abc`, {
|
||||||
@@ -322,7 +320,7 @@ test("ignores bad reaction events", () => {
|
|||||||
|
|
||||||
withTestScheduler(({ schedule, expectObservable }) => {
|
withTestScheduler(({ schedule, expectObservable }) => {
|
||||||
renderHook(() => {
|
renderHook(() => {
|
||||||
const { reactions$ } = useReactionsReader(
|
const { reactions$ } = new ReactionsReader(
|
||||||
rtcSession as unknown as MatrixRTCSession,
|
rtcSession as unknown as MatrixRTCSession,
|
||||||
);
|
);
|
||||||
schedule("ab", {
|
schedule("ab", {
|
||||||
@@ -446,7 +444,7 @@ test("that reactions cannot be spammed", () => {
|
|||||||
|
|
||||||
withTestScheduler(({ schedule, expectObservable }) => {
|
withTestScheduler(({ schedule, expectObservable }) => {
|
||||||
renderHook(() => {
|
renderHook(() => {
|
||||||
const { reactions$ } = useReactionsReader(
|
const { reactions$ } = new ReactionsReader(
|
||||||
rtcSession as unknown as MatrixRTCSession,
|
rtcSession as unknown as MatrixRTCSession,
|
||||||
);
|
);
|
||||||
schedule("abcd", {
|
schedule("abcd", {
|
||||||
336
src/reactions/ReactionsReader.ts
Normal file
336
src/reactions/ReactionsReader.ts
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
/*
|
||||||
|
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 } 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();
|
||||||
|
|
||||||
|
public constructor(private readonly rtcSession: MatrixRTCSession) {
|
||||||
|
// Hide reactions after a given time.
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,288 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright 2024 New Vector Ltd.
|
|
||||||
|
|
||||||
SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
Please see LICENSE in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
|
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
|
||||||
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 Observable } from "rxjs";
|
|
||||||
|
|
||||||
import {
|
|
||||||
ElementCallReactionEventType,
|
|
||||||
type ECallReactionEventContent,
|
|
||||||
GenericReaction,
|
|
||||||
ReactionSet,
|
|
||||||
type RaisedHandInfo,
|
|
||||||
type ReactionInfo,
|
|
||||||
} from ".";
|
|
||||||
import { useLatest } from "../useLatest";
|
|
||||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
|
|
||||||
|
|
||||||
export const REACTION_ACTIVE_TIME_MS = 3000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Listens for reactions from a RTCSession and populates subjects
|
|
||||||
* for consumption by the CallViewModel.
|
|
||||||
* @param rtcSession
|
|
||||||
*/
|
|
||||||
export default function useReactionsReader(rtcSession: MatrixRTCSession): {
|
|
||||||
raisedHands$: Observable<Record<string, RaisedHandInfo>>;
|
|
||||||
reactions$: Observable<Record<string, ReactionInfo>>;
|
|
||||||
} {
|
|
||||||
const raisedHandsSubject$ = useRef(
|
|
||||||
new BehaviorSubject<Record<string, RaisedHandInfo>>({}),
|
|
||||||
);
|
|
||||||
const reactionsSubject$ = useRef(
|
|
||||||
new BehaviorSubject<Record<string, ReactionInfo>>({}),
|
|
||||||
);
|
|
||||||
|
|
||||||
reactionsSubject$.current
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
reactionsSubject$.current.next(nextEntries);
|
|
||||||
});
|
|
||||||
|
|
||||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
|
||||||
const latestMemberships = useLatest(memberships);
|
|
||||||
const latestRaisedHands = useLatest(raisedHandsSubject$.current);
|
|
||||||
const room = rtcSession.room;
|
|
||||||
|
|
||||||
const addRaisedHand = useCallback((userId: string, info: RaisedHandInfo) => {
|
|
||||||
raisedHandsSubject$.current.next({
|
|
||||||
...raisedHandsSubject$.current.value,
|
|
||||||
[userId]: info,
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const removeRaisedHand = useCallback((userId: string) => {
|
|
||||||
raisedHandsSubject$.current.next(
|
|
||||||
Object.fromEntries(
|
|
||||||
Object.entries(raisedHandsSubject$.current.value).filter(
|
|
||||||
([uId]) => uId !== userId,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// This effect will check the state whenever the membership of the session changes.
|
|
||||||
useEffect(() => {
|
|
||||||
// Fetches the first reaction for a given event.
|
|
||||||
const getLastReactionEvent = (
|
|
||||||
eventId: string,
|
|
||||||
expectedSender: string,
|
|
||||||
): MatrixEvent | undefined => {
|
|
||||||
const relations = room.relations.getChildEventsForEvent(
|
|
||||||
eventId,
|
|
||||||
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 === "🖐️",
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Remove any raised hands for users no longer joined to the call.
|
|
||||||
for (const identifier of Object.keys(raisedHandsSubject$).filter(
|
|
||||||
(rhId) => !memberships.find((u) => u.sender == rhId),
|
|
||||||
)) {
|
|
||||||
removeRaisedHand(identifier);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For each member in the call, check to see if a reaction has
|
|
||||||
// been raised and adjust.
|
|
||||||
for (const m of memberships) {
|
|
||||||
if (!m.sender || !m.eventId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const identifier = `${m.sender}:${m.deviceId}`;
|
|
||||||
if (
|
|
||||||
raisedHandsSubject$.current.value[identifier] &&
|
|
||||||
raisedHandsSubject$.current.value[identifier].membershipEventId !==
|
|
||||||
m.eventId
|
|
||||||
) {
|
|
||||||
// Membership event for sender has changed since the hand
|
|
||||||
// was raised, reset.
|
|
||||||
removeRaisedHand(identifier);
|
|
||||||
}
|
|
||||||
const reaction = getLastReactionEvent(m.eventId, m.sender);
|
|
||||||
if (reaction) {
|
|
||||||
const eventId = reaction?.getId();
|
|
||||||
if (!eventId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
addRaisedHand(`${m.sender}:${m.deviceId}`, {
|
|
||||||
membershipEventId: m.eventId,
|
|
||||||
reactionEventId: eventId,
|
|
||||||
time: new Date(reaction.localTimestamp),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Ignoring raisedHands here because we don't want to trigger each time the raised
|
|
||||||
// hands set is updated.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [room, memberships, addRaisedHand, removeRaisedHand]);
|
|
||||||
|
|
||||||
// This effect handles any *live* reaction/redactions in the room.
|
|
||||||
useEffect(() => {
|
|
||||||
const handleReactionEvent = (event: MatrixEvent): void => {
|
|
||||||
// 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 = latestMemberships.current.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 = reactionsSubject$.current.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;
|
|
||||||
}
|
|
||||||
reactionsSubject$.current.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 = latestMemberships.current.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 === "🖐️") {
|
|
||||||
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(latestRaisedHands.current.value).find(
|
|
||||||
([_u, r]) => r.reactionEventId === targetEvent,
|
|
||||||
)?.[0];
|
|
||||||
if (!targetUser) {
|
|
||||||
// Reaction target was not for us, ignoring
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
removeRaisedHand(targetUser);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
room.on(MatrixRoomEvent.Timeline, handleReactionEvent);
|
|
||||||
room.on(MatrixRoomEvent.Redaction, handleReactionEvent);
|
|
||||||
room.client.on(MatrixEventEvent.Decrypted, handleReactionEvent);
|
|
||||||
|
|
||||||
// We listen for a local echo to get the real event ID, as timeline events
|
|
||||||
// may still be sending.
|
|
||||||
room.on(MatrixRoomEvent.LocalEchoUpdated, handleReactionEvent);
|
|
||||||
|
|
||||||
return (): void => {
|
|
||||||
room.off(MatrixRoomEvent.Timeline, handleReactionEvent);
|
|
||||||
room.off(MatrixRoomEvent.Redaction, handleReactionEvent);
|
|
||||||
room.client.off(MatrixEventEvent.Decrypted, handleReactionEvent);
|
|
||||||
room.off(MatrixRoomEvent.LocalEchoUpdated, handleReactionEvent);
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
room,
|
|
||||||
addRaisedHand,
|
|
||||||
removeRaisedHand,
|
|
||||||
latestMemberships,
|
|
||||||
latestRaisedHands,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
reactions$: reactionsSubject$.current.asObservable(),
|
|
||||||
raisedHands$: raisedHandsSubject$.current.asObservable(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -95,8 +95,7 @@ import {
|
|||||||
debugTileLayout as debugTileLayoutSetting,
|
debugTileLayout as debugTileLayoutSetting,
|
||||||
useSetting,
|
useSetting,
|
||||||
} from "../settings/settings";
|
} from "../settings/settings";
|
||||||
import useReactionsReader from "../reactions/useReactionsReader";
|
import { ReactionsReader } from "../reactions/ReactionsReader";
|
||||||
import { useLatest } from "../useLatest";
|
|
||||||
|
|
||||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||||
|
|
||||||
@@ -130,28 +129,24 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const reader = useLatest(useReactionsReader(props.rtcSession));
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (livekitRoom !== undefined && reader !== undefined) {
|
if (livekitRoom !== undefined) {
|
||||||
|
const reactionsReader = new ReactionsReader(props.rtcSession);
|
||||||
const vm = new CallViewModel(
|
const vm = new CallViewModel(
|
||||||
props.rtcSession,
|
props.rtcSession,
|
||||||
livekitRoom,
|
livekitRoom,
|
||||||
props.e2eeSystem,
|
props.e2eeSystem,
|
||||||
connStateObservable$,
|
connStateObservable$,
|
||||||
reader.current.raisedHands$,
|
reactionsReader.raisedHands$,
|
||||||
reader.current.reactions$,
|
reactionsReader.reactions$,
|
||||||
);
|
);
|
||||||
setVm(vm);
|
setVm(vm);
|
||||||
return (): void => vm.destroy();
|
return (): void => {
|
||||||
|
vm.destroy();
|
||||||
|
reactionsReader.destroy();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}, [
|
}, [props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable$]);
|
||||||
props.rtcSession,
|
|
||||||
livekitRoom,
|
|
||||||
props.e2eeSystem,
|
|
||||||
connStateObservable$,
|
|
||||||
reader,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (livekitRoom === undefined || vm === null) return null;
|
if (livekitRoom === undefined || vm === null) return null;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user