Skip to content
Open
6 changes: 6 additions & 0 deletions packages/core/src/components/participantsToggle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { prefixClass } from '../styles-interface';

export function setupParticipantsToggle() {
const className: string = [prefixClass('button'), prefixClass('participants-toggle')].join(' ');
return { className };
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions packages/react/etc/components-react.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ export type ControlBarControls = {
screenShare?: boolean;
leave?: boolean;
settings?: boolean;
participants?: boolean;
};

// @public (undocumented)
Expand Down Expand Up @@ -553,6 +554,25 @@ export interface ParticipantNameProps extends React_2.HTMLAttributes<HTMLSpanEle
// @internal (undocumented)
export const ParticipantPlaceholder: (props: SVGProps<SVGSVGElement>) => 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<SVGSVGElement>) => React_2.JSX.Element;

// @public (undocumented)
export interface ParticipantsProps extends React_2.HTMLAttributes<HTMLDivElement> {
}

// @public
export const ParticipantsToggle: (props: ParticipantsToggleProps & React_2.RefAttributes<HTMLButtonElement>) => React_2.ReactNode;

// @public (undocumented)
export interface ParticipantsToggleProps extends React_2.ButtonHTMLAttributes<HTMLButtonElement> {
}

// @public
export const ParticipantTile: (props: ParticipantTileProps & React_2.RefAttributes<HTMLDivElement>) => React_2.ReactNode;

Expand Down Expand Up @@ -1107,6 +1127,22 @@ export interface UseParticipantsOptions {
updateOnlyOn?: RoomEvent[];
}

// @public
export function useParticipantsToggle({ props }: UseParticipantsToggleProps): {
mergedProps: React_2.ButtonHTMLAttributes<HTMLButtonElement> & {
className: string;
onClick: () => void;
'aria-pressed': string;
'data-lk-participant-count': string;
};
};

// @public (undocumented)
export interface UseParticipantsToggleProps {
// (undocumented)
props: React_2.ButtonHTMLAttributes<HTMLButtonElement>;
}

// @public
export function useParticipantTile<T extends HTMLElement>({ trackRef, onParticipantClick, disableSpeakingIndicator, htmlProps, }: UseParticipantTileProps<T>): {
elementProps: React_2.HTMLAttributes<T>;
Expand Down
25 changes: 25 additions & 0 deletions packages/react/src/assets/icons/ParticipantsIcon.tsx
Original file line number Diff line number Diff line change
@@ -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<SVGSVGElement>) => (
<svg xmlns="http://www.w3.org/2000/svg" width={18} height={16} fill="none" {...props}>
<path
fill="currentColor"
fillRule="evenodd"
d="M7 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm0-1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM1.5 14c0-2.21 2.46-4 5.5-4s5.5 1.79 5.5 4a.75.75 0 0 0 1.5 0c0-3.17-3.13-5.5-7-5.5S0 10.83 0 14a.75.75 0 0 0 1.5 0ZM13.5 7.5a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm0-1.5a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1Z"
clipRule="evenodd"
/>
<path
fill="currentColor"
fillRule="evenodd"
d="M18 13.25c0-1.8-1.88-3.25-4.25-3.25-.42 0-.82.05-1.2.13a.75.75 0 1 0 .32 1.47c.28-.06.57-.1.88-.1 1.57 0 2.75.86 2.75 1.75a.75.75 0 0 0 1.5 0Z"
clipRule="evenodd"
/>
</svg>
);
export default SvgParticipantsIcon;
1 change: 1 addition & 0 deletions packages/react/src/assets/icons/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
32 changes: 32 additions & 0 deletions packages/react/src/components/controls/ParticipantsToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import * as React from 'react';
import { useParticipantsToggle } from '../../hooks';

/** @public */
export interface ParticipantsToggleProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}

/**
* 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
* <LiveKitRoom>
* <ParticipantsToggle />
* </LiveKitRoom>
* ```
* @public
*/
export const ParticipantsToggle: (
props: ParticipantsToggleProps & React.RefAttributes<HTMLButtonElement>,
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<HTMLButtonElement, ParticipantsToggleProps>(
function ParticipantsToggle(props: ParticipantsToggleProps, ref) {
const { mergedProps } = useParticipantsToggle({ props });

return (
<button ref={ref} {...mergedProps}>
{props.children}
</button>
);
},
);
1 change: 1 addition & 0 deletions packages/react/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
12 changes: 10 additions & 2 deletions packages/react/src/context/chat-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 };
}
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
35 changes: 35 additions & 0 deletions packages/react/src/hooks/useParticipantsToggle.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>;
}

/**
* 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 };
}
22 changes: 15 additions & 7 deletions packages/react/src/prefabs/ControlBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -21,6 +22,7 @@ export type ControlBarControls = {
screenShare?: boolean;
leave?: boolean;
settings?: boolean;
participants?: boolean;
};

const trackSourceToProtocol = (source: Track.Source) => {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -203,6 +205,12 @@ export function ControlBar({
{showText && (isScreenShareEnabled ? 'Stop screen share' : 'Share screen')}
</TrackToggle>
)}
{visibleControls.participants && (
<ParticipantsToggle>
{showIcon && <ParticipantsIcon />}
{showText && 'Participants'}
</ParticipantsToggle>
)}
{visibleControls.chat && (
<ChatToggle>
{showIcon && <ChatIcon />}
Expand Down
55 changes: 55 additions & 0 deletions packages/react/src/prefabs/Participants.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement> {}

/**
* The `Participants` component displays a list of all participants in the room
* with their name and audio/video muted indicators.
*
* @example
* ```tsx
* <LiveKitRoom>
* <Participants />
* </LiveKitRoom>
* ```
* @public
*/
export function Participants({ ...props }: ParticipantsProps) {
const participants = useParticipants();
const sortedParticipants = useSortedParticipants(participants);
const layoutContext = useMaybeLayoutContext();

return (
<div {...props} className="lk-participants">
<div className="lk-participants-header">
Participants ({participants.length})
{layoutContext && (
<ParticipantsToggle className="lk-close-button">
<ChatCloseIcon />
</ParticipantsToggle>
)}
</div>
<ul className="lk-list lk-participants-list">
{sortedParticipants.map((participant) => (
<li key={participant.identity} className="lk-participant-entry">
<ParticipantContext.Provider value={participant}>
<ParticipantName />
<TrackMutedIndicator trackRef={{ participant, source: Track.Source.Microphone }} />
<TrackMutedIndicator trackRef={{ participant, source: Track.Source.Camera }} />
</ParticipantContext.Provider>
</li>
))}
</ul>
</div>
);
}
7 changes: 6 additions & 1 deletion packages/react/src/prefabs/VideoConference.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -64,6 +65,7 @@ export function VideoConference({
showChat: false,
unreadMessages: 0,
showSettings: false,
showParticipants: false,
});
const lastAutoFocusedScreenShareTrack = React.useRef<TrackReferenceOrPlaceholder | null>(null);

Expand Down Expand Up @@ -155,14 +157,17 @@ export function VideoConference({
</FocusLayoutContainer>
</div>
)}
<ControlBar controls={{ chat: true, settings: !!SettingsComponent }} />
<ControlBar
controls={{ participants: true, chat: true, settings: !!SettingsComponent }}
/>
</div>
<Chat
style={{ display: widgetState.showChat ? 'grid' : 'none' }}
messageFormatter={chatMessageFormatter}
messageEncoder={chatMessageEncoder}
messageDecoder={chatMessageDecoder}
/>
<Participants style={{ display: widgetState.showParticipants ? 'grid' : 'none' }} />
{SettingsComponent && (
<div
className="lk-settings-menu-modal"
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/prefabs/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { Chat, type ChatProps } from './Chat';
export { Participants, type ParticipantsProps } from './Participants';
export { PreJoin, type PreJoinProps, usePreviewDevice, usePreviewTracks } from './PreJoin';
export { VideoConference, type VideoConferenceProps } from './VideoConference';
export { ControlBar, type ControlBarProps, type ControlBarControls } from './ControlBar';
Expand Down
6 changes: 6 additions & 0 deletions packages/styles/assets/icons/participants-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading