diff --git a/src/frontend/panda.config.ts b/src/frontend/panda.config.ts index 088017dc..73328cfc 100644 --- a/src/frontend/panda.config.ts +++ b/src/frontend/panda.config.ts @@ -75,6 +75,12 @@ const config: Config = { '50%': { height: '4px' }, '100%': { height: '8px' }, }, + wave_hand: { + '0%': { transform: 'rotate(0deg)' }, + '20%': { transform: 'rotate(-20deg)' }, + '80%': { transform: 'rotate(20deg)' }, + '100%': { transform: 'rotate(0)' }, + }, }, tokens: defineTokens({ /* we take a few things from the panda preset but for now we clear out some stuff. diff --git a/src/frontend/src/features/rooms/livekit/api/lowerHandParticipant.ts b/src/frontend/src/features/rooms/livekit/api/lowerHandParticipant.ts new file mode 100644 index 00000000..e9d91fe4 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/api/lowerHandParticipant.ts @@ -0,0 +1,33 @@ +import { Participant } from 'livekit-client' +import { fetchServerApi } from './fetchServerApi' +import { buildServerApiUrl } from './buildServerApiUrl' +import { useRoomData } from '../hooks/useRoomData' + +export const useLowerHandParticipant = () => { + const data = useRoomData() + + const lowerHandParticipant = (participant: Participant) => { + if (!data || !data?.livekit) { + throw new Error('Room data is not available') + } + const newMetadata = JSON.parse(participant.metadata || '{}') + newMetadata.raised = !newMetadata.raised + return fetchServerApi( + buildServerApiUrl( + data.livekit.url, + 'twirp/livekit.RoomService/UpdateParticipant' + ), + data.livekit.token, + { + method: 'POST', + body: JSON.stringify({ + room: data.livekit.room, + identity: participant.identity, + metadata: JSON.stringify(newMetadata), + permission: participant.permissions, + }), + } + ) + } + return { lowerHandParticipant } +} diff --git a/src/frontend/src/features/rooms/livekit/api/lowerHandParticipants.ts b/src/frontend/src/features/rooms/livekit/api/lowerHandParticipants.ts new file mode 100644 index 00000000..2568b261 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/api/lowerHandParticipants.ts @@ -0,0 +1,18 @@ +import { Participant } from 'livekit-client' +import { useLowerHandParticipant } from '@/features/rooms/livekit/api/lowerHandParticipant' + +export const useLowerHandParticipants = () => { + const { lowerHandParticipant } = useLowerHandParticipant() + + const lowerHandParticipants = (participants: Array) => { + try { + const promises = participants.map((participant) => + lowerHandParticipant(participant) + ) + return Promise.all(promises) + } catch (error) { + throw new Error('An error occurred while lowering hands.') + } + } + return { lowerHandParticipants } +} diff --git a/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx b/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx index 182bee68..2db3aace 100644 --- a/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx +++ b/src/frontend/src/features/rooms/livekit/components/ParticipantTile.tsx @@ -25,6 +25,8 @@ import { } from '@livekit/components-core' import { Track } from 'livekit-client' import { ParticipantPlaceholder } from '@/features/rooms/livekit/components/ParticipantPlaceholder' +import { RiHand } from '@remixicon/react' +import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand' export function TrackRefContextIfNeeded( props: React.PropsWithChildren<{ @@ -84,6 +86,10 @@ export const ParticipantTile: ( [trackReference, layoutContext] ) + const { isHandRaised } = useRaisedHand({ + participant: trackReference.participant, + }) + return (
@@ -113,9 +119,26 @@ export const ParticipantTile: ( />
-
+
{trackReference.source === Track.Source.Camera ? ( <> + {isHandRaised && ( + + )} {isEncrypted && ( )} diff --git a/src/frontend/src/features/rooms/livekit/components/controls/HandToggle.tsx b/src/frontend/src/features/rooms/livekit/components/controls/HandToggle.tsx new file mode 100644 index 00000000..db917c4c --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/controls/HandToggle.tsx @@ -0,0 +1,39 @@ +import { useTranslation } from 'react-i18next' +import { RiHand } from '@remixicon/react' +import { ToggleButton } from '@/primitives' +import { css } from '@/styled-system/css' +import { useLocalParticipant } from '@livekit/components-react' +import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand' + +export const HandToggle = () => { + const { t } = useTranslation('rooms') + + const localParticipant = useLocalParticipant().localParticipant + const { isHandRaised, toggleRaisedHand } = useRaisedHand({ + participant: localParticipant, + }) + + const label = isHandRaised + ? t('controls.hand.lower') + : t('controls.hand.raise') + + return ( +
+ toggleRaisedHand()} + > + + +
+ ) +} diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Participants/HandRaisedListItem.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Participants/HandRaisedListItem.tsx new file mode 100644 index 00000000..0571d066 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/controls/Participants/HandRaisedListItem.tsx @@ -0,0 +1,78 @@ +import { css } from '@/styled-system/css' + +import { HStack } from '@/styled-system/jsx' +import { Text } from '@/primitives/Text' +import { useTranslation } from 'react-i18next' +import { Avatar } from '@/components/Avatar' +import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor' +import { Participant } from 'livekit-client' +import { isLocal } from '@/utils/livekit' +import { RiHand } from '@remixicon/react' +import { ListItemActionButton } from '@/features/rooms/livekit/components/controls/Participants/ListItemActionButton' +import { useLowerHandParticipant } from '@/features/rooms/livekit/api/lowerHandParticipant.ts' + +type HandRaisedListItemProps = { + participant: Participant +} + +export const HandRaisedListItem = ({ + participant, +}: HandRaisedListItemProps) => { + const { t } = useTranslation('rooms') + const name = participant.name || participant.identity + + const { lowerHandParticipant } = useLowerHandParticipant() + + return ( + + + + + + {name} + + {isLocal(participant) && ( + + ({t('participants.you')}) + + )} + + + lowerHandParticipant(participant)} + tooltip={t('participants.lowerParticipantHand', { name })} + > + + + + ) +} diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Participants/ListItemActionButton.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Participants/ListItemActionButton.tsx new file mode 100644 index 00000000..aa885a42 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/controls/Participants/ListItemActionButton.tsx @@ -0,0 +1,39 @@ +import { styled } from '@/styled-system/jsx' +import { + Button as RACButton, + ButtonProps as RACButtonsProps, +} from 'react-aria-components' +import { + TooltipWrapper, + TooltipWrapperProps, +} from '@/primitives/TooltipWrapper' + +const StyledButton = styled(RACButton, { + base: { + padding: '10px', + minWidth: '24px', + minHeight: '24px', + borderRadius: '50%', + backgroundColor: 'transparent', + transition: 'background 200ms', + '&[data-hovered]': { + backgroundColor: '#f5f5f5', + }, + '&[data-focused]': { + backgroundColor: '#f5f5f5', + }, + }, +}) + +export const ListItemActionButton = ({ + tooltip, + tooltipType = 'instant', + children, + ...props +}: RACButtonsProps & TooltipWrapperProps) => { + return ( + + {children} + + ) +} diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Participants/LowerAllHandsButton.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Participants/LowerAllHandsButton.tsx new file mode 100644 index 00000000..79a4ba1a --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/controls/Participants/LowerAllHandsButton.tsx @@ -0,0 +1,26 @@ +import { Button } from '@/primitives' +import { useLowerHandParticipants } from '@/features/rooms/livekit/api/lowerHandParticipants' +import { useTranslation } from 'react-i18next' +import { Participant } from 'livekit-client' + +type LowerAllHandsButtonProps = { + participants: Array +} + +export const LowerAllHandsButton = ({ + participants, +}: LowerAllHandsButtonProps) => { + const { lowerHandParticipants } = useLowerHandParticipants() + const { t } = useTranslation('rooms') + return ( + + ) +} diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantListItem.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantListItem.tsx index f0f88fe8..3dfa7a82 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantListItem.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantListItem.tsx @@ -7,7 +7,6 @@ import { Avatar } from '@/components/Avatar' import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor' import { Participant, Track } from 'livekit-client' import { isLocal } from '@/utils/livekit' -import { Button as RACButton } from 'react-aria-components' import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker' import { useIsSpeaking, @@ -15,10 +14,10 @@ import { } from '@livekit/components-react' import Source = Track.Source import { RiMicOffLine } from '@remixicon/react' -import { TooltipWrapper } from '@/primitives/TooltipWrapper.tsx' import { Button, Dialog, P } from '@/primitives' import { useState } from 'react' import { useMuteParticipant } from '@/features/rooms/livekit/api/muteParticipant' +import { ListItemActionButton } from '@/features/rooms/livekit/components/controls/Participants/ListItemActionButton' const MuteAlertDialog = ({ isOpen, @@ -67,37 +66,19 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => { return ( <> - !isMuted && setIsAlertOpen(true)} > - !isMuted && setIsAlertOpen(true)} - > - {isMuted ? ( - - ) : ( - - )} - - + {isMuted ? ( + + ) : ( + + )} + diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsCollapsableList.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsCollapsableList.tsx new file mode 100644 index 00000000..46699875 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsCollapsableList.tsx @@ -0,0 +1,109 @@ +import { useState } from 'react' +import { css } from '@/styled-system/css' +import { ToggleButton } from 'react-aria-components' +import { HStack, styled, VStack } from '@/styled-system/jsx' +import { RiArrowUpSLine } from '@remixicon/react' +import { Participant } from 'livekit-client' +import { useTranslation } from 'react-i18next' + +const ToggleHeader = styled(ToggleButton, { + base: { + minHeight: '40px', //fixme hardcoded value + paddingRight: '.5rem', + cursor: 'pointer', + display: 'flex', + justifyContent: 'space-between', + width: '100%', + alignItems: 'center', + transition: 'background 200ms', + borderTopRadius: '7px', + '&[data-hovered]': { + backgroundColor: '#f5f5f5', + }, + }, +}) + +const Container = styled('div', { + base: { + border: '1px solid #dadce0', + borderRadius: '8px', + margin: '0 .625rem', + }, +}) + +const ListContainer = styled(VStack, { + base: { + borderTop: '1px solid #dadce0', + alignItems: 'start', + overflowY: 'scroll', + overflowX: 'hidden', + minHeight: 0, + flexGrow: 1, + display: 'flex', + paddingY: '0.5rem', + paddingX: '1rem', + gap: 0, + }, +}) + +type ParticipantsCollapsableListProps = { + heading: string + participants: Array + renderParticipant: (participant: Participant) => JSX.Element + action?: () => JSX.Element +} + +export const ParticipantsCollapsableList = ({ + heading, + participants, + renderParticipant, + action, +}: ParticipantsCollapsableListProps) => { + const { t } = useTranslation('rooms') + const [isOpen, setIsOpen] = useState(true) + const label = t(`participants.collapsable.${isOpen ? 'close' : 'open'}`, { + name: heading, + }) + return ( + + setIsOpen(!isOpen)} + style={{ + borderRadius: !isOpen ? '7px' : undefined, + }} + > + +
+ {heading} +
+
{participants?.length || 0}
+
+ +
+ {isOpen && ( + + {action && action()} + {participants.map((participant) => renderParticipant(participant))} + + )} +
+ ) +} diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsList.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsList.tsx index dd82ba5c..f20da550 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsList.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Participants/ParticipantsList.tsx @@ -2,14 +2,16 @@ import { css } from '@/styled-system/css' import { useParticipants } from '@livekit/components-react' import { Heading } from 'react-aria-components' -import { Box, Button, Div } from '@/primitives' +import { Box, Button, Div, H } from '@/primitives' import { text } from '@/primitives/Text' import { RiCloseLine } from '@remixicon/react' import { participantsStore } from '@/stores/participants' import { useTranslation } from 'react-i18next' import { allParticipantRoomEvents } from '@/features/rooms/livekit/constants/events' import { ParticipantListItem } from '@/features/rooms/livekit/components/controls/Participants/ParticipantListItem' -import { VStack } from '@/styled-system/jsx' +import { ParticipantsCollapsableList } from '@/features/rooms/livekit/components/controls/Participants/ParticipantsCollapsableList' +import { HandRaisedListItem } from '@/features/rooms/livekit/components/controls/Participants/HandRaisedListItem' +import { LowerAllHandsButton } from '@/features/rooms/livekit/components/controls/Participants/LowerAllHandsButton' // TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short. export const ParticipantsList = () => { @@ -35,29 +37,35 @@ export const ParticipantsList = () => { ...sortedRemoteParticipants, ] + const raisedHandParticipants = participants.filter((participant) => { + const data = JSON.parse(participant.metadata || '{}') + return data.raised + }) + // TODO - extract inline styling in a centralized styling file, and avoid magic numbers return ( - - {t('participants.heading')}{' '} - - {participants?.length} - + + {t('participants.heading')}
- {sortedParticipants?.length > 0 && ( - + - {sortedParticipants.map((participant) => ( + {t('participants.subheading').toUpperCase()} + + {raisedHandParticipants.length > 0 && ( +
+ ( + + )} + action={() => ( + + )} + /> +
+ )} + ( - ))} -
- )} + )} + /> +
) } diff --git a/src/frontend/src/features/rooms/livekit/hooks/useRaisedHand.tsx b/src/frontend/src/features/rooms/livekit/hooks/useRaisedHand.tsx new file mode 100644 index 00000000..a2539bb0 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/hooks/useRaisedHand.tsx @@ -0,0 +1,22 @@ +import { LocalParticipant, Participant } from 'livekit-client' +import { useParticipantInfo } from '@livekit/components-react' +import { isLocal } from '@/utils/livekit' + +type useRaisedHandProps = { + participant: Participant +} + +export function useRaisedHand({ participant }: useRaisedHandProps) { + const { metadata } = useParticipantInfo({ participant }) + const parsedMetadata = JSON.parse(metadata || '{}') + + const toggleRaisedHand = () => { + if (isLocal(participant)) { + parsedMetadata.raised = !parsedMetadata.raised + const localParticipant = participant as LocalParticipant + localParticipant.setMetadata(JSON.stringify(parsedMetadata)) + } + } + + return { isHandRaised: parsedMetadata.raised, toggleRaisedHand } +} diff --git a/src/frontend/src/features/rooms/livekit/prefabs/ControlBar.tsx b/src/frontend/src/features/rooms/livekit/prefabs/ControlBar.tsx index 6361eef8..6fe0b89d 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/ControlBar.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/ControlBar.tsx @@ -19,6 +19,7 @@ import { useTranslation } from 'react-i18next' import { OptionsButton } from '../components/controls/Options/OptionsButton' import { ParticipantsToggle } from '@/features/rooms/livekit/components/controls/Participants/ParticipantsToggle' import { ChatToggle } from '@/features/rooms/livekit/components/controls/ChatToggle' +import { HandToggle } from '@/features/rooms/livekit/components/controls/HandToggle' /** @public */ export type ControlBarControls = { @@ -183,6 +184,7 @@ export function ControlBar({ )} )} + diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json index 7e6a3b1b..f9d85d01 100644 --- a/src/frontend/src/locales/de/rooms.json +++ b/src/frontend/src/locales/de/rooms.json @@ -32,6 +32,10 @@ "open": "", "closed": "" }, + "hand": { + "raise": "", + "lower": "" + }, "leave": "", "participants": { "open": "", @@ -49,13 +53,22 @@ }, "participants": { "heading": "", + "subheading": "", "closeButton": "", + "contributors": "", + "collapsable": { + "open": "", + "close": "" + }, "you": "", "muteParticipant": "", "muteParticipantAlert": { "description": "", "confirm": "", "cancel": "" - } + }, + "raisedHands": "", + "lowerParticipantHand": "", + "lowerParticipantsHand": "" } } diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index 42f44adb..633a3baf 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -32,6 +32,10 @@ "open": "Close the chat", "closed": "Open the chat" }, + "hand": { + "raise": "Raise hand", + "lower": "Lower hand" + }, "leave": "Leave", "participants": { "open": "Hide everyone", @@ -49,13 +53,22 @@ }, "participants": { "heading": "Participants", + "subheading": "In room", "closeButton": "Hide participants", "you": "You", + "contributors": "Contributors", + "collapsable": { + "open": "Open {{name}} list", + "close": "Close {{name}} list" + }, "muteParticipant": "Close the mic of {{name}}", "muteParticipantAlert": { "description": "Mute {{name}} for all participants? {{name}} is the only person who can unmute themselves.", "confirm": "Mute", "cancel": "Cancel" - } + }, + "raisedHands": "Raised hands", + "lowerParticipantHand": "Lower {{name}}'s hand", + "lowerParticipantsHand": "Lower all hands" } } diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index 14a44e67..29008235 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -32,6 +32,10 @@ "open": "Masquer le chat", "closed": "Afficher le chat" }, + "hand": { + "raise": "Lever la main", + "lower": "Baisser la main" + }, "leave": "Quitter", "participants": { "open": "Masquer les participants", @@ -49,13 +53,22 @@ }, "participants": { "heading": "Participants", + "subheading": "Dans la réunion", "closeButton": "Masquer les participants", "you": "Vous", + "contributors": "Contributeurs", + "collapsable": { + "open": "Ouvrir la liste {{name}}", + "close": "Fermer la liste {{name}}" + }, "muteParticipant": "Couper le micro de {{name}}", "muteParticipantAlert": { "description": "Couper le micro de {{name}} pour tous les participants ? {{name}} est la seule personne habilitée à réactiver son micro", "confirm": "Couper le micro", "cancel": "Annuler" - } + }, + "raisedHands": "Mains levées", + "lowerParticipantHand": "Baisser la main de {{name}}", + "lowerParticipantsHand": "Baisser la main de tous les participants" } } diff --git a/src/frontend/src/primitives/buttonRecipe.ts b/src/frontend/src/primitives/buttonRecipe.ts index 52b2561b..a40f3064 100644 --- a/src/frontend/src/primitives/buttonRecipe.ts +++ b/src/frontend/src/primitives/buttonRecipe.ts @@ -140,6 +140,7 @@ export const buttonRecipe = cva({ legacyStyle: { true: { borderColor: 'gray.400', + transition: 'border 200ms, background 200ms, color 200ms', '&[data-hovered]': { borderColor: 'gray.500', }, @@ -147,10 +148,12 @@ export const buttonRecipe = cva({ borderColor: 'gray.500', }, '&[data-selected]': { - background: '#e5e7eb', - borderColor: 'gray.400', + backgroundColor: '#1d4ed8', + color: 'white', + borderColor: 'gray.500', '&[data-hovered]': { - backgroundColor: 'gray.300', + borderColor: '#6b7280', + backgroundColor: '#1e40af', }, }, },