Refactor reactions into a sender and receiver.

This commit is contained in:
Half-Shot
2024-12-16 14:18:39 +00:00
parent 0122f85542
commit 7017f610fc
14 changed files with 371 additions and 327 deletions

View File

@@ -18,7 +18,7 @@ import { CallViewModel } from "../state/CallViewModel";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
import { alice, local, localRtcMember } from "../utils/test-fixtures";
import { MockRTCSession } from "../utils/test";
import { ReactionsProvider } from "../useReactions";
import { ReactionsProvider } from "../useReactionsSender";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
const localIdent = `${localRtcMember.sender}:${localRtcMember.deviceId}`;

View File

@@ -27,7 +27,7 @@ import classNames from "classnames";
import { useObservableState } from "observable-hooks";
import { map } from "rxjs";
import { useReactions } from "../useReactions";
import { useReactionsSender } from "../useReactionsSender";
import styles from "./ReactionToggleButton.module.css";
import {
type ReactionOption,
@@ -175,7 +175,7 @@ export function ReactionToggleButton({
...props
}: ReactionToggleButtonProps): ReactNode {
const { t } = useTranslation();
const { toggleRaisedHand, sendReaction } = useReactions();
const { toggleRaisedHand, sendReaction } = useReactionsSender();
const [busy, setBusy] = useState(false);
const [showReactionsMenu, setShowReactionsMenu] = useState(false);
const [errorText, setErrorText] = useState<string>();

View File

@@ -181,3 +181,27 @@ 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 {
/**
* ...
*/
ttl: number;
reactionOption: ReactionOption;
}

View File

@@ -1,146 +1,62 @@
/*
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,
type MatrixEvent,
RelationType,
RoomEvent as MatrixRoomEvent,
MatrixEventEvent,
} from "matrix-js-sdk/src/matrix";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import { useCallback, useEffect, useRef } from "react";
import { useLatest } from "../useLatest";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
import { type ReactionEventContent } from "matrix-js-sdk/src/types";
import {
createContext,
useContext,
useState,
type ReactNode,
useCallback,
useEffect,
useMemo,
} from "react";
import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { logger } from "matrix-js-sdk/src/logger";
import { useMatrixRTCSessionMemberships } from "./useMatrixRTCSessionMemberships";
import { useClientState } from "./ClientContext";
RelationType,
EventType,
RoomEvent as MatrixRoomEvent,
} from "matrix-js-sdk/src/matrix";
import {
type ECallReactionEventContent,
ElementCallReactionEventType,
ECallReactionEventContent,
GenericReaction,
type ReactionOption,
ReactionSet,
} from "./reactions";
import { useLatest } from "./useLatest";
import { CallViewModel } from "./state/CallViewModel";
interface ReactionsContextType {
supportsReactions: boolean;
toggleRaisedHand: () => Promise<void>;
sendReaction: (reaction: ReactionOption) => Promise<void>;
}
const ReactionsContext = createContext<ReactionsContextType | undefined>(
undefined,
);
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;
}
RaisedHandInfo,
ReactionInfo,
} from ".";
import { BehaviorSubject, Observable } from "rxjs";
const REACTION_ACTIVE_TIME_MS = 3000;
export const useReactions = (): ReactionsContextType => {
const context = useContext(ReactionsContext);
if (!context) {
throw new Error("useReactions must be used within a ReactionsProvider");
}
return context;
};
/**
* HS plan:
* Provider should publish new hand raised, reaction events to CallViewModel
* Provider should listen for new events from CVM
* 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>>({}),
);
/**
* Provider that handles raised hand reactions for a given `rtcSession`.
*/
export const ReactionsProvider = ({
children,
rtcSession,
vm,
}: {
children: ReactNode;
rtcSession: MatrixRTCSession;
vm: CallViewModel;
}): JSX.Element => {
const [raisedHands, setRaisedHands] = useState<
Record<string, RaisedHandInfo>
>({});
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 latestMemberships = useLatest(memberships);
const latestRaisedHands = useLatest(raisedHands);
useEffect(() => {
vm.updateReactions({
raisedHands: Object.fromEntries(
Object.entries(raisedHands).map(([uid, data]) => [uid, data.time]),
),
reactions,
});
}, [memberships, raisedHands]);
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, setReactions] = useState<Record<string, ReactionOption>>(
{},
);
const latestRaisedHands = useLatest(raisedHandsSubject.current);
const room = rtcSession.room;
const addRaisedHand = useCallback((userId: string, info: RaisedHandInfo) => {
setRaisedHands((prevRaisedHands) => ({
...prevRaisedHands,
raisedHandsSubject.current.next({
...raisedHandsSubject.current.value,
[userId]: info,
}));
});
}, []);
const removeRaisedHand = useCallback((userId: string) => {
setRaisedHands(
({ [userId]: _removed, ...remainingRaisedHands }) => remainingRaisedHands,
raisedHandsSubject.current.next(
Object.fromEntries(
Object.entries(raisedHandsSubject.current.value).filter(
([uId]) => uId !== userId,
),
),
);
}, []);
@@ -166,7 +82,7 @@ export const ReactionsProvider = ({
};
// Remove any raised hands for users no longer joined to the call.
for (const identifier of Object.keys(raisedHands).filter(
for (const identifier of Object.keys(raisedHandsSubject).filter(
(rhId) => !memberships.find((u) => u.sender == rhId),
)) {
removeRaisedHand(identifier);
@@ -180,8 +96,9 @@ export const ReactionsProvider = ({
}
const identifier = `${m.sender}:${m.deviceId}`;
if (
raisedHands[identifier] &&
raisedHands[identifier].membershipEventId !== m.eventId
raisedHandsSubject.current.value[identifier] &&
raisedHandsSubject.current.value[identifier].membershipEventId !==
m.eventId
) {
// Membership event for sender has changed since the hand
// was raised, reset.
@@ -207,7 +124,7 @@ export const ReactionsProvider = ({
// This effect handles any *live* reaction/redactions in the room.
useEffect(() => {
const reactionTimeouts = new Set<number>();
const reactionTimeouts = new Set<NodeJS.Timeout>();
const handleReactionEvent = (event: MatrixEvent): void => {
// Decrypted events might come from a different room
if (event.getRoomId() !== room.roomId) return;
@@ -268,23 +185,29 @@ export const ReactionsProvider = ({
...ReactionSet.find((r) => r.name === content.name),
};
setReactions((reactions) => {
if (reactions[identifier]) {
// We've still got a reaction from this user, ignore it to prevent spamming
return reactions;
}
const timeout = window.setTimeout(() => {
// Clear the reaction after some time.
setReactions(
({ [identifier]: _unused, ...remaining }) => remaining,
);
reactionTimeouts.delete(timeout);
}, REACTION_ACTIVE_TIME_MS);
reactionTimeouts.add(timeout);
return {
...reactions,
[identifier]: reaction,
};
const currentReactions = reactionsSubject.current.value;
if (currentReactions[identifier]) {
// We've still got a reaction from this user, ignore it to prevent spamming
return;
}
const timeout = globalThis.setTimeout(() => {
// Clear the reaction after some time.
reactionsSubject.current.next(
Object.fromEntries(
Object.entries(reactionsSubject.current.value).filter(
([id]) => id !== identifier,
),
),
);
reactionTimeouts.delete(timeout);
}, REACTION_ACTIVE_TIME_MS);
reactionTimeouts.add(timeout);
reactionsSubject.current.next({
...currentReactions,
[identifier]: {
reactionOption: reaction,
ttl: 0,
},
});
} else if (event.getType() === EventType.Reaction) {
const content = event.getContent() as ReactionEventContent;
@@ -340,7 +263,7 @@ export const ReactionsProvider = ({
room.off(MatrixRoomEvent.LocalEchoUpdated, handleReactionEvent);
reactionTimeouts.forEach((t) => clearTimeout(t));
// If we're clearing timeouts, we also clear all reactions.
setReactions({});
reactionsSubject.current.next({});
};
}, [
room,
@@ -350,84 +273,8 @@ export const ReactionsProvider = ({
latestRaisedHands,
]);
const toggleRaisedHand = useCallback(async () => {
console.log("toggleRaisedHand", myMembershipIdentifier);
if (!myMembershipIdentifier) {
return;
}
const myReactionId = raisedHands[myMembershipIdentifier]?.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,
raisedHands,
rtcSession,
room,
]);
const sendReaction = useCallback(
async (reaction: ReactionOption) => {
if (!myMembershipIdentifier || reactions[myMembershipIdentifier]) {
// 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, reactions, room, myMembershipIdentifier, rtcSession],
);
return (
<ReactionsContext.Provider
value={{
supportsReactions,
toggleRaisedHand,
sendReaction,
}}
>
{children}
</ReactionsContext.Provider>
);
};
return {
reactions: reactionsSubject.current.asObservable(),
raisedHands: raisedHandsSubject.current.asObservable(),
};
}

View File

@@ -130,40 +130,53 @@ test("plays no sound when the participant list is more than the maximum size", (
});
test("plays one sound when a hand is raised", () => {
const { vm } = getBasicCallViewModelEnvironment([local, alice]);
const { vm, handRaisedSubject } = getBasicCallViewModelEnvironment([
local,
alice,
]);
render(<CallEventAudioRenderer vm={vm} />);
act(() => {
vm.updateReactions({
raisedHands: {
[bobRtcMember.callId]: new Date(),
handRaisedSubject.next({
[bobRtcMember.callId]: {
time: new Date(),
membershipEventId: "",
reactionEventId: "",
},
reactions: {},
});
});
expect(playSound).toBeCalledWith("raiseHand");
});
test("should not play a sound when a hand raise is retracted", () => {
const { vm } = getBasicCallViewModelEnvironment([local, alice]);
const { vm, handRaisedSubject } = getBasicCallViewModelEnvironment([
local,
alice,
]);
render(<CallEventAudioRenderer vm={vm} />);
act(() => {
vm.updateReactions({
raisedHands: {
["foo"]: new Date(),
["bar"]: new Date(),
handRaisedSubject.next({
["foo"]: {
time: new Date(),
membershipEventId: "",
reactionEventId: "",
},
["bar"]: {
time: new Date(),
membershipEventId: "",
reactionEventId: "",
},
reactions: {},
});
});
expect(playSound).toHaveBeenCalledTimes(2);
act(() => {
vm.updateReactions({
raisedHands: {
["foo"]: new Date(),
handRaisedSubject.next({
["foo"]: {
time: new Date(),
membershipEventId: "",
reactionEventId: "",
},
reactions: {},
});
});
expect(playSound).toHaveBeenCalledTimes(2);

View File

@@ -75,7 +75,7 @@ export function CallEventAudioRenderer({
void audioEngineRef.current?.playSound("left");
});
const handRaisedSub = vm.handRaised.subscribe(() => {
const handRaisedSub = vm.newHandRaised.subscribe(() => {
audioEngineRef.current?.playSound("raiseHand");
});

View File

@@ -55,7 +55,6 @@ import { InviteModal } from "./InviteModal";
import { useUrlParams } from "../UrlParams";
import { E2eeType } from "../e2ee/e2eeType";
import { Link } from "../button/Link";
import { ReactionsProvider } from "../useReactions";
import { useAudioContext } from "../useAudioContext";
import { callEventAudioSounds } from "./CallEventAudioRenderer";
import { useLatest } from "../useLatest";
@@ -364,20 +363,18 @@ export const GroupCallView: FC<Props> = ({
return (
<>
{shareModal}
<ReactionsProvider rtcSession={rtcSession}>
<ActiveCall
client={client}
matrixInfo={matrixInfo}
rtcSession={rtcSession as MatrixRTCSession}
participantCount={participantCount}
onLeave={onLeave}
hideHeader={hideHeader}
muteStates={muteStates}
e2eeSystem={e2eeSystem}
//otelGroupCallMembership={otelGroupCallMembership}
onShareClick={onShareClick}
/>
</ReactionsProvider>
<ActiveCall
client={client}
matrixInfo={matrixInfo}
rtcSession={rtcSession as MatrixRTCSession}
participantCount={participantCount}
onLeave={onLeave}
hideHeader={hideHeader}
muteStates={muteStates}
e2eeSystem={e2eeSystem}
//otelGroupCallMembership={otelGroupCallMembership}
onShareClick={onShareClick}
/>
</>
);
} else if (left && widget === null) {

View File

@@ -83,7 +83,10 @@ import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout";
import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout";
import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout";
import { GridTileViewModel, type TileViewModel } from "../state/TileViewModel";
import { ReactionsProvider, useReactions } from "../useReactions";
import {
ReactionsSenderProvider,
useReactionsSender,
} from "../useReactionsSender";
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
import { useSwitchCamera } from "./useSwitchCamera";
import { ReactionsOverlay } from "./ReactionsOverlay";
@@ -92,6 +95,7 @@ import {
debugTileLayout as debugTileLayoutSetting,
useSetting,
} from "../settings/settings";
import useReactionsReader from "../reactions/useReactionsReader";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
@@ -125,31 +129,41 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const reader = useReactionsReader(props.rtcSession);
useEffect(() => {
if (livekitRoom !== undefined) {
if (livekitRoom !== undefined && reader !== undefined) {
const vm = new CallViewModel(
props.rtcSession,
livekitRoom,
props.e2eeSystem,
connStateObservable,
reader.raisedHands,
reader.reactions,
);
setVm(vm);
return (): void => vm.destroy();
}
}, [props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable]);
}, [
reader,
props.rtcSession,
livekitRoom,
props.e2eeSystem,
connStateObservable,
]);
if (livekitRoom === undefined || vm === null) return null;
return (
<RoomContext.Provider value={livekitRoom}>
<ReactionsProvider vm={vm} rtcSession={props.rtcSession}>
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
<InCallView
{...props}
vm={vm}
livekitRoom={livekitRoom}
connState={connState}
/>
</ReactionsProvider>
</ReactionsSenderProvider>
</RoomContext.Provider>
);
};
@@ -182,7 +196,8 @@ export const InCallView: FC<InCallViewProps> = ({
connState,
onShareClick,
}) => {
const { supportsReactions, sendReaction, toggleRaisedHand } = useReactions();
const { supportsReactions, sendReaction, toggleRaisedHand } =
useReactionsSender();
useWakeLock();

View File

@@ -88,7 +88,7 @@ import { oneOnOneLayout } from "./OneOnOneLayout";
import { pipLayout } from "./PipLayout";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { observeSpeaker } from "./observeSpeaker";
import { ReactionOption } from "../reactions";
import { RaisedHandInfo, ReactionInfo, ReactionOption } from "../reactions";
// How long we wait after a focus switch before showing the real participant
// list again
@@ -528,7 +528,7 @@ export class CallViewModel extends ViewModel {
this.encryptionSystem,
this.livekitRoom,
this.handsRaised.pipe(
map((v) => v[matrixIdentifier] ?? undefined),
map((v) => v[matrixIdentifier].time ?? undefined),
),
this.reactions.pipe(
map((v) => v[matrixIdentifier] ?? undefined),
@@ -1145,62 +1145,16 @@ export class CallViewModel extends ViewModel {
this.scope.state(),
);
private readonly handsRaisedSubject = new BehaviorSubject<
{ userId: string; date: Date | null }[]
>([]);
private readonly reactionsSubject = new BehaviorSubject<
{ userId: string; reaction: ReactionOption; ttl: number }[]
>([]);
public addHandRaised(userId: string, date: Date) {
this.handsRaisedSubject.next([{ userId, date }]);
}
public removeHandRaised(userId: string, date: Date | null) {
this.handsRaisedSubject.next([{ userId, date }]);
}
public addReaction(userId: string, reaction: ReactionOption, ttl: number) {
this.reactionsSubject.next([{ userId, reaction, ttl }]);
}
public readonly reactions = this.reactionsSubject
.pipe(
scan<
{ userId: string; reaction: ReactionOption; ttl: number }[],
Record<string, { reaction: ReactionOption; ttl: number }>
>((acc, value) => {
for (const { userId, reaction, ttl } of value) {
acc[userId] = { reaction, ttl };
}
return acc;
}, {}),
)
.pipe(
map((v) =>
Object.fromEntries(
Object.entries(v).map(([a, { reaction }]) => [a, reaction]),
),
public readonly reactions = this.reactionsSubject.pipe(
map((v) =>
Object.fromEntries(
Object.entries(v).map(([a, { reactionOption }]) => [a, reactionOption]),
),
);
public readonly handsRaised = this.handsRaisedSubject.pipe(
scan<{ userId: string; date: Date | null }[], Record<string, Date>>(
(acc, value) => {
for (const { userId, date } of value) {
if (date) {
acc[userId] = date;
} else {
delete acc[userId];
}
}
console.log("handsRaised", acc);
return acc;
},
{},
),
);
public readonly handsRaised = this.handsRaisedSubject.pipe();
/**
* Emits an array of reactions that should be visible on the screen.
*/
@@ -1255,7 +1209,7 @@ export class CallViewModel extends ViewModel {
* Emits an event every time a new hand is raised in
* the call.
*/
public readonly handRaised = this.handsRaised.pipe(
public readonly newHandRaised = this.handsRaised.pipe(
map((v) => Object.keys(v).length),
scan(
(acc, newValue) => ({
@@ -1273,6 +1227,10 @@ export class CallViewModel extends ViewModel {
private readonly livekitRoom: LivekitRoom,
private readonly encryptionSystem: EncryptionSystem,
private readonly connectionState: Observable<ECConnectionState>,
private readonly handsRaisedSubject: Observable<
Record<string, RaisedHandInfo>
>,
private readonly reactionsSubject: Observable<Record<string, ReactionInfo>>,
) {
super();
}

View File

@@ -15,7 +15,7 @@ import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSess
import { GridTile } from "./GridTile";
import { mockRtcMembership, withRemoteMedia } from "../utils/test";
import { GridTileViewModel } from "../state/TileViewModel";
import { ReactionsProvider } from "../useReactions";
import { ReactionsProvider } from "../useReactionsSender";
global.IntersectionObserver = class MockIntersectionObserver {
public observe(): void {}

View File

@@ -48,7 +48,7 @@ import { MediaView } from "./MediaView";
import { useLatest } from "../useLatest";
import { type GridTileViewModel } from "../state/TileViewModel";
import { useMergedRefs } from "../useMergedRefs";
import { useReactions } from "../useReactions";
import { useReactionsSender } from "../useReactionsSender";
interface TileProps {
className?: string;
@@ -81,7 +81,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
},
ref,
) => {
const { toggleRaisedHand } = useReactions();
const { toggleRaisedHand } = useReactionsSender();
const { t } = useTranslation();
const video = useObservableEagerState(vm.video);
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);

View File

@@ -10,7 +10,7 @@ import { act, type FC } from "react";
import { describe, expect, test } from "vitest";
import { RoomEvent } from "matrix-js-sdk/src/matrix";
import { useReactions } from "./useReactions";
import { useReactionsSender } from "./useReactionsSender";
import {
createHandRaisedReaction,
createRedaction,
@@ -36,7 +36,7 @@ const membership: Record<string, string> = {
*/
const TestComponent: FC = () => {
const { raisedHands } = useReactions();
const { raisedHands } = useReactionsSender();
return (
<div>
<ul>

176
src/useReactionsSender.tsx Normal file
View File

@@ -0,0 +1,176 @@
/*
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 { useMatrixRTCSessionMemberships } from "./useMatrixRTCSessionMemberships";
import { useClientState } from "./ClientContext";
import { ElementCallReactionEventType, type ReactionOption } from "./reactions";
import { CallViewModel } from "./state/CallViewModel";
import { useObservableEagerState } from "observable-hooks";
import { map } from "rxjs";
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 myReaction = useObservableEagerState(
vm.reactions.pipe(
map((v) =>
myMembershipIdentifier !== undefined
? v[myMembershipIdentifier]
: undefined,
),
),
);
const myRaisedHand = useObservableEagerState(
vm.handsRaised.pipe(
map((v) =>
myMembershipIdentifier !== undefined
? v[myMembershipIdentifier]
: undefined,
),
),
);
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>
);
};

View File

@@ -14,6 +14,7 @@ import {
localRtcMember,
} from "./test-fixtures";
import { RelationsContainer } from "matrix-js-sdk/src/models/relations-container";
import { RaisedHandInfo, ReactionInfo } from "../reactions";
/**
* Construct a basic CallViewModel to test components that make use of it.
@@ -28,6 +29,8 @@ export function getBasicCallViewModelEnvironment(
vm: CallViewModel;
remoteRtcMemberships: BehaviorSubject<CallMembership[]>;
rtcSession: MockRTCSession;
handRaisedSubject: BehaviorSubject<Record<string, RaisedHandInfo>>;
reactionsSubject: BehaviorSubject<Record<string, ReactionInfo>>;
} {
const matrixRoomMembers = new Map(members.map((p) => [p.userId, p]));
const remoteParticipants = of([aliceParticipant]);
@@ -54,6 +57,9 @@ export function getBasicCallViewModelEnvironment(
initialRemoteRtcMemberships,
);
const handRaisedSubject = new BehaviorSubject({});
const reactionsSubject = new BehaviorSubject({});
const rtcSession = new MockRTCSession(
matrixRoom,
localRtcMember,
@@ -66,6 +72,14 @@ export function getBasicCallViewModelEnvironment(
kind: E2eeType.PER_PARTICIPANT,
},
of(ConnectionState.Connected),
handRaisedSubject,
reactionsSubject,
);
return { vm, remoteRtcMemberships, rtcSession };
return {
vm,
remoteRtcMemberships,
rtcSession,
handRaisedSubject,
reactionsSubject,
};
}