From 48cf487e0a49e27a35b8312e3ccc7284a97cab84 Mon Sep 17 00:00:00 2001 From: Milton Moura Date: Wed, 7 Aug 2024 01:58:14 +0000 Subject: [PATCH] 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 2eb2b5c3..a5e4b8a3 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 5d747a03..aa479eab 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 00000000..c791d658 --- /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 d50be3c9..6a129f49 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 00000000..a93720e9 --- /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 a46ff472..0dc434b6 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 adde1c7b..2d8aba40 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 42a05603..93515f86 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}