diff --git a/packages/core/src/components/participantsToggle.ts b/packages/core/src/components/participantsToggle.ts new file mode 100644 index 000000000..a7cfbcfa7 --- /dev/null +++ b/packages/core/src/components/participantsToggle.ts @@ -0,0 +1,6 @@ +import { prefixClass } from '../styles-interface'; + +export function setupParticipantsToggle() { + const className: string = [prefixClass('button'), prefixClass('participants-toggle')].join(' '); + return { className }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 432c8a028..ef4d51c17 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -19,6 +19,7 @@ export * from './components/chat'; export * from './components/startAudio'; export * from './components/startVideo'; export * from './components/chatToggle'; +export * from './components/participantsToggle'; export * from './components/focusToggle'; export * from './components/clearPinButton'; export * from './components/room'; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index cdbf36aa4..31474a764 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -12,11 +12,13 @@ export type WidgetState = { showChat: boolean; unreadMessages: number; showSettings?: boolean; + showParticipants?: boolean; }; export const WIDGET_DEFAULT_STATE: WidgetState = { showChat: false, unreadMessages: 0, showSettings: false, + showParticipants: false, }; // ## Track Source Types diff --git a/packages/react/etc/components-react.api.md b/packages/react/etc/components-react.api.md index b62d1c754..98d19e56d 100644 --- a/packages/react/etc/components-react.api.md +++ b/packages/react/etc/components-react.api.md @@ -283,6 +283,7 @@ export type ControlBarControls = { screenShare?: boolean; leave?: boolean; settings?: boolean; + participants?: boolean; }; // @public (undocumented) @@ -553,6 +554,25 @@ export interface ParticipantNameProps extends React_2.HTMLAttributes) => React_2.JSX.Element; +// @public +export function Participants({ ...props }: ParticipantsProps): React_2.JSX.Element; + +// Warning: (ae-internal-missing-underscore) The name "ParticipantsIcon" should be prefixed with an underscore because the declaration is marked as @internal +// +// @internal (undocumented) +export const ParticipantsIcon: (props: SVGProps) => React_2.JSX.Element; + +// @public (undocumented) +export interface ParticipantsProps extends React_2.HTMLAttributes { +} + +// @public +export const ParticipantsToggle: (props: ParticipantsToggleProps & React_2.RefAttributes) => React_2.ReactNode; + +// @public (undocumented) +export interface ParticipantsToggleProps extends React_2.ButtonHTMLAttributes { +} + // @public export const ParticipantTile: (props: ParticipantTileProps & React_2.RefAttributes) => React_2.ReactNode; @@ -1107,6 +1127,22 @@ export interface UseParticipantsOptions { updateOnlyOn?: RoomEvent[]; } +// @public +export function useParticipantsToggle({ props }: UseParticipantsToggleProps): { + mergedProps: React_2.ButtonHTMLAttributes & { + className: string; + onClick: () => void; + 'aria-pressed': string; + 'data-lk-participant-count': string; + }; +}; + +// @public (undocumented) +export interface UseParticipantsToggleProps { + // (undocumented) + props: React_2.ButtonHTMLAttributes; +} + // @public export function useParticipantTile({ trackRef, onParticipantClick, disableSpeakingIndicator, htmlProps, }: UseParticipantTileProps): { elementProps: React_2.HTMLAttributes; diff --git a/packages/react/src/assets/icons/ParticipantsIcon.tsx b/packages/react/src/assets/icons/ParticipantsIcon.tsx new file mode 100644 index 000000000..592602473 --- /dev/null +++ b/packages/react/src/assets/icons/ParticipantsIcon.tsx @@ -0,0 +1,25 @@ +/** + * WARNING: This file was auto-generated by svgr. Do not edit. + */ +import * as React from 'react'; +import type { SVGProps } from 'react'; +/** + * @internal + */ +const SvgParticipantsIcon = (props: SVGProps) => ( + + + + +); +export default SvgParticipantsIcon; diff --git a/packages/react/src/assets/icons/index.ts b/packages/react/src/assets/icons/index.ts index 7425b1e9a..4d74a860b 100644 --- a/packages/react/src/assets/icons/index.ts +++ b/packages/react/src/assets/icons/index.ts @@ -9,6 +9,7 @@ export { default as LeaveIcon } from './LeaveIcon'; export { default as LockLockedIcon } from './LockLockedIcon'; export { default as MicDisabledIcon } from './MicDisabledIcon'; export { default as MicIcon } from './MicIcon'; +export { default as ParticipantsIcon } from './ParticipantsIcon'; export { default as QualityExcellentIcon } from './QualityExcellentIcon'; export { default as QualityGoodIcon } from './QualityGoodIcon'; export { default as QualityPoorIcon } from './QualityPoorIcon'; diff --git a/packages/react/src/components/controls/ParticipantsToggle.tsx b/packages/react/src/components/controls/ParticipantsToggle.tsx new file mode 100644 index 000000000..e9109a7dc --- /dev/null +++ b/packages/react/src/components/controls/ParticipantsToggle.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { useParticipantsToggle } from '../../hooks'; + +/** @public */ +export interface ParticipantsToggleProps extends React.ButtonHTMLAttributes {} + +/** + * The `ParticipantsToggle` component is a button that toggles the visibility of the participants panel. + * @remarks + * For the component to have any effect it has to live inside a `LayoutContext` context. + * + * @example + * ```tsx + * + * + * + * ``` + * @public + */ +export const ParticipantsToggle: ( + props: ParticipantsToggleProps & React.RefAttributes, +) => React.ReactNode = /* @__PURE__ */ React.forwardRef( + function ParticipantsToggle(props: ParticipantsToggleProps, ref) { + const { mergedProps } = useParticipantsToggle({ props }); + + return ( + + ); + }, +); diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 235d6c878..d5c0e3277 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -1,6 +1,7 @@ export * from './controls/ClearPinButton'; export * from './ConnectionState'; export * from './controls/ChatToggle'; +export * from './controls/ParticipantsToggle'; export * from './controls/DisconnectButton'; export * from './controls/FocusToggle'; export * from './controls/MediaDeviceSelect'; diff --git a/packages/react/src/context/chat-context.ts b/packages/react/src/context/chat-context.ts index 8499063b6..6134420aa 100644 --- a/packages/react/src/context/chat-context.ts +++ b/packages/react/src/context/chat-context.ts @@ -7,7 +7,8 @@ export type ChatContextAction = | { msg: 'hide_chat' } | { msg: 'toggle_chat' } | { msg: 'unread_msg'; count: number } - | { msg: 'toggle_settings' }; + | { msg: 'toggle_settings' } + | { msg: 'toggle_participants' }; /** @internal */ export type WidgetContextType = { @@ -18,19 +19,26 @@ export type WidgetContextType = { /** @internal */ export function chatReducer(state: WidgetState, action: ChatContextAction): WidgetState { if (action.msg === 'show_chat') { - return { ...state, showChat: true, unreadMessages: 0 }; + return { ...state, showChat: true, unreadMessages: 0, showParticipants: false }; } else if (action.msg === 'hide_chat') { return { ...state, showChat: false }; } else if (action.msg === 'toggle_chat') { const newState = { ...state, showChat: !state.showChat }; if (newState.showChat === true) { newState.unreadMessages = 0; + newState.showParticipants = false; } return newState; } else if (action.msg === 'unread_msg') { return { ...state, unreadMessages: action.count }; } else if (action.msg === 'toggle_settings') { return { ...state, showSettings: !state.showSettings }; + } else if (action.msg === 'toggle_participants') { + const newState = { ...state, showParticipants: !state.showParticipants }; + if (newState.showParticipants === true) { + newState.showChat = false; + } + return newState; } else { return { ...state }; } diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index c47c201af..cd9cc1adf 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -38,6 +38,7 @@ export { type UseStartAudioProps, useStartAudio } from './useStartAudio'; export { type UseStartVideoProps, useStartVideo } from './useStartVideo'; export { type UseSwipeOptions, useSwipe } from './useSwipe'; export { type UseChatToggleProps, useChatToggle } from './useChatToggle'; +export { type UseParticipantsToggleProps, useParticipantsToggle } from './useParticipantsToggle'; export { type UseTokenOptions, type UserInfo, useToken } from './useToken'; export { useTrackMutedIndicator } from './useTrackMutedIndicator'; export { type UseTrackToggleProps, useTrackToggle } from './useTrackToggle'; diff --git a/packages/react/src/hooks/useParticipantsToggle.ts b/packages/react/src/hooks/useParticipantsToggle.ts new file mode 100644 index 000000000..f1087e6a4 --- /dev/null +++ b/packages/react/src/hooks/useParticipantsToggle.ts @@ -0,0 +1,35 @@ +import { setupParticipantsToggle } from '@livekit/components-core'; +import { useLayoutContext } from '../context'; +import { mergeProps } from '../mergeProps'; +import * as React from 'react'; +import { useParticipants } from './useParticipants'; + +/** @public */ +export interface UseParticipantsToggleProps { + props: React.ButtonHTMLAttributes; +} + +/** + * The `useParticipantsToggle` hook provides state and functions for toggling the participants panel. + * @remarks + * Depends on the `LayoutContext` to work properly. + * @public + */ +export function useParticipantsToggle({ props }: UseParticipantsToggleProps) { + const { dispatch, state } = useLayoutContext().widget; + const { className } = React.useMemo(() => setupParticipantsToggle(), []); + const participants = useParticipants(); + + const mergedProps = React.useMemo(() => { + return mergeProps(props, { + className, + onClick: () => { + if (dispatch) dispatch({ msg: 'toggle_participants' }); + }, + 'aria-pressed': state?.showParticipants ? 'true' : 'false', + 'data-lk-participant-count': participants.length.toFixed(0), + }); + }, [props, className, dispatch, state, participants.length]); + + return { mergedProps }; +} diff --git a/packages/react/src/prefabs/ControlBar.tsx b/packages/react/src/prefabs/ControlBar.tsx index 2b004834e..fdf68efee 100644 --- a/packages/react/src/prefabs/ControlBar.tsx +++ b/packages/react/src/prefabs/ControlBar.tsx @@ -3,8 +3,9 @@ import * as React from 'react'; import { MediaDeviceMenu } from './MediaDeviceMenu'; import { DisconnectButton } from '../components/controls/DisconnectButton'; import { TrackToggle } from '../components/controls/TrackToggle'; -import { ChatIcon, GearIcon, LeaveIcon } from '../assets/icons'; +import { ChatIcon, GearIcon, LeaveIcon, ParticipantsIcon } from '../assets/icons'; import { ChatToggle } from '../components/controls/ChatToggle'; +import { ParticipantsToggle } from '../components/controls/ParticipantsToggle'; import { useLocalParticipantPermissions, usePersistentUserChoices } from '../hooks'; import { useMediaQuery } from '../hooks/internal'; import { useMaybeLayoutContext } from '../context'; @@ -21,6 +22,7 @@ export type ControlBarControls = { screenShare?: boolean; leave?: boolean; settings?: boolean; + participants?: boolean; }; const trackSourceToProtocol = (source: Track.Source) => { @@ -74,14 +76,14 @@ export function ControlBar({ onDeviceError, ...props }: ControlBarProps) { - const [isChatOpen, setIsChatOpen] = React.useState(false); + const [isSidebarOpen, setIsSidebarOpen] = React.useState(false); const layoutContext = useMaybeLayoutContext(); React.useEffect(() => { - if (layoutContext?.widget.state?.showChat !== undefined) { - setIsChatOpen(layoutContext?.widget.state?.showChat); - } - }, [layoutContext?.widget.state?.showChat]); - const isTooLittleSpace = useMediaQuery(`(max-width: ${isChatOpen ? 1000 : 760}px)`); + const showChat = layoutContext?.widget.state?.showChat ?? false; + const showParticipants = layoutContext?.widget.state?.showParticipants ?? false; + setIsSidebarOpen(showChat || showParticipants); + }, [layoutContext?.widget.state?.showChat, layoutContext?.widget.state?.showParticipants]); + const isTooLittleSpace = useMediaQuery(`(max-width: ${isSidebarOpen ? 1000 : 760}px)`); const defaultVariation = isTooLittleSpace ? 'minimal' : 'verbose'; variation ??= defaultVariation; @@ -203,6 +205,12 @@ export function ControlBar({ {showText && (isScreenShareEnabled ? 'Stop screen share' : 'Share screen')} )} + {visibleControls.participants && ( + + {showIcon && } + {showText && 'Participants'} + + )} {visibleControls.chat && ( {showIcon && } diff --git a/packages/react/src/prefabs/Participants.tsx b/packages/react/src/prefabs/Participants.tsx new file mode 100644 index 000000000..a96579050 --- /dev/null +++ b/packages/react/src/prefabs/Participants.tsx @@ -0,0 +1,55 @@ +import { Track } from 'livekit-client'; +import * as React from 'react'; +import { useMaybeLayoutContext } from '../context'; +import { ParticipantsToggle } from '../components'; +import { ParticipantName } from '../components/participant/ParticipantName'; +import { TrackMutedIndicator } from '../components/participant/TrackMutedIndicator'; +import { useParticipants } from '../hooks/useParticipants'; +import { useSortedParticipants } from '../hooks/useSortedParticipants'; +import { ParticipantContext } from '../context'; +import ChatCloseIcon from '../assets/icons/ChatCloseIcon'; + +/** @public */ +export interface ParticipantsProps extends React.HTMLAttributes {} + +/** + * The `Participants` component displays a list of all participants in the room + * with their name and audio/video muted indicators. + * + * @example + * ```tsx + * + * + * + * ``` + * @public + */ +export function Participants({ ...props }: ParticipantsProps) { + const participants = useParticipants(); + const sortedParticipants = useSortedParticipants(participants); + const layoutContext = useMaybeLayoutContext(); + + return ( +
+
+ Participants ({participants.length}) + {layoutContext && ( + + + + )} +
+
    + {sortedParticipants.map((participant) => ( +
  • + + + + + +
  • + ))} +
+
+ ); +} diff --git a/packages/react/src/prefabs/VideoConference.tsx b/packages/react/src/prefabs/VideoConference.tsx index 62af5d61f..e89087702 100644 --- a/packages/react/src/prefabs/VideoConference.tsx +++ b/packages/react/src/prefabs/VideoConference.tsx @@ -22,6 +22,7 @@ import { useCreateLayoutContext } from '../context'; import { usePinnedTracks, useTracks } from '../hooks'; import { Chat } from './Chat'; import { ControlBar } from './ControlBar'; +import { Participants } from './Participants'; import { useWarnAboutMissingStyles } from '../hooks/useWarnAboutMissingStyles'; /** @@ -64,6 +65,7 @@ export function VideoConference({ showChat: false, unreadMessages: 0, showSettings: false, + showParticipants: false, }); const lastAutoFocusedScreenShareTrack = React.useRef(null); @@ -155,7 +157,9 @@ export function VideoConference({ )} - + + {SettingsComponent && (
+ + + + + diff --git a/packages/styles/scss/components/controls/_participants-toggle.scss b/packages/styles/scss/components/controls/_participants-toggle.scss new file mode 100644 index 000000000..83ed7b02c --- /dev/null +++ b/packages/styles/scss/components/controls/_participants-toggle.scss @@ -0,0 +1,20 @@ +@use 'button'; + +.participants-toggle { + @extend .button; + position: relative; +} + +.participants-toggle[data-lk-participant-count]:not(.close-button)::after { + content: attr(data-lk-participant-count); + position: absolute; + top: 0; + left: 0; + padding: 0.25rem; + margin-left: 0.25rem; + margin-top: 0.25rem; + border-radius: 50%; + font-size: 0.5rem; + line-height: 0.75; + background: var(--accent-bg); +} diff --git a/packages/styles/scss/components/controls/index.scss b/packages/styles/scss/components/controls/index.scss index c92102698..bc8d0f24b 100644 --- a/packages/styles/scss/components/controls/index.scss +++ b/packages/styles/scss/components/controls/index.scss @@ -2,6 +2,7 @@ @use 'disconnect-button'; @use 'focus-toggle'; @use 'chat-toggle'; +@use 'participants-toggle'; @use 'media-device-select'; @use 'start-audio'; @use 'pagination-control'; diff --git a/packages/styles/scss/prefabs/index.scss b/packages/styles/scss/prefabs/index.scss index 5d8f5057c..4722d6700 100644 --- a/packages/styles/scss/prefabs/index.scss +++ b/packages/styles/scss/prefabs/index.scss @@ -2,5 +2,6 @@ @use 'audio-conference'; @use 'chat'; @use 'control-bar'; +@use 'participants'; @use 'prejoin'; @use 'video-conference'; diff --git a/packages/styles/scss/prefabs/participants.scss b/packages/styles/scss/prefabs/participants.scss new file mode 100644 index 000000000..d186dde26 --- /dev/null +++ b/packages/styles/scss/prefabs/participants.scss @@ -0,0 +1,65 @@ +.participants { + display: grid; + grid-template-rows: var(--chat-header-height) 1fr; + width: clamp(200px, 55ch, 60ch); + background-color: var(--bg2); + border-left: 1px solid var(--border-color); +} + +.participants-header { + height: var(--chat-header-height); + padding: 0.75rem; + position: relative; + display: flex; + align-items: center; + justify-content: center; + .close-button { + position: absolute; + right: 0; + transform: translateX(-50%); + background-color: transparent; + &:hover { + background-color: var(--lk-control-active-hover-bg); + } + } +} + +.participants-list { + display: flex; + width: 100%; + max-height: 100%; + flex-direction: column; + gap: 0.25rem; + overflow: auto; + padding: 0.25rem; +} + +.participant-entry { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-radius: var(--border-radius); + + .participant-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .track-muted-indicator-camera, + .track-muted-indicator-microphone { + flex-shrink: 0; + } +} + +@media (max-width: 600px) { + .participants { + position: fixed; + top: 0; + right: 0; + max-width: 100%; + bottom: var(--control-bar-height); + } +}