mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-29 21:15:19 +00:00
Refactor reactions into a sender and receiver.
This commit is contained in:
@@ -18,7 +18,7 @@ import { CallViewModel } from "../state/CallViewModel";
|
|||||||
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
|
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
|
||||||
import { alice, local, localRtcMember } from "../utils/test-fixtures";
|
import { alice, local, localRtcMember } from "../utils/test-fixtures";
|
||||||
import { MockRTCSession } from "../utils/test";
|
import { MockRTCSession } from "../utils/test";
|
||||||
import { ReactionsProvider } from "../useReactions";
|
import { ReactionsProvider } from "../useReactionsSender";
|
||||||
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
|
||||||
|
|
||||||
const localIdent = `${localRtcMember.sender}:${localRtcMember.deviceId}`;
|
const localIdent = `${localRtcMember.sender}:${localRtcMember.deviceId}`;
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import classNames from "classnames";
|
|||||||
import { useObservableState } from "observable-hooks";
|
import { useObservableState } from "observable-hooks";
|
||||||
import { map } from "rxjs";
|
import { map } from "rxjs";
|
||||||
|
|
||||||
import { useReactions } from "../useReactions";
|
import { useReactionsSender } from "../useReactionsSender";
|
||||||
import styles from "./ReactionToggleButton.module.css";
|
import styles from "./ReactionToggleButton.module.css";
|
||||||
import {
|
import {
|
||||||
type ReactionOption,
|
type ReactionOption,
|
||||||
@@ -175,7 +175,7 @@ export function ReactionToggleButton({
|
|||||||
...props
|
...props
|
||||||
}: ReactionToggleButtonProps): ReactNode {
|
}: ReactionToggleButtonProps): ReactNode {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { toggleRaisedHand, sendReaction } = useReactions();
|
const { toggleRaisedHand, sendReaction } = useReactionsSender();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [showReactionsMenu, setShowReactionsMenu] = useState(false);
|
const [showReactionsMenu, setShowReactionsMenu] = useState(false);
|
||||||
const [errorText, setErrorText] = useState<string>();
|
const [errorText, setErrorText] = useState<string>();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,146 +1,62 @@
|
|||||||
/*
|
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
|
||||||
Copyright 2024 Milton Moura <miltonmoura@gmail.com>
|
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
|
||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
SPDX-License-Identifier: AGPL-3.0-only
|
import { useLatest } from "../useLatest";
|
||||||
Please see LICENSE in the repository root for full details.
|
import { logger } from "matrix-js-sdk/src/logger";
|
||||||
*/
|
import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
import {
|
|
||||||
EventType,
|
|
||||||
type MatrixEvent,
|
|
||||||
RelationType,
|
|
||||||
RoomEvent as MatrixRoomEvent,
|
|
||||||
MatrixEventEvent,
|
|
||||||
} from "matrix-js-sdk/src/matrix";
|
|
||||||
import { type ReactionEventContent } from "matrix-js-sdk/src/types";
|
import { type ReactionEventContent } from "matrix-js-sdk/src/types";
|
||||||
import {
|
import {
|
||||||
createContext,
|
RelationType,
|
||||||
useContext,
|
EventType,
|
||||||
useState,
|
RoomEvent as MatrixRoomEvent,
|
||||||
type ReactNode,
|
} from "matrix-js-sdk/src/matrix";
|
||||||
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";
|
|
||||||
import {
|
import {
|
||||||
type ECallReactionEventContent,
|
|
||||||
ElementCallReactionEventType,
|
ElementCallReactionEventType,
|
||||||
|
ECallReactionEventContent,
|
||||||
GenericReaction,
|
GenericReaction,
|
||||||
type ReactionOption,
|
|
||||||
ReactionSet,
|
ReactionSet,
|
||||||
} from "./reactions";
|
RaisedHandInfo,
|
||||||
import { useLatest } from "./useLatest";
|
ReactionInfo,
|
||||||
import { CallViewModel } from "./state/CallViewModel";
|
} from ".";
|
||||||
|
import { BehaviorSubject, Observable } from "rxjs";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const REACTION_ACTIVE_TIME_MS = 3000;
|
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:
|
* Listens for reactions from a RTCSession and populates subjects
|
||||||
* Provider should publish new hand raised, reaction events to CallViewModel
|
* for consumption by the CallViewModel.
|
||||||
* Provider should listen for new events from CVM
|
* @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 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 latestMemberships = useLatest(memberships);
|
||||||
const latestRaisedHands = useLatest(raisedHands);
|
const latestRaisedHands = useLatest(raisedHandsSubject.current);
|
||||||
|
const room = rtcSession.room;
|
||||||
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 addRaisedHand = useCallback((userId: string, info: RaisedHandInfo) => {
|
const addRaisedHand = useCallback((userId: string, info: RaisedHandInfo) => {
|
||||||
setRaisedHands((prevRaisedHands) => ({
|
raisedHandsSubject.current.next({
|
||||||
...prevRaisedHands,
|
...raisedHandsSubject.current.value,
|
||||||
[userId]: info,
|
[userId]: info,
|
||||||
}));
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const removeRaisedHand = useCallback((userId: string) => {
|
const removeRaisedHand = useCallback((userId: string) => {
|
||||||
setRaisedHands(
|
raisedHandsSubject.current.next(
|
||||||
({ [userId]: _removed, ...remainingRaisedHands }) => remainingRaisedHands,
|
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.
|
// 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),
|
(rhId) => !memberships.find((u) => u.sender == rhId),
|
||||||
)) {
|
)) {
|
||||||
removeRaisedHand(identifier);
|
removeRaisedHand(identifier);
|
||||||
@@ -180,8 +96,9 @@ export const ReactionsProvider = ({
|
|||||||
}
|
}
|
||||||
const identifier = `${m.sender}:${m.deviceId}`;
|
const identifier = `${m.sender}:${m.deviceId}`;
|
||||||
if (
|
if (
|
||||||
raisedHands[identifier] &&
|
raisedHandsSubject.current.value[identifier] &&
|
||||||
raisedHands[identifier].membershipEventId !== m.eventId
|
raisedHandsSubject.current.value[identifier].membershipEventId !==
|
||||||
|
m.eventId
|
||||||
) {
|
) {
|
||||||
// Membership event for sender has changed since the hand
|
// Membership event for sender has changed since the hand
|
||||||
// was raised, reset.
|
// was raised, reset.
|
||||||
@@ -207,7 +124,7 @@ export const ReactionsProvider = ({
|
|||||||
|
|
||||||
// This effect handles any *live* reaction/redactions in the room.
|
// This effect handles any *live* reaction/redactions in the room.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const reactionTimeouts = new Set<number>();
|
const reactionTimeouts = new Set<NodeJS.Timeout>();
|
||||||
const handleReactionEvent = (event: MatrixEvent): void => {
|
const handleReactionEvent = (event: MatrixEvent): void => {
|
||||||
// Decrypted events might come from a different room
|
// Decrypted events might come from a different room
|
||||||
if (event.getRoomId() !== room.roomId) return;
|
if (event.getRoomId() !== room.roomId) return;
|
||||||
@@ -268,23 +185,29 @@ export const ReactionsProvider = ({
|
|||||||
...ReactionSet.find((r) => r.name === content.name),
|
...ReactionSet.find((r) => r.name === content.name),
|
||||||
};
|
};
|
||||||
|
|
||||||
setReactions((reactions) => {
|
const currentReactions = reactionsSubject.current.value;
|
||||||
if (reactions[identifier]) {
|
if (currentReactions[identifier]) {
|
||||||
// We've still got a reaction from this user, ignore it to prevent spamming
|
// We've still got a reaction from this user, ignore it to prevent spamming
|
||||||
return reactions;
|
return;
|
||||||
}
|
}
|
||||||
const timeout = window.setTimeout(() => {
|
const timeout = globalThis.setTimeout(() => {
|
||||||
// Clear the reaction after some time.
|
// Clear the reaction after some time.
|
||||||
setReactions(
|
reactionsSubject.current.next(
|
||||||
({ [identifier]: _unused, ...remaining }) => remaining,
|
Object.fromEntries(
|
||||||
);
|
Object.entries(reactionsSubject.current.value).filter(
|
||||||
reactionTimeouts.delete(timeout);
|
([id]) => id !== identifier,
|
||||||
}, REACTION_ACTIVE_TIME_MS);
|
),
|
||||||
reactionTimeouts.add(timeout);
|
),
|
||||||
return {
|
);
|
||||||
...reactions,
|
reactionTimeouts.delete(timeout);
|
||||||
[identifier]: reaction,
|
}, REACTION_ACTIVE_TIME_MS);
|
||||||
};
|
reactionTimeouts.add(timeout);
|
||||||
|
reactionsSubject.current.next({
|
||||||
|
...currentReactions,
|
||||||
|
[identifier]: {
|
||||||
|
reactionOption: reaction,
|
||||||
|
ttl: 0,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} else if (event.getType() === EventType.Reaction) {
|
} else if (event.getType() === EventType.Reaction) {
|
||||||
const content = event.getContent() as ReactionEventContent;
|
const content = event.getContent() as ReactionEventContent;
|
||||||
@@ -340,7 +263,7 @@ export const ReactionsProvider = ({
|
|||||||
room.off(MatrixRoomEvent.LocalEchoUpdated, handleReactionEvent);
|
room.off(MatrixRoomEvent.LocalEchoUpdated, handleReactionEvent);
|
||||||
reactionTimeouts.forEach((t) => clearTimeout(t));
|
reactionTimeouts.forEach((t) => clearTimeout(t));
|
||||||
// If we're clearing timeouts, we also clear all reactions.
|
// If we're clearing timeouts, we also clear all reactions.
|
||||||
setReactions({});
|
reactionsSubject.current.next({});
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
room,
|
room,
|
||||||
@@ -350,84 +273,8 @@ export const ReactionsProvider = ({
|
|||||||
latestRaisedHands,
|
latestRaisedHands,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const toggleRaisedHand = useCallback(async () => {
|
return {
|
||||||
console.log("toggleRaisedHand", myMembershipIdentifier);
|
reactions: reactionsSubject.current.asObservable(),
|
||||||
if (!myMembershipIdentifier) {
|
raisedHands: raisedHandsSubject.current.asObservable(),
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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", () => {
|
test("plays one sound when a hand is raised", () => {
|
||||||
const { vm } = getBasicCallViewModelEnvironment([local, alice]);
|
const { vm, handRaisedSubject } = getBasicCallViewModelEnvironment([
|
||||||
|
local,
|
||||||
|
alice,
|
||||||
|
]);
|
||||||
render(<CallEventAudioRenderer vm={vm} />);
|
render(<CallEventAudioRenderer vm={vm} />);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
vm.updateReactions({
|
handRaisedSubject.next({
|
||||||
raisedHands: {
|
[bobRtcMember.callId]: {
|
||||||
[bobRtcMember.callId]: new Date(),
|
time: new Date(),
|
||||||
|
membershipEventId: "",
|
||||||
|
reactionEventId: "",
|
||||||
},
|
},
|
||||||
reactions: {},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
expect(playSound).toBeCalledWith("raiseHand");
|
expect(playSound).toBeCalledWith("raiseHand");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should not play a sound when a hand raise is retracted", () => {
|
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} />);
|
render(<CallEventAudioRenderer vm={vm} />);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
vm.updateReactions({
|
handRaisedSubject.next({
|
||||||
raisedHands: {
|
["foo"]: {
|
||||||
["foo"]: new Date(),
|
time: new Date(),
|
||||||
["bar"]: new Date(),
|
membershipEventId: "",
|
||||||
|
reactionEventId: "",
|
||||||
|
},
|
||||||
|
["bar"]: {
|
||||||
|
time: new Date(),
|
||||||
|
membershipEventId: "",
|
||||||
|
reactionEventId: "",
|
||||||
},
|
},
|
||||||
reactions: {},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
expect(playSound).toHaveBeenCalledTimes(2);
|
expect(playSound).toHaveBeenCalledTimes(2);
|
||||||
act(() => {
|
act(() => {
|
||||||
vm.updateReactions({
|
handRaisedSubject.next({
|
||||||
raisedHands: {
|
["foo"]: {
|
||||||
["foo"]: new Date(),
|
time: new Date(),
|
||||||
|
membershipEventId: "",
|
||||||
|
reactionEventId: "",
|
||||||
},
|
},
|
||||||
reactions: {},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
expect(playSound).toHaveBeenCalledTimes(2);
|
expect(playSound).toHaveBeenCalledTimes(2);
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function CallEventAudioRenderer({
|
|||||||
void audioEngineRef.current?.playSound("left");
|
void audioEngineRef.current?.playSound("left");
|
||||||
});
|
});
|
||||||
|
|
||||||
const handRaisedSub = vm.handRaised.subscribe(() => {
|
const handRaisedSub = vm.newHandRaised.subscribe(() => {
|
||||||
audioEngineRef.current?.playSound("raiseHand");
|
audioEngineRef.current?.playSound("raiseHand");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ import { InviteModal } from "./InviteModal";
|
|||||||
import { useUrlParams } from "../UrlParams";
|
import { useUrlParams } from "../UrlParams";
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
import { Link } from "../button/Link";
|
import { Link } from "../button/Link";
|
||||||
import { ReactionsProvider } from "../useReactions";
|
|
||||||
import { useAudioContext } from "../useAudioContext";
|
import { useAudioContext } from "../useAudioContext";
|
||||||
import { callEventAudioSounds } from "./CallEventAudioRenderer";
|
import { callEventAudioSounds } from "./CallEventAudioRenderer";
|
||||||
import { useLatest } from "../useLatest";
|
import { useLatest } from "../useLatest";
|
||||||
@@ -364,20 +363,18 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{shareModal}
|
{shareModal}
|
||||||
<ReactionsProvider rtcSession={rtcSession}>
|
<ActiveCall
|
||||||
<ActiveCall
|
client={client}
|
||||||
client={client}
|
matrixInfo={matrixInfo}
|
||||||
matrixInfo={matrixInfo}
|
rtcSession={rtcSession as MatrixRTCSession}
|
||||||
rtcSession={rtcSession as MatrixRTCSession}
|
participantCount={participantCount}
|
||||||
participantCount={participantCount}
|
onLeave={onLeave}
|
||||||
onLeave={onLeave}
|
hideHeader={hideHeader}
|
||||||
hideHeader={hideHeader}
|
muteStates={muteStates}
|
||||||
muteStates={muteStates}
|
e2eeSystem={e2eeSystem}
|
||||||
e2eeSystem={e2eeSystem}
|
//otelGroupCallMembership={otelGroupCallMembership}
|
||||||
//otelGroupCallMembership={otelGroupCallMembership}
|
onShareClick={onShareClick}
|
||||||
onShareClick={onShareClick}
|
/>
|
||||||
/>
|
|
||||||
</ReactionsProvider>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
} else if (left && widget === null) {
|
} else if (left && widget === null) {
|
||||||
|
|||||||
@@ -83,7 +83,10 @@ import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout";
|
|||||||
import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout";
|
import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout";
|
||||||
import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout";
|
import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout";
|
||||||
import { GridTileViewModel, type TileViewModel } from "../state/TileViewModel";
|
import { GridTileViewModel, type TileViewModel } from "../state/TileViewModel";
|
||||||
import { ReactionsProvider, useReactions } from "../useReactions";
|
import {
|
||||||
|
ReactionsSenderProvider,
|
||||||
|
useReactionsSender,
|
||||||
|
} from "../useReactionsSender";
|
||||||
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
|
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
|
||||||
import { useSwitchCamera } from "./useSwitchCamera";
|
import { useSwitchCamera } from "./useSwitchCamera";
|
||||||
import { ReactionsOverlay } from "./ReactionsOverlay";
|
import { ReactionsOverlay } from "./ReactionsOverlay";
|
||||||
@@ -92,6 +95,7 @@ import {
|
|||||||
debugTileLayout as debugTileLayoutSetting,
|
debugTileLayout as debugTileLayoutSetting,
|
||||||
useSetting,
|
useSetting,
|
||||||
} from "../settings/settings";
|
} from "../settings/settings";
|
||||||
|
import useReactionsReader from "../reactions/useReactionsReader";
|
||||||
|
|
||||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||||
|
|
||||||
@@ -125,31 +129,41 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const reader = useReactionsReader(props.rtcSession);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (livekitRoom !== undefined) {
|
if (livekitRoom !== undefined && reader !== undefined) {
|
||||||
const vm = new CallViewModel(
|
const vm = new CallViewModel(
|
||||||
props.rtcSession,
|
props.rtcSession,
|
||||||
livekitRoom,
|
livekitRoom,
|
||||||
props.e2eeSystem,
|
props.e2eeSystem,
|
||||||
connStateObservable,
|
connStateObservable,
|
||||||
|
reader.raisedHands,
|
||||||
|
reader.reactions,
|
||||||
);
|
);
|
||||||
setVm(vm);
|
setVm(vm);
|
||||||
return (): void => vm.destroy();
|
return (): void => vm.destroy();
|
||||||
}
|
}
|
||||||
}, [props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable]);
|
}, [
|
||||||
|
reader,
|
||||||
|
props.rtcSession,
|
||||||
|
livekitRoom,
|
||||||
|
props.e2eeSystem,
|
||||||
|
connStateObservable,
|
||||||
|
]);
|
||||||
|
|
||||||
if (livekitRoom === undefined || vm === null) return null;
|
if (livekitRoom === undefined || vm === null) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RoomContext.Provider value={livekitRoom}>
|
<RoomContext.Provider value={livekitRoom}>
|
||||||
<ReactionsProvider vm={vm} rtcSession={props.rtcSession}>
|
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
|
||||||
<InCallView
|
<InCallView
|
||||||
{...props}
|
{...props}
|
||||||
vm={vm}
|
vm={vm}
|
||||||
livekitRoom={livekitRoom}
|
livekitRoom={livekitRoom}
|
||||||
connState={connState}
|
connState={connState}
|
||||||
/>
|
/>
|
||||||
</ReactionsProvider>
|
</ReactionsSenderProvider>
|
||||||
</RoomContext.Provider>
|
</RoomContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -182,7 +196,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
connState,
|
connState,
|
||||||
onShareClick,
|
onShareClick,
|
||||||
}) => {
|
}) => {
|
||||||
const { supportsReactions, sendReaction, toggleRaisedHand } = useReactions();
|
const { supportsReactions, sendReaction, toggleRaisedHand } =
|
||||||
|
useReactionsSender();
|
||||||
|
|
||||||
useWakeLock();
|
useWakeLock();
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ import { oneOnOneLayout } from "./OneOnOneLayout";
|
|||||||
import { pipLayout } from "./PipLayout";
|
import { pipLayout } from "./PipLayout";
|
||||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||||
import { observeSpeaker } from "./observeSpeaker";
|
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
|
// How long we wait after a focus switch before showing the real participant
|
||||||
// list again
|
// list again
|
||||||
@@ -528,7 +528,7 @@ export class CallViewModel extends ViewModel {
|
|||||||
this.encryptionSystem,
|
this.encryptionSystem,
|
||||||
this.livekitRoom,
|
this.livekitRoom,
|
||||||
this.handsRaised.pipe(
|
this.handsRaised.pipe(
|
||||||
map((v) => v[matrixIdentifier] ?? undefined),
|
map((v) => v[matrixIdentifier].time ?? undefined),
|
||||||
),
|
),
|
||||||
this.reactions.pipe(
|
this.reactions.pipe(
|
||||||
map((v) => v[matrixIdentifier] ?? undefined),
|
map((v) => v[matrixIdentifier] ?? undefined),
|
||||||
@@ -1145,62 +1145,16 @@ export class CallViewModel extends ViewModel {
|
|||||||
this.scope.state(),
|
this.scope.state(),
|
||||||
);
|
);
|
||||||
|
|
||||||
private readonly handsRaisedSubject = new BehaviorSubject<
|
public readonly reactions = this.reactionsSubject.pipe(
|
||||||
{ userId: string; date: Date | null }[]
|
map((v) =>
|
||||||
>([]);
|
Object.fromEntries(
|
||||||
private readonly reactionsSubject = new BehaviorSubject<
|
Object.entries(v).map(([a, { reactionOption }]) => [a, reactionOption]),
|
||||||
{ 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 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.
|
* 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
|
* Emits an event every time a new hand is raised in
|
||||||
* the call.
|
* the call.
|
||||||
*/
|
*/
|
||||||
public readonly handRaised = this.handsRaised.pipe(
|
public readonly newHandRaised = this.handsRaised.pipe(
|
||||||
map((v) => Object.keys(v).length),
|
map((v) => Object.keys(v).length),
|
||||||
scan(
|
scan(
|
||||||
(acc, newValue) => ({
|
(acc, newValue) => ({
|
||||||
@@ -1273,6 +1227,10 @@ export class CallViewModel extends ViewModel {
|
|||||||
private readonly livekitRoom: LivekitRoom,
|
private readonly livekitRoom: LivekitRoom,
|
||||||
private readonly encryptionSystem: EncryptionSystem,
|
private readonly encryptionSystem: EncryptionSystem,
|
||||||
private readonly connectionState: Observable<ECConnectionState>,
|
private readonly connectionState: Observable<ECConnectionState>,
|
||||||
|
private readonly handsRaisedSubject: Observable<
|
||||||
|
Record<string, RaisedHandInfo>
|
||||||
|
>,
|
||||||
|
private readonly reactionsSubject: Observable<Record<string, ReactionInfo>>,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { type MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSess
|
|||||||
import { GridTile } from "./GridTile";
|
import { GridTile } from "./GridTile";
|
||||||
import { mockRtcMembership, withRemoteMedia } from "../utils/test";
|
import { mockRtcMembership, withRemoteMedia } from "../utils/test";
|
||||||
import { GridTileViewModel } from "../state/TileViewModel";
|
import { GridTileViewModel } from "../state/TileViewModel";
|
||||||
import { ReactionsProvider } from "../useReactions";
|
import { ReactionsProvider } from "../useReactionsSender";
|
||||||
|
|
||||||
global.IntersectionObserver = class MockIntersectionObserver {
|
global.IntersectionObserver = class MockIntersectionObserver {
|
||||||
public observe(): void {}
|
public observe(): void {}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ import { MediaView } from "./MediaView";
|
|||||||
import { useLatest } from "../useLatest";
|
import { useLatest } from "../useLatest";
|
||||||
import { type GridTileViewModel } from "../state/TileViewModel";
|
import { type GridTileViewModel } from "../state/TileViewModel";
|
||||||
import { useMergedRefs } from "../useMergedRefs";
|
import { useMergedRefs } from "../useMergedRefs";
|
||||||
import { useReactions } from "../useReactions";
|
import { useReactionsSender } from "../useReactionsSender";
|
||||||
|
|
||||||
interface TileProps {
|
interface TileProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -81,7 +81,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
|||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const { toggleRaisedHand } = useReactions();
|
const { toggleRaisedHand } = useReactionsSender();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const video = useObservableEagerState(vm.video);
|
const video = useObservableEagerState(vm.video);
|
||||||
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
|
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { act, type FC } from "react";
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { RoomEvent } from "matrix-js-sdk/src/matrix";
|
import { RoomEvent } from "matrix-js-sdk/src/matrix";
|
||||||
|
|
||||||
import { useReactions } from "./useReactions";
|
import { useReactionsSender } from "./useReactionsSender";
|
||||||
import {
|
import {
|
||||||
createHandRaisedReaction,
|
createHandRaisedReaction,
|
||||||
createRedaction,
|
createRedaction,
|
||||||
@@ -36,7 +36,7 @@ const membership: Record<string, string> = {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const TestComponent: FC = () => {
|
const TestComponent: FC = () => {
|
||||||
const { raisedHands } = useReactions();
|
const { raisedHands } = useReactionsSender();
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<ul>
|
<ul>
|
||||||
|
|||||||
176
src/useReactionsSender.tsx
Normal file
176
src/useReactionsSender.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
localRtcMember,
|
localRtcMember,
|
||||||
} from "./test-fixtures";
|
} from "./test-fixtures";
|
||||||
import { RelationsContainer } from "matrix-js-sdk/src/models/relations-container";
|
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.
|
* Construct a basic CallViewModel to test components that make use of it.
|
||||||
@@ -28,6 +29,8 @@ export function getBasicCallViewModelEnvironment(
|
|||||||
vm: CallViewModel;
|
vm: CallViewModel;
|
||||||
remoteRtcMemberships: BehaviorSubject<CallMembership[]>;
|
remoteRtcMemberships: BehaviorSubject<CallMembership[]>;
|
||||||
rtcSession: MockRTCSession;
|
rtcSession: MockRTCSession;
|
||||||
|
handRaisedSubject: BehaviorSubject<Record<string, RaisedHandInfo>>;
|
||||||
|
reactionsSubject: BehaviorSubject<Record<string, ReactionInfo>>;
|
||||||
} {
|
} {
|
||||||
const matrixRoomMembers = new Map(members.map((p) => [p.userId, p]));
|
const matrixRoomMembers = new Map(members.map((p) => [p.userId, p]));
|
||||||
const remoteParticipants = of([aliceParticipant]);
|
const remoteParticipants = of([aliceParticipant]);
|
||||||
@@ -54,6 +57,9 @@ export function getBasicCallViewModelEnvironment(
|
|||||||
initialRemoteRtcMemberships,
|
initialRemoteRtcMemberships,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handRaisedSubject = new BehaviorSubject({});
|
||||||
|
const reactionsSubject = new BehaviorSubject({});
|
||||||
|
|
||||||
const rtcSession = new MockRTCSession(
|
const rtcSession = new MockRTCSession(
|
||||||
matrixRoom,
|
matrixRoom,
|
||||||
localRtcMember,
|
localRtcMember,
|
||||||
@@ -66,6 +72,14 @@ export function getBasicCallViewModelEnvironment(
|
|||||||
kind: E2eeType.PER_PARTICIPANT,
|
kind: E2eeType.PER_PARTICIPANT,
|
||||||
},
|
},
|
||||||
of(ConnectionState.Connected),
|
of(ConnectionState.Connected),
|
||||||
|
handRaisedSubject,
|
||||||
|
reactionsSubject,
|
||||||
);
|
);
|
||||||
return { vm, remoteRtcMemberships, rtcSession };
|
return {
|
||||||
|
vm,
|
||||||
|
remoteRtcMemberships,
|
||||||
|
rtcSession,
|
||||||
|
handRaisedSubject,
|
||||||
|
reactionsSubject,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user