mirror of
https://github.com/vector-im/element-call.git
synced 2026-03-31 07:00:26 +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 { 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}`;
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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 @@
|
||||
/*
|
||||
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(),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
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,
|
||||
} 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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user