diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index b4e7a4a7..08454006 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 959ae089..d556fd68 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 4f809420..2dde20a7 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 87039d99..85bb79fd 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 9cd920c6..78cca38f 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 (