From 48cf487e0a49e27a35b8312e3ccc7284a97cab84 Mon Sep 17 00:00:00 2001 From: Milton Moura Date: Wed, 7 Aug 2024 01:58:14 +0000 Subject: [PATCH 01/45] Initial support for Hand Raise feature Signed-off-by: Milton Moura --- public/locales/en-GB/app.json | 1 + src/button/Button.tsx | 22 +++++++++ src/icons/RaiseHand.svg | 9 ++++ src/room/InCallView.tsx | 93 ++++++++++++++++++++++++++++++++--- src/room/useRaisedHands.tsx | 47 ++++++++++++++++++ src/tile/GridTile.tsx | 4 ++ src/tile/MediaView.module.css | 16 ++++++ src/tile/MediaView.tsx | 8 +++ 8 files changed, 192 insertions(+), 8 deletions(-) create mode 100644 src/icons/RaiseHand.svg create mode 100644 src/room/useRaisedHands.tsx diff --git a/public/locales/en-GB/app.json b/public/locales/en-GB/app.json index 2eb2b5c36..a5e4b8a32 100644 --- a/public/locales/en-GB/app.json +++ b/public/locales/en-GB/app.json @@ -54,6 +54,7 @@ "options": "Options", "password": "Password", "profile": "Profile", + "raise_hand": "Raise hand", "settings": "Settings", "unencrypted": "Not encrypted", "username": "Username", diff --git a/src/button/Button.tsx b/src/button/Button.tsx index 5d747a03e..aa479eab4 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -18,6 +18,7 @@ import { SettingsSolidIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; +import RaiseHandIcon from "../icons/RaiseHand.svg?react"; import styles from "./Button.module.css"; interface MicButtonProps extends ComponentPropsWithoutRef<"button"> { @@ -91,6 +92,27 @@ export const ShareScreenButton: FC = ({ ); }; +interface RaiseHandButtonProps extends ComponentPropsWithoutRef<"button"> { + raised: boolean; +} +export const RaiseHandButton: FC = ({ + raised, + ...props +}) => { + const { t } = useTranslation(); + + return ( + + + + ); +}; + export const EndCallButton: FC> = ({ className, ...props diff --git a/src/icons/RaiseHand.svg b/src/icons/RaiseHand.svg new file mode 100644 index 000000000..c791d6586 --- /dev/null +++ b/src/icons/RaiseHand.svg @@ -0,0 +1,9 @@ + + + raise-hand + + + + + + \ No newline at end of file diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index d50be3c9d..6a129f49e 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -10,7 +10,14 @@ import { RoomContext, useLocalParticipant, } from "@livekit/components-react"; -import { ConnectionState, Room } from "livekit-client"; +import { + ConnectionState, + // eslint-disable-next-line camelcase + DataPacket_Kind, + Participant, + Room, + RoomEvent, +} from "livekit-client"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { FC, @@ -39,6 +46,7 @@ import { MicButton, VideoButton, ShareScreenButton, + RaiseHandButton, SettingsButton, } from "../button"; import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; @@ -78,6 +86,7 @@ import { makeOneOnOneLayout } from "../grid/OneOnOneLayout"; import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout"; import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout"; import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout"; +import { RaisedHandsProvider, useRaisedHands } from "./useRaisedHands"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -130,12 +139,14 @@ export const ActiveCall: FC = (props) => { return ( - + + + ); }; @@ -298,6 +309,34 @@ export const InCallView: FC = ({ [vm], ); + const { raisedHands, setRaisedHands } = useRaisedHands(); + const isHandRaised = raisedHands.includes( + localParticipant.identity.split(":")[0] + + ":" + + localParticipant.identity.split(":")[1], + ); + + useEffect(() => { + const handleDataReceived = ( + payload: Uint8Array, + participant?: Participant, + // eslint-disable-next-line camelcase + kind?: DataPacket_Kind, + ): void => { + const decoder = new TextDecoder(); + const strData = decoder.decode(payload); + // get json object from strData + const data = JSON.parse(strData); + setRaisedHands(data.raisedHands); + }; + + livekitRoom.on(RoomEvent.DataReceived, handleDataReceived); + + return (): void => { + livekitRoom.off(RoomEvent.DataReceived, handleDataReceived); + }; + }, [livekitRoom, setRaisedHands]); + useEffect(() => { widget?.api.transport .send( @@ -479,6 +518,37 @@ export const InCallView: FC = ({ .catch(logger.error); }, [localParticipant, isScreenShareEnabled]); + const toggleRaisedHand = useCallback(() => { + // TODO: wtf + const userId = + localParticipant.identity.split(":")[0] + + ":" + + localParticipant.identity.split(":")[1]; + const raisedHand = raisedHands.includes(userId); + let result = raisedHands; + if (raisedHand) { + result = raisedHands.filter((id) => id !== userId); + } else { + result = [...raisedHands, userId]; + } + try { + const strData = JSON.stringify({ + raisedHands: result, + }); + const encoder = new TextEncoder(); + const data = encoder.encode(strData); + livekitRoom.localParticipant.publishData(data, { reliable: true }); + setRaisedHands(result); + } catch (e) { + logger.error(e); + } + }, [ + livekitRoom.localParticipant, + localParticipant.identity, + raisedHands, + setRaisedHands, + ]); + let footer: JSX.Element | null; if (noControls) { @@ -513,7 +583,14 @@ export const InCallView: FC = ({ />, ); } - buttons.push(); + buttons.push( + , + ); + buttons.push(); } buttons.push( diff --git a/src/room/useRaisedHands.tsx b/src/room/useRaisedHands.tsx new file mode 100644 index 000000000..a93720e98 --- /dev/null +++ b/src/room/useRaisedHands.tsx @@ -0,0 +1,47 @@ +/* +Copyright 2024 Milton Moura + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { createContext, useContext, useState, ReactNode } from "react"; + +interface RaisedHandsContextType { + raisedHands: string[]; + setRaisedHands: React.Dispatch>; +} + +const RaisedHandsContext = createContext( + undefined, +); + +export const useRaisedHands = (): RaisedHandsContextType => { + const context = useContext(RaisedHandsContext); + if (!context) { + throw new Error("useRaisedHands must be used within a RaisedHandsProvider"); + } + return context; +}; + +export const RaisedHandsProvider = ({ + children, +}: { + children: ReactNode; +}): JSX.Element => { + const [raisedHands, setRaisedHands] = useState([]); + return ( + + {children} + + ); +}; diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index a46ff4725..0dc434b6b 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -44,6 +44,7 @@ import { import { Slider } from "../Slider"; import { MediaView } from "./MediaView"; import { useLatest } from "../useLatest"; +import { useRaisedHands } from "../room/useRaisedHands"; interface TileProps { className?: string; @@ -90,6 +91,8 @@ const UserMediaTile = forwardRef( }, [vm], ); + const { raisedHands } = useRaisedHands(); + const raisedHand = raisedHands.includes(vm.member?.userId ?? ""); const MicIcon = audioEnabled ? MicOnSolidIcon : MicOffSolidIcon; @@ -144,6 +147,7 @@ const UserMediaTile = forwardRef( {menu} } + raisedHand={raisedHand} {...props} /> ); diff --git a/src/tile/MediaView.module.css b/src/tile/MediaView.module.css index adde1c7b8..2d8aba401 100644 --- a/src/tile/MediaView.module.css +++ b/src/tile/MediaView.module.css @@ -90,6 +90,22 @@ unconditionally select the container so we can use cqmin units */ place-items: start; } +.raisedHand { + margin: var(--cpd-space-2x); + padding: var(--cpd-space-2x); + padding-block: var(--cpd-space-2x); + color: var(--cpd-color-icon-secondary); + background-color: var(--cpd-color-icon-secondary); + display: flex; + align-items: center; + border-radius: var(--cpd-radius-pill-effect); + user-select: none; + overflow: hidden; + box-shadow: var(--small-drop-shadow); + box-sizing: border-box; + max-inline-size: 100%; +} + .nameTag { grid-area: nameTag; padding: var(--cpd-space-1x); diff --git a/src/tile/MediaView.tsx b/src/tile/MediaView.tsx index 42a056035..93515f861 100644 --- a/src/tile/MediaView.tsx +++ b/src/tile/MediaView.tsx @@ -16,6 +16,7 @@ import { Text, Tooltip } from "@vector-im/compound-web"; import { ErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import styles from "./MediaView.module.css"; +import RaiseHandIcon from "../icons/RaiseHand.svg?react"; import { Avatar } from "../Avatar"; interface Props extends ComponentProps { @@ -32,6 +33,7 @@ interface Props extends ComponentProps { nameTagLeadingIcon?: ReactNode; displayName: string; primaryButton?: ReactNode; + raisedHand: boolean; } export const MediaView = forwardRef( @@ -50,6 +52,7 @@ export const MediaView = forwardRef( nameTagLeadingIcon, displayName, primaryButton, + raisedHand, ...props }, ref, @@ -86,6 +89,11 @@ export const MediaView = forwardRef( )}
+ {raisedHand && ( +
+ +
+ )}
{nameTagLeadingIcon} From 2d1917c22a1a9374cf5f2cd884f50bab7b81fe11 Mon Sep 17 00:00:00 2001 From: Milton Moura Date: Sun, 8 Sep 2024 00:57:45 +0000 Subject: [PATCH 02/45] Refactored to use reaction and redaction events Signed-off-by: Milton Moura --- src/room/InCallView.tsx | 118 ++++++++++++++++++++++++---------------- 1 file changed, 70 insertions(+), 48 deletions(-) diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 6a129f49e..3b798e1f3 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -10,14 +10,11 @@ import { RoomContext, useLocalParticipant, } from "@livekit/components-react"; +import { ConnectionState, Room } from "livekit-client"; import { - ConnectionState, - // eslint-disable-next-line camelcase - DataPacket_Kind, - Participant, - Room, - RoomEvent, -} from "livekit-client"; + MatrixEvent, + RoomEvent as MatrixRoomEvent, +} from "matrix-js-sdk/src/matrix"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { FC, @@ -37,6 +34,8 @@ import classNames from "classnames"; import { BehaviorSubject, of } from "rxjs"; import { useObservableEagerState } from "observable-hooks"; import { logger } from "matrix-js-sdk/src/logger"; +import { EventType, RelationType } from "matrix-js-sdk/src/matrix"; +import { ReactionEventContent } from "matrix-js-sdk/src/types"; import LogoMark from "../icons/LogoMark.svg?react"; import LogoType from "../icons/LogoType.svg?react"; @@ -87,6 +86,7 @@ import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout"; import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout"; import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout"; import { RaisedHandsProvider, useRaisedHands } from "./useRaisedHands"; +import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -309,33 +309,36 @@ export const InCallView: FC = ({ [vm], ); + const memberships = useMatrixRTCSessionMemberships(rtcSession); const { raisedHands, setRaisedHands } = useRaisedHands(); - const isHandRaised = raisedHands.includes( - localParticipant.identity.split(":")[0] + - ":" + - localParticipant.identity.split(":")[1], - ); + const [reactionId, setReactionId] = useState(null); + const [username, localpart] = localParticipant.identity.split(":"); + const userId = `${username}:${localpart}`; + const isHandRaised = raisedHands.includes(userId); useEffect(() => { - const handleDataReceived = ( - payload: Uint8Array, - participant?: Participant, - // eslint-disable-next-line camelcase - kind?: DataPacket_Kind, - ): void => { - const decoder = new TextDecoder(); - const strData = decoder.decode(payload); - // get json object from strData - const data = JSON.parse(strData); - setRaisedHands(data.raisedHands); + const handleReactionEvent = (event: MatrixEvent): void => { + if (event.getType() === EventType.Reaction) { + // TODO: check if target of reaction is a call membership event + const content = event.getContent() as ReactionEventContent; + if (content?.["m.relates_to"].key === "🖐️") { + setRaisedHands([...raisedHands, event.getSender()!]); + } + } + if (event.getType() === EventType.RoomRedaction) { + // TODO: check target of redaction event + setRaisedHands(raisedHands.filter((id) => id !== event.getSender())); + } }; - livekitRoom.on(RoomEvent.DataReceived, handleDataReceived); + client.on(MatrixRoomEvent.Timeline, handleReactionEvent); + client.on(MatrixRoomEvent.Redaction, handleReactionEvent); return (): void => { - livekitRoom.off(RoomEvent.DataReceived, handleDataReceived); + client.on(MatrixRoomEvent.Timeline, handleReactionEvent); + client.off(MatrixRoomEvent.Redaction, handleReactionEvent); }; - }, [livekitRoom, setRaisedHands]); + }, [client, raisedHands, setRaisedHands]); useEffect(() => { widget?.api.transport @@ -518,35 +521,54 @@ export const InCallView: FC = ({ .catch(logger.error); }, [localParticipant, isScreenShareEnabled]); - const toggleRaisedHand = useCallback(() => { - // TODO: wtf - const userId = - localParticipant.identity.split(":")[0] + - ":" + - localParticipant.identity.split(":")[1]; - const raisedHand = raisedHands.includes(userId); - let result = raisedHands; - if (raisedHand) { - result = raisedHands.filter((id) => id !== userId); - } else { - result = [...raisedHands, userId]; - } + const toggleRaisedHand = useCallback(async () => { try { - const strData = JSON.stringify({ - raisedHands: result, - }); - const encoder = new TextEncoder(); - const data = encoder.encode(strData); - livekitRoom.localParticipant.publishData(data, { reliable: true }); - setRaisedHands(result); + if (isHandRaised) { + try { + if (reactionId) { + await client.redactEvent(rtcSession.room.roomId, reactionId); + setReactionId(null); + setRaisedHands(raisedHands.filter((id) => id !== userId)); + logger.debug("Redacted reaction event"); + } + } catch (e) { + logger.error("Failed to redact reaction event", e); + } + } else { + const m = memberships.filter((m) => m.sender === userId); + const eventId = m[0].eventId!; + try { + const reaction = await client.sendEvent( + rtcSession.room.roomId, + EventType.Reaction, + { + "m.relates_to": { + rel_type: RelationType.Annotation, + event_id: eventId, + key: "🖐️", + }, + }, + ); + setReactionId(reaction.event_id); + setRaisedHands([...raisedHands, userId]); + logger.debug("Sent reaction event", reaction.event_id); + } catch (e) { + logger.error("Failed to send reaction event", e); + } + } } catch (e) { logger.error(e); } }, [ - livekitRoom.localParticipant, - localParticipant.identity, + client, + isHandRaised, + memberships, raisedHands, + reactionId, + rtcSession.room.roomId, setRaisedHands, + setReactionId, + userId, ]); let footer: JSX.Element | null; From f6ae6a0765cbc8b5eed3f242a29b612251361f14 Mon Sep 17 00:00:00 2001 From: Milton Moura Date: Sun, 8 Sep 2024 01:38:44 +0000 Subject: [PATCH 03/45] Replacing button svg with raised hand emoji Signed-off-by: Milton Moura --- src/button/Button.tsx | 19 +++++++++++++++---- src/tile/MediaView.tsx | 14 ++++++++++++-- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/button/Button.tsx b/src/button/Button.tsx index aa479eab4..6012e5b9b 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -18,7 +18,6 @@ import { SettingsSolidIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; -import RaiseHandIcon from "../icons/RaiseHand.svg?react"; import styles from "./Button.module.css"; interface MicButtonProps extends ComponentPropsWithoutRef<"button"> { @@ -104,11 +103,23 @@ export const RaiseHandButton: FC = ({ return ( + style={{ paddingLeft: 8, paddingRight: 8 }} + > +

+ ✋ +

+
); }; diff --git a/src/tile/MediaView.tsx b/src/tile/MediaView.tsx index 93515f861..c369625b4 100644 --- a/src/tile/MediaView.tsx +++ b/src/tile/MediaView.tsx @@ -16,7 +16,6 @@ import { Text, Tooltip } from "@vector-im/compound-web"; import { ErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import styles from "./MediaView.module.css"; -import RaiseHandIcon from "../icons/RaiseHand.svg?react"; import { Avatar } from "../Avatar"; interface Props extends ComponentProps { @@ -91,7 +90,18 @@ export const MediaView = forwardRef(
{raisedHand && (
- +

+ ✋ +

)}
From ac7321d1e6adb0963970162db689d1cefb0dbf5d Mon Sep 17 00:00:00 2001 From: Milton Moura Date: Sun, 8 Sep 2024 16:06:28 +0000 Subject: [PATCH 04/45] SpotlightTile should not duplicate the raised hand Signed-off-by: Milton Moura --- src/tile/SpotlightTile.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/tile/SpotlightTile.tsx b/src/tile/SpotlightTile.tsx index a37d9cc22..d01242c40 100644 --- a/src/tile/SpotlightTile.tsx +++ b/src/tile/SpotlightTile.tsx @@ -53,6 +53,7 @@ interface SpotlightItemBaseProps { unencryptedWarning: boolean; displayName: string; "aria-hidden"?: boolean; + raisedHand: boolean; } interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps { @@ -157,6 +158,7 @@ const SpotlightItem = forwardRef( unencryptedWarning, displayName, "aria-hidden": ariaHidden, + raisedHand: false, }; return vm instanceof ScreenShareViewModel ? ( From bcad5003e7aaf6bebad59feca298288e6fd37ef2 Mon Sep 17 00:00:00 2001 From: fkwp Date: Mon, 9 Sep 2024 10:35:08 +0200 Subject: [PATCH 05/45] Update src/room/useRaisedHands.tsx Element Call recently changed to AGPL-3.0 --- src/room/useRaisedHands.tsx | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/room/useRaisedHands.tsx b/src/room/useRaisedHands.tsx index a93720e98..458ad59bd 100644 --- a/src/room/useRaisedHands.tsx +++ b/src/room/useRaisedHands.tsx @@ -1,17 +1,8 @@ /* Copyright 2024 Milton Moura -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. */ import React, { createContext, useContext, useState, ReactNode } from "react"; From 0730ba5c709e3f7a26dd5e27ec4c865aa3a24061 Mon Sep 17 00:00:00 2001 From: Milton Moura Date: Tue, 10 Sep 2024 00:05:08 +0000 Subject: [PATCH 06/45] Use relations to load existing reactions when joining the call Signed-off-by: Milton Moura --- src/room/InCallView.tsx | 35 +++++++++++++++++++++++++++++++++++ src/widget.ts | 2 ++ 2 files changed, 37 insertions(+) diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 3b798e1f3..62bdabf63 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -316,6 +316,41 @@ export const InCallView: FC = ({ const userId = `${username}:${localpart}`; const isHandRaised = raisedHands.includes(userId); + useEffect(() => { + const getLastReactionEvent = async ( + eventId: string, + ): Promise => { + const rels = await client.relations( + rtcSession.room.roomId, + eventId, + RelationType.Annotation, + EventType.Reaction, + { + limit: 1, + }, + ); + + return rels.events.length > 0 ? rels.events[0] : undefined; + }; + + const fetchReactions = async (): Promise => { + const newRaisedHands = [...raisedHands]; + for (const m of memberships) { + const reaction = await getLastReactionEvent(m.eventId!); + if (reaction && reaction.getType() === EventType.Reaction) { + const content = reaction.getContent() as ReactionEventContent; + if (content?.["m.relates_to"].key === "🖐️") { + newRaisedHands.push(m.sender!); + } + } + } + setRaisedHands(newRaisedHands); + }; + + fetchReactions(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useEffect(() => { const handleReactionEvent = (event: MatrixEvent): void => { if (event.getType() === EventType.Reaction) { diff --git a/src/widget.ts b/src/widget.ts index f08968b65..9d3da4792 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -103,6 +103,8 @@ export const widget = ((): WidgetHelpers | null => { const sendRecvEvent = [ "org.matrix.rageshake_request", EventType.CallEncryptionKeysPrefix, + EventType.Reaction, + EventType.RoomRedaction, ]; const sendState = [ From ab5654cf3290f264eb7be70f5c465e043b68fad3 Mon Sep 17 00:00:00 2001 From: Milton Moura Date: Tue, 10 Sep 2024 23:04:36 +0000 Subject: [PATCH 07/45] Links to sha commit of matrix-js-sdk that exposes the call membership event id and refactors some async code Signed-off-by: Milton Moura --- src/room/InCallView.tsx | 82 ++++++++++++++++++++--------------------- 1 file changed, 40 insertions(+), 42 deletions(-) diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 62bdabf63..931ddaad4 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -320,17 +320,19 @@ export const InCallView: FC = ({ const getLastReactionEvent = async ( eventId: string, ): Promise => { - const rels = await client.relations( - rtcSession.room.roomId, - eventId, - RelationType.Annotation, - EventType.Reaction, - { - limit: 1, - }, - ); - - return rels.events.length > 0 ? rels.events[0] : undefined; + return client + .relations( + rtcSession.room.roomId, + eventId, + RelationType.Annotation, + EventType.Reaction, + { + limit: 1, + }, + ) + .then((rels) => { + return rels.events.length > 0 ? rels.events[0] : undefined; + }); }; const fetchReactions = async (): Promise => { @@ -347,7 +349,7 @@ export const InCallView: FC = ({ setRaisedHands(newRaisedHands); }; - fetchReactions(); + void fetchReactions(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -556,43 +558,39 @@ export const InCallView: FC = ({ .catch(logger.error); }, [localParticipant, isScreenShareEnabled]); - const toggleRaisedHand = useCallback(async () => { - try { - if (isHandRaised) { - try { - if (reactionId) { - await client.redactEvent(rtcSession.room.roomId, reactionId); + const toggleRaisedHand = useCallback(() => { + if (isHandRaised) { + if (reactionId) { + client + .redactEvent(rtcSession.room.roomId, reactionId) + .then(() => { setReactionId(null); setRaisedHands(raisedHands.filter((id) => id !== userId)); logger.debug("Redacted reaction event"); - } - } catch (e) { - logger.error("Failed to redact reaction event", e); - } - } else { - const m = memberships.filter((m) => m.sender === userId); - const eventId = m[0].eventId!; - try { - const reaction = await client.sendEvent( - rtcSession.room.roomId, - EventType.Reaction, - { - "m.relates_to": { - rel_type: RelationType.Annotation, - event_id: eventId, - key: "🖐️", - }, - }, - ); + }) + .catch((e) => { + logger.error("Failed to redact reaction event", e); + }); + } + } else { + const m = memberships.filter((m) => m.sender === userId); + const eventId = m[0].eventId!; + client + .sendEvent(rtcSession.room.roomId, EventType.Reaction, { + "m.relates_to": { + rel_type: RelationType.Annotation, + event_id: eventId, + key: "🖐️", + }, + }) + .then((reaction) => { setReactionId(reaction.event_id); setRaisedHands([...raisedHands, userId]); logger.debug("Sent reaction event", reaction.event_id); - } catch (e) { + }) + .catch((e) => { logger.error("Failed to send reaction event", e); - } - } - } catch (e) { - logger.error(e); + }); } }, [ client, From 7ac5642245a47753f22917a4e6d155f7e0be509c Mon Sep 17 00:00:00 2001 From: Milton Moura Date: Tue, 10 Sep 2024 23:09:46 +0000 Subject: [PATCH 08/45] Removing RaiseHand.svg --- src/icons/RaiseHand.svg | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 src/icons/RaiseHand.svg diff --git a/src/icons/RaiseHand.svg b/src/icons/RaiseHand.svg deleted file mode 100644 index c791d6586..000000000 --- a/src/icons/RaiseHand.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - raise-hand - - - - - - \ No newline at end of file From 69a50fb2a8cd0a8794b8df689bc93fd4068be33c Mon Sep 17 00:00:00 2001 From: Milton Moura Date: Thu, 19 Sep 2024 11:33:48 +0100 Subject: [PATCH 09/45] Check for reaction & redaction capabilities in widget mode Signed-off-by: Milton Moura --- src/App.tsx | 45 ++++++++++++++++++---------------- src/ClientContext.tsx | 41 ++++++++++++++++++++++++++++--- src/room/InCallView.tsx | 39 +++++++++++++++-------------- src/room/useRaisedHands.tsx | 38 ---------------------------- src/tile/GridTile.tsx | 4 +-- src/useReactions.tsx | 49 +++++++++++++++++++++++++++++++++++++ 6 files changed, 133 insertions(+), 83 deletions(-) delete mode 100644 src/room/useRaisedHands.tsx create mode 100644 src/useReactions.tsx diff --git a/src/App.tsx b/src/App.tsx index 8d841dba7..9f0f5f149 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,6 +28,7 @@ import { Initializer } from "./initializer"; import { MediaDevicesProvider } from "./livekit/MediaDevicesContext"; import { widget } from "./widget"; import { useTheme } from "./useTheme"; +import { ReactionsProvider } from "./useReactions"; const SentryRoute = Sentry.withSentryRouting(Route); @@ -82,27 +83,29 @@ export const App: FC = ({ history }) => { {loaded ? ( - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + ) : ( diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 805b23137..1b40f3081 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -25,6 +25,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import { useTranslation } from "react-i18next"; import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync"; import { MatrixError } from "matrix-js-sdk/src/matrix"; +import { WidgetApi } from "matrix-widget-api"; import { ErrorView } from "./FullScreenView"; import { fallbackICEServerAllowed, initClient } from "./utils/matrix"; @@ -36,6 +37,7 @@ import { import { translatedError } from "./TranslatedError"; import { useEventTarget } from "./useEvents"; import { Config } from "./config/Config"; +import { useReactions } from "./useReactions"; declare global { interface Window { @@ -144,6 +146,7 @@ interface Props { } export const ClientProvider: FC = ({ children }) => { + const { setSupportsReactions } = useReactions(); const history = useHistory(); // null = signed out, undefined = loading @@ -188,11 +191,11 @@ export const ClientProvider: FC = ({ children }) => { saveSession({ ...session, passwordlessUser: false }); setInitClientState({ - client: initClientState.client, + ...initClientState, passwordlessUser: false, }); }, - [initClientState?.client], + [initClientState], ); const setClient = useCallback( @@ -206,6 +209,7 @@ export const ClientProvider: FC = ({ children }) => { if (clientParams) { saveSession(clientParams.session); setInitClientState({ + widgetApi: null, client: clientParams.client, passwordlessUser: clientParams.session.passwordlessUser, }); @@ -309,12 +313,40 @@ export const ClientProvider: FC = ({ children }) => { initClientState.client.on(ClientEvent.Sync, onSync); } + if (initClientState.widgetApi) { + let supportsReactions = true; + + const reactSend = initClientState.widgetApi.hasCapability( + "org.matrix.msc2762.send.event:m.reaction", + ); + const redactSend = initClientState.widgetApi.hasCapability( + "org.matrix.msc2762.send.event:m.room.redaction", + ); + const reactRcv = initClientState.widgetApi.hasCapability( + "org.matrix.msc2762.receive.event:m.reaction", + ); + const redactRcv = initClientState.widgetApi.hasCapability( + "org.matrix.msc2762.receive.event:m.room.redaction", + ); + + if (!reactSend || !reactRcv || !redactSend || !redactRcv) { + supportsReactions = false; + } + + setSupportsReactions(supportsReactions); + if (!supportsReactions) { + logger.warn("Widget does not support reactions"); + } else { + logger.warn("Widget does support reactions"); + } + } + return (): void => { if (initClientState.client) { initClientState.client.removeListener(ClientEvent.Sync, onSync); } }; - }, [initClientState, onSync]); + }, [initClientState, onSync, setSupportsReactions]); if (alreadyOpenedErr) { return ; @@ -326,6 +358,7 @@ export const ClientProvider: FC = ({ children }) => { }; type InitResult = { + widgetApi: WidgetApi | null; client: MatrixClient; passwordlessUser: boolean; }; @@ -336,6 +369,7 @@ async function loadClient(): Promise { logger.log("Using a matryoshka client"); const client = await widget.client; return { + widgetApi: widget.api, client, passwordlessUser: false, }; @@ -364,6 +398,7 @@ async function loadClient(): Promise { try { const client = await initClient(initClientParams, true); return { + widgetApi: null, client, passwordlessUser, }; diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 931ddaad4..b4e7a4a7e 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -85,8 +85,8 @@ import { makeOneOnOneLayout } from "../grid/OneOnOneLayout"; import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout"; import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout"; import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout"; -import { RaisedHandsProvider, useRaisedHands } from "./useRaisedHands"; import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; +import { useReactions } from "../useReactions"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -139,14 +139,12 @@ export const ActiveCall: FC = (props) => { return ( - - - + ); }; @@ -179,6 +177,8 @@ export const InCallView: FC = ({ connState, onShareClick, }) => { + const { supportsReactions } = useReactions(); + useWakeLock(); useEffect(() => { @@ -310,10 +310,9 @@ export const InCallView: FC = ({ ); const memberships = useMatrixRTCSessionMemberships(rtcSession); - const { raisedHands, setRaisedHands } = useRaisedHands(); + const { raisedHands, setRaisedHands } = useReactions(); const [reactionId, setReactionId] = useState(null); - const [username, localpart] = localParticipant.identity.split(":"); - const userId = `${username}:${localpart}`; + const userId = client.getUserId()!; const isHandRaised = raisedHands.includes(userId); useEffect(() => { @@ -638,13 +637,15 @@ export const InCallView: FC = ({ />, ); } - buttons.push( - , - ); + if (supportsReactions) { + buttons.push( + , + ); + } buttons.push(); } diff --git a/src/room/useRaisedHands.tsx b/src/room/useRaisedHands.tsx deleted file mode 100644 index 458ad59bd..000000000 --- a/src/room/useRaisedHands.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/* -Copyright 2024 Milton Moura - -SPDX-License-Identifier: AGPL-3.0-only -Please see LICENSE in the repository root for full details. -*/ - -import React, { createContext, useContext, useState, ReactNode } from "react"; - -interface RaisedHandsContextType { - raisedHands: string[]; - setRaisedHands: React.Dispatch>; -} - -const RaisedHandsContext = createContext( - undefined, -); - -export const useRaisedHands = (): RaisedHandsContextType => { - const context = useContext(RaisedHandsContext); - if (!context) { - throw new Error("useRaisedHands must be used within a RaisedHandsProvider"); - } - return context; -}; - -export const RaisedHandsProvider = ({ - children, -}: { - children: ReactNode; -}): JSX.Element => { - const [raisedHands, setRaisedHands] = useState([]); - return ( - - {children} - - ); -}; diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index 0dc434b6b..959ae089b 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -44,7 +44,7 @@ import { import { Slider } from "../Slider"; import { MediaView } from "./MediaView"; import { useLatest } from "../useLatest"; -import { useRaisedHands } from "../room/useRaisedHands"; +import { useReactions } from "../useReactions"; interface TileProps { className?: string; @@ -91,7 +91,7 @@ const UserMediaTile = forwardRef( }, [vm], ); - const { raisedHands } = useRaisedHands(); + const { raisedHands } = useReactions(); const raisedHand = raisedHands.includes(vm.member?.userId ?? ""); const MicIcon = audioEnabled ? MicOnSolidIcon : MicOffSolidIcon; diff --git a/src/useReactions.tsx b/src/useReactions.tsx new file mode 100644 index 000000000..9cd920c60 --- /dev/null +++ b/src/useReactions.tsx @@ -0,0 +1,49 @@ +/* +Copyright 2024 Milton Moura + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import React, { createContext, useContext, useState, ReactNode } from "react"; + +interface ReactionsContextType { + raisedHands: string[]; + setRaisedHands: React.Dispatch>; + supportsReactions: boolean; + setSupportsReactions: React.Dispatch>; +} + +const ReactionsContext = createContext( + undefined, +); + +export const useReactions = (): ReactionsContextType => { + const context = useContext(ReactionsContext); + if (!context) { + throw new Error("useReactions must be used within a ReactionsProvider"); + } + return context; +}; + +export const ReactionsProvider = ({ + children, +}: { + children: ReactNode; +}): JSX.Element => { + const [raisedHands, setRaisedHands] = useState([]); + const [supportsReactions, setSupportsReactions] = useState(true); + + return ( + + {children} + + ); +}; From 42a7b1ec2ac3a6608e1c7fa2de1a3ac5739a234a Mon Sep 17 00:00:00 2001 From: Milton Moura Date: Thu, 19 Sep 2024 11:59:06 +0100 Subject: [PATCH 10/45] Fix failing GridTile test Signed-off-by: Milton Moura --- src/tile/GridTile.test.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/tile/GridTile.test.tsx b/src/tile/GridTile.test.tsx index 4d518df45..ca0fa52c6 100644 --- a/src/tile/GridTile.test.tsx +++ b/src/tile/GridTile.test.tsx @@ -12,6 +12,7 @@ import { axe } from "vitest-axe"; import { GridTile } from "./GridTile"; import { withRemoteMedia } from "../utils/test"; +import { ReactionsProvider } from "../useReactions"; test("GridTile is accessible", async () => { await withRemoteMedia( @@ -26,14 +27,16 @@ test("GridTile is accessible", async () => { }, async (vm) => { const { container } = render( - {}} - targetWidth={300} - targetHeight={200} - showVideo - showSpeakingIndicators - />, + + {}} + targetWidth={300} + targetHeight={200} + showVideo + showSpeakingIndicators + /> + , ); expect(await axe(container)).toHaveNoViolations(); // Name should be visible From 16afb568fd2e2b6992a3b14b18d3abf514254ae0 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 25 Oct 2024 11:37:40 +0100 Subject: [PATCH 11/45] Center align hand raise. --- src/tile/MediaView.module.css | 8 ++++++++ src/tile/MediaView.tsx | 13 ++----------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/tile/MediaView.module.css b/src/tile/MediaView.module.css index 2d8aba401..4f8094200 100644 --- a/src/tile/MediaView.module.css +++ b/src/tile/MediaView.module.css @@ -106,6 +106,14 @@ unconditionally select the container so we can use cqmin units */ max-inline-size: 100%; } +.raisedHand > span { + width: var(--cpd-space-8x); + height: var(--cpd-space-8x); + display: inline-block; + text-align: center; + font-size: 22px; +} + .nameTag { grid-area: nameTag; padding: var(--cpd-space-1x); diff --git a/src/tile/MediaView.tsx b/src/tile/MediaView.tsx index c369625b4..87039d990 100644 --- a/src/tile/MediaView.tsx +++ b/src/tile/MediaView.tsx @@ -90,18 +90,9 @@ export const MediaView = forwardRef(
{raisedHand && (
-

+ ✋ -

+
)}
From 1c8e547b9a0aac563c314cbc55a382d6dd800b97 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 25 Oct 2024 17:09:45 +0100 Subject: [PATCH 12/45] Add support for displaying the duration of a raised hand. --- src/room/InCallView.tsx | 36 +++++++++++++++++---------- src/tile/GridTile.tsx | 3 +-- src/tile/MediaView.module.css | 20 +++++++++++++++ src/tile/MediaView.tsx | 46 +++++++++++++++++++++++++++++------ src/useReactions.tsx | 36 +++++++++++++++++++++++---- 5 files changed, 113 insertions(+), 28 deletions(-) diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index b4e7a4a7e..08454006e 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -310,10 +310,11 @@ export const InCallView: FC = ({ ); const memberships = useMatrixRTCSessionMemberships(rtcSession); - const { raisedHands, setRaisedHands } = useReactions(); + const { raisedHands, addRaisedHand, removeRaisedHand } = useReactions(); const [reactionId, setReactionId] = useState(null); const userId = client.getUserId()!; - const isHandRaised = raisedHands.includes(userId); + + const isHandRaised = !!raisedHands[userId]; useEffect(() => { const getLastReactionEvent = async ( @@ -335,17 +336,21 @@ export const InCallView: FC = ({ }; const fetchReactions = async (): Promise => { - const newRaisedHands = [...raisedHands]; for (const m of memberships) { + if (!m.sender) { + continue; + } const reaction = await getLastReactionEvent(m.eventId!); if (reaction && reaction.getType() === EventType.Reaction) { const content = reaction.getContent() as ReactionEventContent; if (content?.["m.relates_to"].key === "🖐️") { - newRaisedHands.push(m.sender!); + addRaisedHand(m.sender, new Date(m.createdTs())); + if (m.sender === userId) { + setReactionId(m.eventId!); + } } } } - setRaisedHands(newRaisedHands); }; void fetchReactions(); @@ -354,16 +359,21 @@ export const InCallView: FC = ({ useEffect(() => { const handleReactionEvent = (event: MatrixEvent): void => { + const sender = event.getSender(); + if (!sender) { + // Weird, skip. + return; + } if (event.getType() === EventType.Reaction) { // TODO: check if target of reaction is a call membership event const content = event.getContent() as ReactionEventContent; if (content?.["m.relates_to"].key === "🖐️") { - setRaisedHands([...raisedHands, event.getSender()!]); + addRaisedHand(sender, new Date(event.localTimestamp)); } } - if (event.getType() === EventType.RoomRedaction) { + if (event.getType() === EventType.RoomRedaction && event.getSender()) { // TODO: check target of redaction event - setRaisedHands(raisedHands.filter((id) => id !== event.getSender())); + removeRaisedHand(sender); } }; @@ -374,7 +384,7 @@ export const InCallView: FC = ({ client.on(MatrixRoomEvent.Timeline, handleReactionEvent); client.off(MatrixRoomEvent.Redaction, handleReactionEvent); }; - }, [client, raisedHands, setRaisedHands]); + }, [client, raisedHands, addRaisedHand, removeRaisedHand]); useEffect(() => { widget?.api.transport @@ -564,7 +574,7 @@ export const InCallView: FC = ({ .redactEvent(rtcSession.room.roomId, reactionId) .then(() => { setReactionId(null); - setRaisedHands(raisedHands.filter((id) => id !== userId)); + removeRaisedHand(userId); logger.debug("Redacted reaction event"); }) .catch((e) => { @@ -584,7 +594,7 @@ export const InCallView: FC = ({ }) .then((reaction) => { setReactionId(reaction.event_id); - setRaisedHands([...raisedHands, userId]); + addRaisedHand(userId, new Date()); logger.debug("Sent reaction event", reaction.event_id); }) .catch((e) => { @@ -595,10 +605,10 @@ export const InCallView: FC = ({ client, isHandRaised, memberships, - raisedHands, reactionId, rtcSession.room.roomId, - setRaisedHands, + addRaisedHand, + removeRaisedHand, setReactionId, userId, ]); diff --git a/src/tile/GridTile.tsx b/src/tile/GridTile.tsx index 959ae089b..d556fd687 100644 --- a/src/tile/GridTile.tsx +++ b/src/tile/GridTile.tsx @@ -92,7 +92,6 @@ const UserMediaTile = forwardRef( [vm], ); const { raisedHands } = useReactions(); - const raisedHand = raisedHands.includes(vm.member?.userId ?? ""); const MicIcon = audioEnabled ? MicOnSolidIcon : MicOffSolidIcon; @@ -147,7 +146,7 @@ const UserMediaTile = forwardRef( {menu} } - raisedHand={raisedHand} + raisedHandTime={raisedHands[vm.member?.userId ?? ""]} {...props} /> ); diff --git a/src/tile/MediaView.module.css b/src/tile/MediaView.module.css index 4f8094200..2dde20a79 100644 --- a/src/tile/MediaView.module.css +++ b/src/tile/MediaView.module.css @@ -90,6 +90,21 @@ unconditionally select the container so we can use cqmin units */ place-items: start; } +.raisedHandWidget { + display: flex; + background-color: var(--cpd-color-bg-subtle-primary); + border-radius: var(--cpd-radius-pill-effect); + color: var(--cpd-color-icon-secondary); + border: 1px solid var(--cpd-color-yellow-1200); +} + +.raisedHandWidget > p { + padding: var(--cpd-space-2x); + margin-top: auto; + margin-bottom: auto; + width: 4em; +} + .raisedHand { margin: var(--cpd-space-2x); padding: var(--cpd-space-2x); @@ -104,6 +119,7 @@ unconditionally select the container so we can use cqmin units */ box-shadow: var(--small-drop-shadow); box-sizing: border-box; max-inline-size: 100%; + max-width: fit-content; } .raisedHand > span { @@ -114,6 +130,10 @@ unconditionally select the container so we can use cqmin units */ font-size: 22px; } +.raisedHandBorder { + border: var(--cpd-space-1x) solid var(--cpd-color-yellow-1200); +} + .nameTag { grid-area: nameTag; padding: var(--cpd-space-1x); diff --git a/src/tile/MediaView.tsx b/src/tile/MediaView.tsx index 87039d990..85bb79fda 100644 --- a/src/tile/MediaView.tsx +++ b/src/tile/MediaView.tsx @@ -8,7 +8,13 @@ Please see LICENSE in the repository root for full details. import { TrackReferenceOrPlaceholder } from "@livekit/components-core"; import { animated } from "@react-spring/web"; import { RoomMember } from "matrix-js-sdk/src/matrix"; -import { ComponentProps, ReactNode, forwardRef } from "react"; +import { + ComponentProps, + ReactNode, + forwardRef, + useEffect, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import classNames from "classnames"; import { VideoTrack } from "@livekit/components-react"; @@ -32,7 +38,7 @@ interface Props extends ComponentProps { nameTagLeadingIcon?: ReactNode; displayName: string; primaryButton?: ReactNode; - raisedHand: boolean; + raisedHandTime?: Date; } export const MediaView = forwardRef( @@ -51,18 +57,39 @@ export const MediaView = forwardRef( nameTagLeadingIcon, displayName, primaryButton, - raisedHand, + raisedHandTime, ...props }, ref, ) => { const { t } = useTranslation(); + const [raisedHandDuration, setRaisedHandDuration] = useState(""); + + useEffect(() => { + if (!raisedHandTime) { + return; + } + setRaisedHandDuration("00:00"); + const to = setInterval(() => { + const totalSeconds = Math.ceil( + (new Date().getTime() - raisedHandTime.getTime()) / 1000, + ); + const seconds = totalSeconds % 60; + const minutes = Math.floor(totalSeconds / 60); + setRaisedHandDuration( + `${minutes < 10 ? "0" : ""}${minutes}:${seconds < 10 ? "0" : ""}${seconds}`, + ); + }, 1000); + return (): void => clearInterval(to); + }, [setRaisedHandDuration, raisedHandTime]); + return ( ( )}
- {raisedHand && ( -
- - ✋ - + {raisedHandTime && ( +
+
+ + ✋ + +
+

{raisedHandDuration}

)}
diff --git a/src/useReactions.tsx b/src/useReactions.tsx index 9cd920c60..78cca38f3 100644 --- a/src/useReactions.tsx +++ b/src/useReactions.tsx @@ -5,11 +5,18 @@ SPDX-License-Identifier: AGPL-3.0-only Please see LICENSE in the repository root for full details. */ -import React, { createContext, useContext, useState, ReactNode } from "react"; +import React, { + createContext, + useContext, + useState, + ReactNode, + useCallback, +} from "react"; interface ReactionsContextType { - raisedHands: string[]; - setRaisedHands: React.Dispatch>; + raisedHands: Record; + addRaisedHand: (userId: string, date: Date) => void; + removeRaisedHand: (userId: string) => void; supportsReactions: boolean; setSupportsReactions: React.Dispatch>; } @@ -31,14 +38,33 @@ export const ReactionsProvider = ({ }: { children: ReactNode; }): JSX.Element => { - const [raisedHands, setRaisedHands] = useState([]); + const [raisedHands, setRaisedHands] = useState>({}); const [supportsReactions, setSupportsReactions] = useState(true); + const addRaisedHand = useCallback( + (userId: string, time: Date) => { + setRaisedHands({ + ...raisedHands, + [userId]: time, + }); + }, + [raisedHands], + ); + + const removeRaisedHand = useCallback( + (userId: string) => { + delete raisedHands[userId]; + setRaisedHands(raisedHands); + }, + [raisedHands], + ); + return ( Date: Fri, 25 Oct 2024 17:10:14 +0100 Subject: [PATCH 13/45] Add a sound for when a hand is raised. --- src/res/sounds/raise-hand.ogg | Bin 0 -> 7297 bytes src/room/InCallView.tsx | 7 +++++++ 2 files changed, 7 insertions(+) create mode 100644 src/res/sounds/raise-hand.ogg diff --git a/src/res/sounds/raise-hand.ogg b/src/res/sounds/raise-hand.ogg new file mode 100644 index 0000000000000000000000000000000000000000..d1b350d5c8467e0c2b6e2e54e505d70786a53b9e GIT binary patch literal 7297 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 literal 0 HcmV?d00001 diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 08454006e..8cd2b47a0 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -87,6 +87,7 @@ import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout"; import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout"; import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; import { useReactions } from "../useReactions"; +import handSound from "../res/sounds/raise-hand.ogg?url"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -314,6 +315,8 @@ export const InCallView: FC = ({ const [reactionId, setReactionId] = useState(null); const userId = client.getUserId()!; + const handRaisePlayer = useRef(null); + const isHandRaised = !!raisedHands[userId]; useEffect(() => { @@ -369,6 +372,9 @@ export const InCallView: FC = ({ const content = event.getContent() as ReactionEventContent; if (content?.["m.relates_to"].key === "🖐️") { addRaisedHand(sender, new Date(event.localTimestamp)); + handRaisePlayer.current?.play().catch((ex) => { + logger.warn("Failed to play hand raise sound", ex); + }); } } if (event.getType() === EventType.RoomRedaction && event.getSender()) { @@ -737,6 +743,7 @@ export const InCallView: FC = ({ ))} {renderContent()} +
- {raisedHandTime && ( -
-
- - ✋ - -
-

{raisedHandDuration}

-
- )} +
{nameTagLeadingIcon} From 43b4fc0a0c734861dedc73cb7e909da3239c6e1f Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 28 Oct 2024 12:46:26 +0000 Subject: [PATCH 15/45] lint --- src/reactions/RaisedHandIndicator.test.tsx | 6 +++--- src/reactions/RaisedHandIndicator.tsx | 16 ++++++++++++---- src/tile/MediaView.tsx | 8 +------- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/reactions/RaisedHandIndicator.test.tsx b/src/reactions/RaisedHandIndicator.test.tsx index 8463a6256..6db9ada44 100644 --- a/src/reactions/RaisedHandIndicator.test.tsx +++ b/src/reactions/RaisedHandIndicator.test.tsx @@ -15,18 +15,18 @@ configure({ }); describe("RaisedHandIndicator", () => { - test("renders nothing when no hand has been raised", async () => { + test("renders nothing when no hand has been raised", () => { const { container } = render(); expect(container.firstChild).toBeNull(); }); - test("renders an indicator when a hand has been raised", async () => { + test("renders an indicator when a hand has been raised", () => { const dateTime = new Date(); const { container } = render( , ); expect(container.firstChild).toMatchSnapshot(); }); - test("renders an indicator when a hand has been raised with the expected time", async () => { + test("renders an indicator when a hand has been raised with the expected time", () => { const dateTime = new Date(new Date().getTime() - 60000); const { container } = render( , diff --git a/src/reactions/RaisedHandIndicator.tsx b/src/reactions/RaisedHandIndicator.tsx index 012fa571c..ba7e87881 100644 --- a/src/reactions/RaisedHandIndicator.tsx +++ b/src/reactions/RaisedHandIndicator.tsx @@ -1,18 +1,26 @@ -import { useEffect, useState } from "react"; +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import { ReactNode, useEffect, useState } from "react"; + import styles from "./RaisedHandIndicator.module.css"; export function RaisedHandIndicator({ raisedHandTime, }: { raisedHandTime?: Date; -}) { +}): ReactNode { const [raisedHandDuration, setRaisedHandDuration] = useState(""); useEffect(() => { if (!raisedHandTime) { return; } - const calculateTime = () => { + const calculateTime = (): void => { const totalSeconds = Math.ceil( (new Date().getTime() - raisedHandTime.getTime()) / 1000, ); @@ -22,8 +30,8 @@ export function RaisedHandIndicator({ `${minutes < 10 ? "0" : ""}${minutes}:${seconds < 10 ? "0" : ""}${seconds}`, ); }; - const to = setInterval(calculateTime, 1000); calculateTime(); + const to = setInterval(calculateTime, 1000); return (): void => clearInterval(to); }, [setRaisedHandDuration, raisedHandTime]); diff --git a/src/tile/MediaView.tsx b/src/tile/MediaView.tsx index 41a3bdc19..be2d4a628 100644 --- a/src/tile/MediaView.tsx +++ b/src/tile/MediaView.tsx @@ -8,13 +8,7 @@ Please see LICENSE in the repository root for full details. import { TrackReferenceOrPlaceholder } from "@livekit/components-core"; import { animated } from "@react-spring/web"; import { RoomMember } from "matrix-js-sdk/src/matrix"; -import { - ComponentProps, - ReactNode, - forwardRef, - useEffect, - useState, -} from "react"; +import { ComponentProps, ReactNode, forwardRef } from "react"; import { useTranslation } from "react-i18next"; import classNames from "classnames"; import { VideoTrack } from "@livekit/components-react"; From 4501e670b24a7b8ad8a48712507b301488831a8c Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 28 Oct 2024 15:57:55 +0000 Subject: [PATCH 16/45] Refactor into own files. --- src/App.tsx | 45 ++++--- src/ClientContext.tsx | 24 ++-- src/button/Button.tsx | 33 ----- src/button/RaisedHandToggleButton.tsx | 143 +++++++++++++++++++++ src/button/index.ts | 1 + src/room/InCallView.tsx | 171 ++++---------------------- src/tile/GridTile.test.tsx | 12 +- src/useReactions.tsx | 96 ++++++++++++++- 8 files changed, 308 insertions(+), 217 deletions(-) create mode 100644 src/button/RaisedHandToggleButton.tsx diff --git a/src/App.tsx b/src/App.tsx index 9f0f5f149..8d841dba7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,7 +28,6 @@ import { Initializer } from "./initializer"; import { MediaDevicesProvider } from "./livekit/MediaDevicesContext"; import { widget } from "./widget"; import { useTheme } from "./useTheme"; -import { ReactionsProvider } from "./useReactions"; const SentryRoute = Sentry.withSentryRouting(Route); @@ -83,29 +82,27 @@ export const App: FC = ({ history }) => { {loaded ? ( - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + ) : ( diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 1b40f3081..5a531c2a8 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -37,7 +37,6 @@ import { import { translatedError } from "./TranslatedError"; import { useEventTarget } from "./useEvents"; import { Config } from "./config/Config"; -import { useReactions } from "./useReactions"; declare global { interface Window { @@ -54,6 +53,9 @@ export type ValidClientState = { // 'Disconnected' rather than 'connected' because it tracks specifically // whether the client is supposed to be connected but is not disconnected: boolean; + supportedFeatures: { + reactions: boolean; + }; setClient: (params?: SetClientParams) => void; }; @@ -146,7 +148,6 @@ interface Props { } export const ClientProvider: FC = ({ children }) => { - const { setSupportsReactions } = useReactions(); const history = useHistory(); // null = signed out, undefined = loading @@ -258,6 +259,7 @@ export const ClientProvider: FC = ({ children }) => { ); const [isDisconnected, setIsDisconnected] = useState(false); + const [supportsReactions, setSupportsReactions] = useState(false); const state: ClientState | undefined = useMemo(() => { if (alreadyOpenedErr) { @@ -281,6 +283,9 @@ export const ClientProvider: FC = ({ children }) => { authenticated, setClient, disconnected: isDisconnected, + supportedFeatures: { + reactions: supportsReactions, + }, }; }, [ alreadyOpenedErr, @@ -289,6 +294,7 @@ export const ClientProvider: FC = ({ children }) => { logout, setClient, isDisconnected, + supportsReactions, ]); const onSync = useCallback( @@ -314,8 +320,6 @@ export const ClientProvider: FC = ({ children }) => { } if (initClientState.widgetApi) { - let supportsReactions = true; - const reactSend = initClientState.widgetApi.hasCapability( "org.matrix.msc2762.send.event:m.reaction", ); @@ -330,15 +334,13 @@ export const ClientProvider: FC = ({ children }) => { ); if (!reactSend || !reactRcv || !redactSend || !redactRcv) { - supportsReactions = false; - } - - setSupportsReactions(supportsReactions); - if (!supportsReactions) { logger.warn("Widget does not support reactions"); + setSupportsReactions(false); } else { - logger.warn("Widget does support reactions"); + setSupportsReactions(true); } + } else { + setSupportsReactions(true); } return (): void => { @@ -346,7 +348,7 @@ export const ClientProvider: FC = ({ children }) => { initClientState.client.removeListener(ClientEvent.Sync, onSync); } }; - }, [initClientState, onSync, setSupportsReactions]); + }, [initClientState, onSync]); if (alreadyOpenedErr) { return ; diff --git a/src/button/Button.tsx b/src/button/Button.tsx index 6012e5b9b..5d747a03e 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -91,39 +91,6 @@ export const ShareScreenButton: FC = ({ ); }; -interface RaiseHandButtonProps extends ComponentPropsWithoutRef<"button"> { - raised: boolean; -} -export const RaiseHandButton: FC = ({ - raised, - ...props -}) => { - const { t } = useTranslation(); - - return ( - - -

- ✋ -

-
-
- ); -}; - export const EndCallButton: FC> = ({ className, ...props diff --git a/src/button/RaisedHandToggleButton.tsx b/src/button/RaisedHandToggleButton.tsx new file mode 100644 index 000000000..502f62404 --- /dev/null +++ b/src/button/RaisedHandToggleButton.tsx @@ -0,0 +1,143 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import { Button as CpdButton, Tooltip } from "@vector-im/compound-web"; +import { + ComponentPropsWithoutRef, + FC, + ReactNode, + useCallback, + useState, +} from "react"; +import { useTranslation } from "react-i18next"; +import { logger } from "matrix-js-sdk/src/logger"; +import { EventType, RelationType } from "matrix-js-sdk/src/matrix"; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; + +import { useReactions } from "../useReactions"; +import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; + +interface InnerButtonButtonProps extends ComponentPropsWithoutRef<"button"> { + raised: boolean; +} +const InnerButton: FC = ({ raised, ...props }) => { + const { t } = useTranslation(); + + return ( + + +

+ ✋ +

+
+
+ ); +}; + +interface RaisedHandToggleButton { + key: string; + rtcSession: MatrixRTCSession; + client: MatrixClient; +} + +export function RaiseHandToggleButton({ + key, + client, + rtcSession, +}: RaisedHandToggleButton): ReactNode { + const { + raisedHands, + removeRaisedHand, + addRaisedHand, + myReactionId, + setMyReactionId, + } = useReactions(); + const [busy, setBusy] = useState(false); + const userId = client.getUserId()!; + const isHandRaised = !!raisedHands[userId]; + const memberships = useMatrixRTCSessionMemberships(rtcSession); + + const toggleRaisedHand = useCallback(() => { + if (isHandRaised) { + if (myReactionId) { + setBusy(true); + client + .redactEvent(rtcSession.room.roomId, myReactionId) + .then(() => { + logger.debug("Redacted raise hand event"); + setMyReactionId(null); + removeRaisedHand(userId); + }) + .catch((e) => { + logger.error("Failed to redact reaction event", e); + }) + .finally(() => { + setBusy(false); + }); + } + } else { + const myMembership = memberships.find((m) => m.sender === userId); + if (!myMembership?.eventId) { + logger.error("Cannot find own membership event"); + return; + } + setBusy(true); + client + .sendEvent(rtcSession.room.roomId, EventType.Reaction, { + "m.relates_to": { + rel_type: RelationType.Annotation, + event_id: myMembership.eventId, + key: "🖐️", + }, + }) + .then((reaction) => { + logger.debug("Sent raise hand event", reaction.event_id); + setMyReactionId(reaction.event_id); + addRaisedHand(userId, new Date()); + }) + .catch((e) => { + logger.error("Failed to send reaction event", e); + }) + .finally(() => { + setBusy(false); + }); + } + }, [ + client, + isHandRaised, + memberships, + myReactionId, + rtcSession.room.roomId, + addRaisedHand, + removeRaisedHand, + setMyReactionId, + userId, + ]); + + return ( + + ); +} diff --git a/src/button/index.ts b/src/button/index.ts index 178b58c0f..e4e7cfad2 100644 --- a/src/button/index.ts +++ b/src/button/index.ts @@ -7,3 +7,4 @@ Please see LICENSE in the repository root for full details. export * from "./Button"; export * from "./LinkButton"; +export * from "./RaisedHandToggleButton"; diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 8cd2b47a0..90f7c8402 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -11,10 +11,6 @@ import { useLocalParticipant, } from "@livekit/components-react"; import { ConnectionState, Room } from "livekit-client"; -import { - MatrixEvent, - RoomEvent as MatrixRoomEvent, -} from "matrix-js-sdk/src/matrix"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { FC, @@ -34,8 +30,6 @@ import classNames from "classnames"; import { BehaviorSubject, of } from "rxjs"; import { useObservableEagerState } from "observable-hooks"; import { logger } from "matrix-js-sdk/src/logger"; -import { EventType, RelationType } from "matrix-js-sdk/src/matrix"; -import { ReactionEventContent } from "matrix-js-sdk/src/types"; import LogoMark from "../icons/LogoMark.svg?react"; import LogoType from "../icons/LogoType.svg?react"; @@ -45,8 +39,8 @@ import { MicButton, VideoButton, ShareScreenButton, - RaiseHandButton, SettingsButton, + RaiseHandToggleButton, } from "../button"; import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; import { useUrlParams } from "../UrlParams"; @@ -85,8 +79,7 @@ import { makeOneOnOneLayout } from "../grid/OneOnOneLayout"; import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout"; import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout"; import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout"; -import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; -import { useReactions } from "../useReactions"; +import { ReactionsProvider, useReactions } from "../useReactions"; import handSound from "../res/sounds/raise-hand.ogg?url"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -140,12 +133,14 @@ export const ActiveCall: FC = (props) => { return ( - + + + ); }; @@ -178,7 +173,9 @@ export const InCallView: FC = ({ connState, onShareClick, }) => { - const { supportsReactions } = useReactions(); + const { supportsReactions, raisedHandCount } = useReactions(); + const [previousRaisedHandCount, setPreviousRaisedHandCount] = + useState(raisedHandCount); useWakeLock(); @@ -310,87 +307,19 @@ export const InCallView: FC = ({ [vm], ); - const memberships = useMatrixRTCSessionMemberships(rtcSession); - const { raisedHands, addRaisedHand, removeRaisedHand } = useReactions(); - const [reactionId, setReactionId] = useState(null); - const userId = client.getUserId()!; - + // Play a sound when the raised hand count increases. const handRaisePlayer = useRef(null); - - const isHandRaised = !!raisedHands[userId]; - - useEffect(() => { - const getLastReactionEvent = async ( - eventId: string, - ): Promise => { - return client - .relations( - rtcSession.room.roomId, - eventId, - RelationType.Annotation, - EventType.Reaction, - { - limit: 1, - }, - ) - .then((rels) => { - return rels.events.length > 0 ? rels.events[0] : undefined; - }); - }; - - const fetchReactions = async (): Promise => { - for (const m of memberships) { - if (!m.sender) { - continue; - } - const reaction = await getLastReactionEvent(m.eventId!); - if (reaction && reaction.getType() === EventType.Reaction) { - const content = reaction.getContent() as ReactionEventContent; - if (content?.["m.relates_to"].key === "🖐️") { - addRaisedHand(m.sender, new Date(m.createdTs())); - if (m.sender === userId) { - setReactionId(m.eventId!); - } - } - } - } - }; - - void fetchReactions(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - useEffect(() => { - const handleReactionEvent = (event: MatrixEvent): void => { - const sender = event.getSender(); - if (!sender) { - // Weird, skip. - return; - } - if (event.getType() === EventType.Reaction) { - // TODO: check if target of reaction is a call membership event - const content = event.getContent() as ReactionEventContent; - if (content?.["m.relates_to"].key === "🖐️") { - addRaisedHand(sender, new Date(event.localTimestamp)); - handRaisePlayer.current?.play().catch((ex) => { - logger.warn("Failed to play hand raise sound", ex); - }); - } - } - if (event.getType() === EventType.RoomRedaction && event.getSender()) { - // TODO: check target of redaction event - removeRaisedHand(sender); - } - }; - - client.on(MatrixRoomEvent.Timeline, handleReactionEvent); - client.on(MatrixRoomEvent.Redaction, handleReactionEvent); - - return (): void => { - client.on(MatrixRoomEvent.Timeline, handleReactionEvent); - client.off(MatrixRoomEvent.Redaction, handleReactionEvent); - }; - }, [client, raisedHands, addRaisedHand, removeRaisedHand]); + if (!handRaisePlayer.current) { + return; + } + if (previousRaisedHandCount < raisedHandCount) { + handRaisePlayer.current.play().catch((ex) => { + logger.warn("Failed to play raise hand sound", ex); + }); + } + setPreviousRaisedHandCount(raisedHandCount); + }, [raisedHandCount, handRaisePlayer, previousRaisedHandCount]); useEffect(() => { widget?.api.transport @@ -573,52 +502,6 @@ export const InCallView: FC = ({ .catch(logger.error); }, [localParticipant, isScreenShareEnabled]); - const toggleRaisedHand = useCallback(() => { - if (isHandRaised) { - if (reactionId) { - client - .redactEvent(rtcSession.room.roomId, reactionId) - .then(() => { - setReactionId(null); - removeRaisedHand(userId); - logger.debug("Redacted reaction event"); - }) - .catch((e) => { - logger.error("Failed to redact reaction event", e); - }); - } - } else { - const m = memberships.filter((m) => m.sender === userId); - const eventId = m[0].eventId!; - client - .sendEvent(rtcSession.room.roomId, EventType.Reaction, { - "m.relates_to": { - rel_type: RelationType.Annotation, - event_id: eventId, - key: "🖐️", - }, - }) - .then((reaction) => { - setReactionId(reaction.event_id); - addRaisedHand(userId, new Date()); - logger.debug("Sent reaction event", reaction.event_id); - }) - .catch((e) => { - logger.error("Failed to send reaction event", e); - }); - } - }, [ - client, - isHandRaised, - memberships, - reactionId, - rtcSession.room.roomId, - addRaisedHand, - removeRaisedHand, - setReactionId, - userId, - ]); - let footer: JSX.Element | null; if (noControls) { @@ -655,10 +538,10 @@ export const InCallView: FC = ({ } if (supportsReactions) { buttons.push( - , ); } diff --git a/src/tile/GridTile.test.tsx b/src/tile/GridTile.test.tsx index ca0fa52c6..9ba92afb1 100644 --- a/src/tile/GridTile.test.tsx +++ b/src/tile/GridTile.test.tsx @@ -9,6 +9,7 @@ import { RemoteTrackPublication } from "livekit-client"; import { test, expect } from "vitest"; import { render, screen } from "@testing-library/react"; import { axe } from "vitest-axe"; +import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import { GridTile } from "./GridTile"; import { withRemoteMedia } from "../utils/test"; @@ -26,8 +27,17 @@ test("GridTile is accessible", async () => { ({}) as Partial as RemoteTrackPublication, }, async (vm) => { + const fakeRtcSession = { + on: () => {}, + off: () => {}, + room: { + on: () => {}, + off: () => {}, + }, + memberships: [], + } as unknown as MatrixRTCSession; const { container } = render( - + {}} diff --git a/src/useReactions.tsx b/src/useReactions.tsx index 78cca38f3..72969a7b0 100644 --- a/src/useReactions.tsx +++ b/src/useReactions.tsx @@ -5,20 +5,34 @@ SPDX-License-Identifier: AGPL-3.0-only Please see LICENSE in the repository root for full details. */ -import React, { +import { + EventType, + MatrixEvent, + RelationType, + RoomEvent as MatrixRoomEvent, +} from "matrix-js-sdk/src/matrix"; +import { ReactionEventContent } from "matrix-js-sdk/src/types"; +import { createContext, useContext, useState, ReactNode, useCallback, + useEffect, } from "react"; +import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; + +import { useMatrixRTCSessionMemberships } from "./useMatrixRTCSessionMemberships"; +import { useClientState } from "./ClientContext"; interface ReactionsContextType { raisedHands: Record; + raisedHandCount: number; addRaisedHand: (userId: string, date: Date) => void; removeRaisedHand: (userId: string) => void; supportsReactions: boolean; - setSupportsReactions: React.Dispatch>; + myReactionId: string | null; + setMyReactionId: (id: string | null) => void; } const ReactionsContext = createContext( @@ -35,11 +49,19 @@ export const useReactions = (): ReactionsContextType => { export const ReactionsProvider = ({ children, + rtcSession, }: { children: ReactNode; + rtcSession: MatrixRTCSession; }): JSX.Element => { const [raisedHands, setRaisedHands] = useState>({}); - const [supportsReactions, setSupportsReactions] = useState(true); + const [myReactionId, setMyReactionId] = useState(null); + const [raisedHandCount, setRaisedHandCount] = useState(0); + const memberships = useMatrixRTCSessionMemberships(rtcSession); + const clientState = useClientState(); + const supportsReactions = + clientState?.state === "valid" && clientState.supportedFeatures.reactions; + const room = rtcSession.room; const addRaisedHand = useCallback( (userId: string, time: Date) => { @@ -47,6 +69,7 @@ export const ReactionsProvider = ({ ...raisedHands, [userId]: time, }); + setRaisedHandCount(Object.keys(raisedHands).length + 1); }, [raisedHands], ); @@ -55,18 +78,83 @@ export const ReactionsProvider = ({ (userId: string) => { delete raisedHands[userId]; setRaisedHands(raisedHands); + setRaisedHandCount(Object.keys(raisedHands).length); }, [raisedHands], ); + // Load any existing reactions. + useEffect(() => { + const getLastReactionEvent = (eventId: string): MatrixEvent | undefined => { + const relations = room.relations.getChildEventsForEvent( + eventId, + RelationType.Annotation, + EventType.Reaction, + ); + const allEvents = relations?.getRelations() ?? []; + return allEvents.length > 0 ? allEvents[0] : undefined; + }; + + const fetchReactions = (): void => { + for (const m of memberships) { + if (!m.sender) { + continue; + } + const reaction = getLastReactionEvent(m.eventId!); + if (reaction && reaction.getType() === EventType.Reaction) { + const content = reaction.getContent() as ReactionEventContent; + if (content?.["m.relates_to"].key === "🖐️") { + addRaisedHand(m.sender, new Date(m.createdTs())); + if (m.sender === room.client.getUserId()) { + setMyReactionId(m.eventId!); + } + } + } + } + }; + + void fetchReactions(); + }, [room, addRaisedHand, memberships]); + + useEffect(() => { + const handleReactionEvent = (event: MatrixEvent): void => { + const sender = event.getSender(); + if (!sender) { + // Skip any event without a sender. + return; + } + if (event.getType() === EventType.Reaction) { + // TODO: check if target of reaction is a call membership event + const content = event.getContent() as ReactionEventContent; + if (content?.["m.relates_to"].key === "🖐️") { + addRaisedHand(sender, new Date(event.localTimestamp)); + } + } + if (event.getType() === EventType.RoomRedaction && event.getSender()) { + // TODO: check target of redaction event + removeRaisedHand(sender); + } + }; + + room.on(MatrixRoomEvent.Timeline, handleReactionEvent); + room.on(MatrixRoomEvent.Redaction, handleReactionEvent); + + return (): void => { + room.off(MatrixRoomEvent.Timeline, handleReactionEvent); + room.off(MatrixRoomEvent.Redaction, handleReactionEvent); + }; + }, [room, raisedHands, addRaisedHand, removeRaisedHand]); + return ( {children} From 4a712dcaa24345ec4c9a7d654f785bf288e99bbe Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 28 Oct 2024 16:11:28 +0000 Subject: [PATCH 17/45] Redact the right thing. --- src/useReactions.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/useReactions.tsx b/src/useReactions.tsx index 72969a7b0..8a46ade96 100644 --- a/src/useReactions.tsx +++ b/src/useReactions.tsx @@ -97,16 +97,20 @@ export const ReactionsProvider = ({ const fetchReactions = (): void => { for (const m of memberships) { - if (!m.sender) { + if (!m.sender || !m.eventId) { + continue; + } + const reaction = getLastReactionEvent(m.eventId); + const eventId = reaction?.getId(); + if (!eventId) { continue; } - const reaction = getLastReactionEvent(m.eventId!); if (reaction && reaction.getType() === EventType.Reaction) { const content = reaction.getContent() as ReactionEventContent; - if (content?.["m.relates_to"].key === "🖐️") { + if (content?.["m.relates_to"]?.key === "🖐️") { addRaisedHand(m.sender, new Date(m.createdTs())); if (m.sender === room.client.getUserId()) { - setMyReactionId(m.eventId!); + setMyReactionId(eventId); } } } From ba921f8c6704e26608c50c701ae83dc61320c4e7 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 28 Oct 2024 16:23:17 +0000 Subject: [PATCH 18/45] Tidy up useEffect --- src/useReactions.tsx | 40 +++++++++++++++++++--------------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/src/useReactions.tsx b/src/useReactions.tsx index 8a46ade96..b665c43f0 100644 --- a/src/useReactions.tsx +++ b/src/useReactions.tsx @@ -95,30 +95,28 @@ export const ReactionsProvider = ({ return allEvents.length > 0 ? allEvents[0] : undefined; }; - const fetchReactions = (): void => { - for (const m of memberships) { - if (!m.sender || !m.eventId) { - continue; - } - const reaction = getLastReactionEvent(m.eventId); - const eventId = reaction?.getId(); - if (!eventId) { - continue; - } - if (reaction && reaction.getType() === EventType.Reaction) { - const content = reaction.getContent() as ReactionEventContent; - if (content?.["m.relates_to"]?.key === "🖐️") { - addRaisedHand(m.sender, new Date(m.createdTs())); - if (m.sender === room.client.getUserId()) { - setMyReactionId(eventId); - } + for (const m of memberships) { + if (!m.sender || !m.eventId) { + continue; + } + const reaction = getLastReactionEvent(m.eventId); + const eventId = reaction?.getId(); + if (!eventId) { + continue; + } + if (reaction && reaction.getType() === EventType.Reaction) { + const content = reaction.getContent() as ReactionEventContent; + if (content?.["m.relates_to"]?.key === "🖐️") { + addRaisedHand(m.sender, new Date(m.createdTs())); + if (m.sender === room.client.getUserId()) { + setMyReactionId(eventId); } } } - }; - - void fetchReactions(); - }, [room, addRaisedHand, memberships]); + } + // Deliberately ignoring addRaisedHand which was causing looping. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [room, memberships]); useEffect(() => { const handleReactionEvent = (event: MatrixEvent): void => { From 9d01e8c1298eeea1795e4b9284b385a46f3c10cd Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 28 Oct 2024 17:44:17 +0000 Subject: [PATCH 19/45] Lint tests --- src/useReactions.test.tsx | 164 ++++++++++++++++++++++++++++++++++++++ src/useReactions.tsx | 5 +- 2 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 src/useReactions.test.tsx diff --git a/src/useReactions.test.tsx b/src/useReactions.test.tsx new file mode 100644 index 000000000..6b11a71a7 --- /dev/null +++ b/src/useReactions.test.tsx @@ -0,0 +1,164 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only +Please see LICENSE in the repository root for full details. +*/ + +import { render } from "@testing-library/react"; +import { FC, ReactNode } from "react"; +import { describe, expect, test } from "vitest"; +import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; +import { + EventTimeline, + EventTimelineSet, + EventType, + MatrixClient, + MatrixEvent, + Room, + RoomEvent, +} from "matrix-js-sdk/src/matrix"; +import EventEmitter from "events"; +import { randomUUID } from "crypto"; + +import { ReactionsProvider, useReactions } from "./useReactions"; + +const membership = [ + "@alice:example.org", + "@bob:example.org", + "@charlie:example.org", +]; + +const TestComponent: FC = () => { + const { raisedHands } = useReactions(); + return ( +
+
    + {Object.entries(raisedHands).map(([userId, date]) => ( +
  • + {userId} + +
  • + ))} +
+
+ ); +}; + +const TestComponentWrapper = ({ room }: { room: MockRoom }): ReactNode => { + const fakeRtcSession = { + on: () => {}, + off: () => {}, + room, + memberships: membership.map((sender) => ({ + sender, + eventId: "!fake:event", + createdTs: (): Date => new Date(), + })), + } as unknown as MatrixRTCSession; + + return ( + + + + ); +}; + +function createReaction(sender: string): MatrixEvent { + return new MatrixEvent({ + sender, + type: EventType.Reaction, + origin_server_ts: new Date().getTime(), + content: { + "m.relates_to": { + key: "🖐️", + }, + }, + event_id: randomUUID(), + }); +} + +function createRedaction(sender: string): MatrixEvent { + return new MatrixEvent({ + sender, + type: EventType.RoomRedaction, + origin_server_ts: new Date().getTime(), + content: {}, + event_id: randomUUID(), + }); +} + +export class MockRoom extends EventEmitter { + public constructor(private readonly existingRelations: MatrixEvent[] = []) { + super(); + } + + public get client(): MatrixClient { + return { + getUserId: (): string => "@alice:example.org", + } as unknown as MatrixClient; + } + + public get relations(): Room["relations"] { + return { + getChildEventsForEvent: () => ({ + getRelations: () => this.existingRelations, + }), + } as unknown as Room["relations"]; + } + + public testSendReaction(sender: string): void { + this.emit( + RoomEvent.Timeline, + createReaction(sender), + this, + undefined, + false, + { + timeline: new EventTimeline(new EventTimelineSet(undefined)), + }, + ); + } +} + +describe("useReactions", () => { + test("starts with an empty list", () => { + const room = new MockRoom(); + const { queryByRole } = render(); + expect(queryByRole("list")?.children).to.have.lengthOf(0); + }); + test("handles incoming raised hand", () => { + const room = new MockRoom(); + const { queryByRole, rerender } = render( + , + ); + room.testSendReaction("@foo:bar"); + rerender(); + expect(queryByRole("list")?.children).to.have.lengthOf(1); + room.testSendReaction("@baz:bar"); + rerender(); + expect(queryByRole("list")?.children).to.have.lengthOf(2); + }); + test("handles incoming unraised hand", () => { + const room = new MockRoom(); + const { queryByRole, rerender } = render( + , + ); + room.testSendReaction("@foo:bar"); + rerender(); + expect(queryByRole("list")?.children).to.have.lengthOf(1); + room.emit( + RoomEvent.Redaction, + createRedaction("@foo:bar"), + room, + undefined, + ); + rerender(); + expect(queryByRole("list")?.children).to.have.lengthOf(0); + }); + test("handles loading events from cold", () => { + const room = new MockRoom([createReaction(membership[0])]); + const { queryByRole } = render(); + expect(queryByRole("list")?.children).to.have.lengthOf(1); + }); +}); diff --git a/src/useReactions.tsx b/src/useReactions.tsx index b665c43f0..1d09e1125 100644 --- a/src/useReactions.tsx +++ b/src/useReactions.tsx @@ -131,8 +131,7 @@ export const ReactionsProvider = ({ if (content?.["m.relates_to"].key === "🖐️") { addRaisedHand(sender, new Date(event.localTimestamp)); } - } - if (event.getType() === EventType.RoomRedaction && event.getSender()) { + } else if (event.getType() === EventType.RoomRedaction) { // TODO: check target of redaction event removeRaisedHand(sender); } @@ -145,7 +144,7 @@ export const ReactionsProvider = ({ room.off(MatrixRoomEvent.Timeline, handleReactionEvent); room.off(MatrixRoomEvent.Redaction, handleReactionEvent); }; - }, [room, raisedHands, addRaisedHand, removeRaisedHand]); + }, [room, addRaisedHand, removeRaisedHand]); return ( Date: Mon, 28 Oct 2024 17:45:12 +0000 Subject: [PATCH 20/45] Remove extra layer --- src/useReactions.test.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/useReactions.test.tsx b/src/useReactions.test.tsx index 6b11a71a7..2d27cf6db 100644 --- a/src/useReactions.test.tsx +++ b/src/useReactions.test.tsx @@ -32,16 +32,14 @@ const membership = [ const TestComponent: FC = () => { const { raisedHands } = useReactions(); return ( -
-
    - {Object.entries(raisedHands).map(([userId, date]) => ( -
  • - {userId} - -
  • - ))} -
-
+
    + {Object.entries(raisedHands).map(([userId, date]) => ( +
  • + {userId} + +
  • + ))} +
); }; From 33724efee368f00a46e8b1656e177775c5de472b Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 28 Oct 2024 18:00:39 +0000 Subject: [PATCH 21/45] Add better sound. (woosh) --- src/res/sounds/raise-hand.ogg | Bin 7297 -> 0 bytes src/room/InCallView.tsx | 8 ++++++-- src/sound/raise_hand.mp3 | Bin 0 -> 12960 bytes src/sound/raise_hand.ogg | Bin 0 -> 6497 bytes 4 files changed, 6 insertions(+), 2 deletions(-) delete mode 100644 src/res/sounds/raise-hand.ogg create mode 100644 src/sound/raise_hand.mp3 create mode 100644 src/sound/raise_hand.ogg diff --git a/src/res/sounds/raise-hand.ogg b/src/res/sounds/raise-hand.ogg deleted file mode 100644 index d1b350d5c8467e0c2b6e2e54e505d70786a53b9e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7297 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 diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 90f7c8402..239c94e26 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -80,7 +80,8 @@ import { makeSpotlightExpandedLayout } from "../grid/SpotlightExpandedLayout"; import { makeSpotlightLandscapeLayout } from "../grid/SpotlightLandscapeLayout"; import { makeSpotlightPortraitLayout } from "../grid/SpotlightPortraitLayout"; import { ReactionsProvider, useReactions } from "../useReactions"; -import handSound from "../res/sounds/raise-hand.ogg?url"; +import handSoundOgg from "../sound/raise_hand.ogg?url"; +import handSoundMp3 from "../sound/raise_hand.mp3?url"; const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); @@ -626,7 +627,10 @@ export const InCallView: FC = ({ ))} {renderContent()} -