mirror of https://github.com/msgbyte/tailchat
feat: rewrite all videoconference components. support useravatar and i18n
parent
95c589df4f
commit
8eca54a77b
@ -0,0 +1,57 @@
|
|||||||
|
import { showErrorToasts, useEvent } from '@capital/common';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import type { LocalUserChoices } from '@livekit/components-react';
|
||||||
|
import { PreJoinView } from './lib/PreJoinView';
|
||||||
|
import { LivekitContainer } from './LivekitContainer';
|
||||||
|
import { ActiveRoom } from './ActiveRoom';
|
||||||
|
import { useLivekitState } from '../store/useLivekitState';
|
||||||
|
|
||||||
|
interface LivekitViewProps {
|
||||||
|
roomName: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
export const LivekitView: React.FC<LivekitViewProps> = React.memo((props) => {
|
||||||
|
const [preJoinChoices, setPreJoinChoices] = useState<
|
||||||
|
LocalUserChoices | undefined
|
||||||
|
>(undefined);
|
||||||
|
const { setActive, setDeactive } = useLivekitState();
|
||||||
|
|
||||||
|
const handleError = useEvent((err: Error) => {
|
||||||
|
showErrorToasts('error while setting up prejoin');
|
||||||
|
console.log('error while setting up prejoin', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleJoin = useEvent((userChoices: LocalUserChoices) => {
|
||||||
|
setPreJoinChoices(userChoices);
|
||||||
|
setActive(props.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleLeave = useEvent(() => {
|
||||||
|
setPreJoinChoices(undefined);
|
||||||
|
setDeactive();
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LivekitContainer>
|
||||||
|
{props.roomName && preJoinChoices ? (
|
||||||
|
<ActiveRoom
|
||||||
|
roomName={props.roomName}
|
||||||
|
userChoices={preJoinChoices}
|
||||||
|
onLeave={handleLeave}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', placeItems: 'center', height: '100%' }}>
|
||||||
|
<PreJoinView
|
||||||
|
onError={handleError}
|
||||||
|
defaults={{
|
||||||
|
videoEnabled: false,
|
||||||
|
audioEnabled: false,
|
||||||
|
}}
|
||||||
|
onSubmit={handleJoin}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</LivekitContainer>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
LivekitView.displayName = 'LivekitView';
|
@ -0,0 +1,91 @@
|
|||||||
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core';
|
||||||
|
import { getScrollBarWidth } from '@livekit/components-core';
|
||||||
|
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useSize } from '../../utils/useResizeObserver';
|
||||||
|
|
||||||
|
const MIN_HEIGHT = 130;
|
||||||
|
const MIN_WIDTH = 140;
|
||||||
|
const MIN_VISIBLE_TILES = 1;
|
||||||
|
const ASPECT_RATIO = 16 / 10;
|
||||||
|
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1;
|
||||||
|
|
||||||
|
/** @public */
|
||||||
|
export interface CarouselLayoutProps
|
||||||
|
extends React.HTMLAttributes<HTMLMediaElement> {
|
||||||
|
tracks: TrackReferenceOrPlaceholder[];
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** Place the tiles vertically or horizontally next to each other.
|
||||||
|
* If undefined orientation is guessed by the dimensions of the container. */
|
||||||
|
orientation?: 'vertical' | 'horizontal';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `CarouselLayout` displays a list of tracks horizontally or vertically.
|
||||||
|
* Depending on the size of the container, the carousel will display as many tiles as possible and overflows the rest.
|
||||||
|
* The CarouselLayout uses the `useVisualStableUpdate` hook to ensure that tile reordering due to track updates
|
||||||
|
* is visually stable but still moves the important tiles (speaking participants) to the front.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const tracks = useTracks();
|
||||||
|
* <CarouselLayout tracks={tracks}>
|
||||||
|
* <ParticipantTile />
|
||||||
|
* </CarouselLayout>
|
||||||
|
* ```
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export const CarouselLayout: React.FC<CarouselLayoutProps> = React.memo(
|
||||||
|
({ tracks, orientation, ...props }) => {
|
||||||
|
const asideEl = React.useRef<HTMLDivElement>(null);
|
||||||
|
const [prevTiles, setPrevTiles] = React.useState(0);
|
||||||
|
const { width, height } = useSize(asideEl);
|
||||||
|
const carouselOrientation = orientation
|
||||||
|
? orientation
|
||||||
|
: height >= width
|
||||||
|
? 'vertical'
|
||||||
|
: 'horizontal';
|
||||||
|
|
||||||
|
const tileSpan =
|
||||||
|
carouselOrientation === 'vertical'
|
||||||
|
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
||||||
|
: Math.max(height * ASPECT_RATIO, MIN_WIDTH);
|
||||||
|
const scrollBarWidth = getScrollBarWidth();
|
||||||
|
|
||||||
|
const tilesThatFit =
|
||||||
|
carouselOrientation === 'vertical'
|
||||||
|
? Math.max((height - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
||||||
|
: Math.max((width - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES);
|
||||||
|
|
||||||
|
let maxVisibleTiles = Math.round(tilesThatFit);
|
||||||
|
if (Math.abs(tilesThatFit - prevTiles) < 0.5) {
|
||||||
|
maxVisibleTiles = Math.round(prevTiles);
|
||||||
|
} else if (prevTiles !== tilesThatFit) {
|
||||||
|
setPrevTiles(tilesThatFit);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles);
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (asideEl.current) {
|
||||||
|
asideEl.current.dataset.lkOrientation = carouselOrientation;
|
||||||
|
asideEl.current.style.setProperty(
|
||||||
|
'--lk-max-visible-tiles',
|
||||||
|
maxVisibleTiles.toString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [maxVisibleTiles, carouselOrientation]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
key={carouselOrientation}
|
||||||
|
className="lk-carousel"
|
||||||
|
ref={asideEl}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
CarouselLayout.displayName = 'CarouselLayout';
|
@ -0,0 +1,124 @@
|
|||||||
|
import type {
|
||||||
|
ChatMessage,
|
||||||
|
ReceivedChatMessage,
|
||||||
|
} from '@livekit/components-core';
|
||||||
|
import { setupChat } from '@livekit/components-core';
|
||||||
|
import {
|
||||||
|
ChatEntry,
|
||||||
|
MessageFormatter,
|
||||||
|
useRoomContext,
|
||||||
|
} from '@livekit/components-react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Translate } from '../../translate';
|
||||||
|
import { cloneSingleChild } from '../../utils/common';
|
||||||
|
import { useObservableState } from '../../utils/useObservableState';
|
||||||
|
// import { useRoomContext } from '../context';
|
||||||
|
// import { useObservableState } from '../hooks/internal/useObservableState';
|
||||||
|
// import { cloneSingleChild } from '../utils';
|
||||||
|
// import type { MessageFormatter } from '../components/ChatEntry';
|
||||||
|
// import { ChatEntry } from '../components/ChatEntry';
|
||||||
|
|
||||||
|
export type { ChatMessage, ReceivedChatMessage };
|
||||||
|
|
||||||
|
/** @public */
|
||||||
|
export interface ChatProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
messageFormatter?: MessageFormatter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @public */
|
||||||
|
export function useChat() {
|
||||||
|
const room = useRoomContext();
|
||||||
|
const [setup, setSetup] = React.useState<ReturnType<typeof setupChat>>();
|
||||||
|
const isSending = useObservableState(setup?.isSendingObservable, false);
|
||||||
|
const chatMessages = useObservableState(setup?.messageObservable, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const setupChatReturn = setupChat(room);
|
||||||
|
setSetup(setupChatReturn);
|
||||||
|
return setupChatReturn.destroy;
|
||||||
|
}, [room]);
|
||||||
|
|
||||||
|
return { send: setup?.send, chatMessages, isSending };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Chat component adds a basis chat functionality to the LiveKit room. The messages are distributed to all participants
|
||||||
|
* in the room. Only users who are in the room at the time of dispatch will receive the message.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <LiveKitRoom>
|
||||||
|
* <Chat />
|
||||||
|
* </LiveKitRoom>
|
||||||
|
* ```
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export function Chat({ messageFormatter, ...props }: ChatProps) {
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
const ulRef = React.useRef<HTMLUListElement>(null);
|
||||||
|
const { send, chatMessages, isSending } = useChat();
|
||||||
|
|
||||||
|
async function handleSubmit(event: React.FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (inputRef.current && inputRef.current.value.trim() !== '') {
|
||||||
|
if (send) {
|
||||||
|
await send(inputRef.current.value);
|
||||||
|
inputRef.current.value = '';
|
||||||
|
inputRef.current.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (ulRef) {
|
||||||
|
ulRef.current?.scrollTo({ top: ulRef.current.scrollHeight });
|
||||||
|
}
|
||||||
|
}, [ulRef, chatMessages]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div {...props} className="lk-chat">
|
||||||
|
<ul className="lk-list lk-chat-messages" ref={ulRef}>
|
||||||
|
{props.children
|
||||||
|
? chatMessages.map((msg, idx) =>
|
||||||
|
cloneSingleChild(props.children, {
|
||||||
|
entry: msg,
|
||||||
|
key: idx,
|
||||||
|
messageFormatter,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
: chatMessages.map((msg, idx, allMsg) => {
|
||||||
|
const hideName = idx >= 1 && allMsg[idx - 1].from === msg.from;
|
||||||
|
// If the time delta between two messages is bigger than 60s show timestamp.
|
||||||
|
const hideTimestamp =
|
||||||
|
idx >= 1 && msg.timestamp - allMsg[idx - 1].timestamp < 60_000;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChatEntry
|
||||||
|
key={idx}
|
||||||
|
hideName={hideName}
|
||||||
|
hideTimestamp={hideName === false ? false : hideTimestamp} // If we show the name always show the timestamp as well.
|
||||||
|
entry={msg}
|
||||||
|
messageFormatter={messageFormatter}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<form className="lk-chat-form" onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
className="lk-form-control lk-chat-form-input"
|
||||||
|
disabled={isSending}
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder={Translate.enterMessage}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="lk-button lk-chat-form-button"
|
||||||
|
disabled={isSending}
|
||||||
|
>
|
||||||
|
{Translate.send}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,153 @@
|
|||||||
|
import { Track } from 'livekit-client';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { supportsScreenSharing } from '@livekit/components-core';
|
||||||
|
import {
|
||||||
|
ChatToggle,
|
||||||
|
DisconnectButton,
|
||||||
|
MediaDeviceMenu,
|
||||||
|
StartAudio,
|
||||||
|
TrackToggle,
|
||||||
|
useLocalParticipantPermissions,
|
||||||
|
useMaybeLayoutContext,
|
||||||
|
} from '@livekit/components-react';
|
||||||
|
import { Translate } from '../../translate';
|
||||||
|
import { useMediaQuery } from '../../utils/useMediaQuery';
|
||||||
|
import { ChatIcon } from './icons/ChatIcon';
|
||||||
|
import { LeaveIcon } from './icons/LeaveIcon';
|
||||||
|
|
||||||
|
/** @public */
|
||||||
|
export type ControlBarControls = {
|
||||||
|
microphone?: boolean;
|
||||||
|
camera?: boolean;
|
||||||
|
chat?: boolean;
|
||||||
|
screenShare?: boolean;
|
||||||
|
leave?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @public */
|
||||||
|
export type ControlBarProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
|
variation?: 'minimal' | 'verbose' | 'textOnly';
|
||||||
|
controls?: ControlBarControls;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ControlBar prefab component gives the user the basic user interface
|
||||||
|
* to control their media devices and leave the room.
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* This component is build with other LiveKit components like `TrackToggle`,
|
||||||
|
* `DeviceSelectorButton`, `DisconnectButton` and `StartAudio`.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <LiveKitRoom>
|
||||||
|
* <ControlBar />
|
||||||
|
* </LiveKitRoom>
|
||||||
|
* ```
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export function ControlBar({ variation, controls, ...props }: ControlBarProps) {
|
||||||
|
const [isChatOpen, setIsChatOpen] = 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 defaultVariation = isTooLittleSpace ? 'minimal' : 'verbose';
|
||||||
|
variation ??= defaultVariation;
|
||||||
|
|
||||||
|
const visibleControls = { leave: true, ...controls };
|
||||||
|
|
||||||
|
const localPermissions = useLocalParticipantPermissions();
|
||||||
|
|
||||||
|
if (!localPermissions) {
|
||||||
|
visibleControls.camera = false;
|
||||||
|
visibleControls.chat = false;
|
||||||
|
visibleControls.microphone = false;
|
||||||
|
visibleControls.screenShare = false;
|
||||||
|
} else {
|
||||||
|
visibleControls.camera ??= localPermissions.canPublish;
|
||||||
|
visibleControls.microphone ??= localPermissions.canPublish;
|
||||||
|
visibleControls.screenShare ??= localPermissions.canPublish;
|
||||||
|
visibleControls.chat ??= localPermissions.canPublishData && controls?.chat;
|
||||||
|
}
|
||||||
|
|
||||||
|
const showIcon = React.useMemo(
|
||||||
|
() => variation === 'minimal' || variation === 'verbose',
|
||||||
|
[variation]
|
||||||
|
);
|
||||||
|
const showText = React.useMemo(
|
||||||
|
() => variation === 'textOnly' || variation === 'verbose',
|
||||||
|
[variation]
|
||||||
|
);
|
||||||
|
|
||||||
|
const browserSupportsScreenSharing = supportsScreenSharing();
|
||||||
|
|
||||||
|
const [isScreenShareEnabled, setIsScreenShareEnabled] = React.useState(false);
|
||||||
|
|
||||||
|
const onScreenShareChange = (enabled: boolean) => {
|
||||||
|
setIsScreenShareEnabled(enabled);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lk-control-bar" {...props}>
|
||||||
|
{visibleControls.microphone && (
|
||||||
|
<div className="lk-button-group">
|
||||||
|
<TrackToggle source={Track.Source.Microphone} showIcon={showIcon}>
|
||||||
|
{showText && Translate.micLabel}
|
||||||
|
</TrackToggle>
|
||||||
|
<div className="lk-button-group-menu">
|
||||||
|
<MediaDeviceMenu kind="audioinput" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visibleControls.camera && (
|
||||||
|
<div className="lk-button-group">
|
||||||
|
<TrackToggle source={Track.Source.Camera} showIcon={showIcon}>
|
||||||
|
{showText && Translate.camLabel}
|
||||||
|
</TrackToggle>
|
||||||
|
<div className="lk-button-group-menu">
|
||||||
|
<MediaDeviceMenu kind="videoinput" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visibleControls.screenShare && browserSupportsScreenSharing && (
|
||||||
|
<TrackToggle
|
||||||
|
source={Track.Source.ScreenShare}
|
||||||
|
captureOptions={{ audio: true, selfBrowserSurface: 'include' }}
|
||||||
|
showIcon={showIcon}
|
||||||
|
onChange={onScreenShareChange}
|
||||||
|
>
|
||||||
|
{showText &&
|
||||||
|
(isScreenShareEnabled
|
||||||
|
? Translate.stopScreenShare
|
||||||
|
: Translate.startScreenShare)}
|
||||||
|
</TrackToggle>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visibleControls.chat && (
|
||||||
|
<ChatToggle>
|
||||||
|
{showIcon && <ChatIcon />}
|
||||||
|
{showText && Translate.chat}
|
||||||
|
</ChatToggle>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visibleControls.leave && (
|
||||||
|
<DisconnectButton>
|
||||||
|
{showIcon && <LeaveIcon />}
|
||||||
|
{showText && Translate.leave}
|
||||||
|
</DisconnectButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<StartAudio label={Translate.startAudio} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,244 @@
|
|||||||
|
/**
|
||||||
|
* Fork <ParticipantTile /> from "@livekit/components-react"
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import type { Participant, TrackPublication } from 'livekit-client';
|
||||||
|
import { Track } from 'livekit-client';
|
||||||
|
import type {
|
||||||
|
ParticipantClickEvent,
|
||||||
|
TrackReferenceOrPlaceholder,
|
||||||
|
} from '@livekit/components-core';
|
||||||
|
import {
|
||||||
|
isParticipantSourcePinned,
|
||||||
|
setupParticipantTile,
|
||||||
|
} from '@livekit/components-core';
|
||||||
|
import {
|
||||||
|
AudioTrack,
|
||||||
|
ConnectionQualityIndicator,
|
||||||
|
FocusToggle,
|
||||||
|
ParticipantContext,
|
||||||
|
ParticipantName,
|
||||||
|
TrackMutedIndicator,
|
||||||
|
useEnsureParticipant,
|
||||||
|
useFacingMode,
|
||||||
|
useIsMuted,
|
||||||
|
useIsSpeaking,
|
||||||
|
useMaybeLayoutContext,
|
||||||
|
useMaybeParticipantContext,
|
||||||
|
useMaybeTrackContext,
|
||||||
|
VideoTrack,
|
||||||
|
} from '@livekit/components-react';
|
||||||
|
import { UserAvatar } from '@capital/component';
|
||||||
|
import { mergeProps } from '../../utils/mergeProps';
|
||||||
|
import { ScreenShareIcon } from './icons/ScreenShareIcon';
|
||||||
|
import { useSize } from '../../utils/useResizeObserver';
|
||||||
|
import { Translate } from '../../translate';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
/** @public */
|
||||||
|
export type ParticipantTileProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
|
disableSpeakingIndicator?: boolean;
|
||||||
|
participant?: Participant;
|
||||||
|
source?: Track.Source;
|
||||||
|
publication?: TrackPublication;
|
||||||
|
onParticipantClick?: (event: ParticipantClickEvent) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @public */
|
||||||
|
export type UseParticipantTileProps<T extends HTMLDivElement> =
|
||||||
|
TrackReferenceOrPlaceholder & {
|
||||||
|
disableSpeakingIndicator?: boolean;
|
||||||
|
publication?: TrackPublication;
|
||||||
|
onParticipantClick?: (event: ParticipantClickEvent) => void;
|
||||||
|
htmlProps: React.HTMLAttributes<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @public */
|
||||||
|
export function useParticipantTile<T extends HTMLDivElement>({
|
||||||
|
participant,
|
||||||
|
source,
|
||||||
|
publication,
|
||||||
|
onParticipantClick,
|
||||||
|
disableSpeakingIndicator,
|
||||||
|
htmlProps,
|
||||||
|
}: UseParticipantTileProps<T>) {
|
||||||
|
const p = useEnsureParticipant(participant);
|
||||||
|
const mergedProps = useMemo(() => {
|
||||||
|
const { className } = setupParticipantTile();
|
||||||
|
return mergeProps(htmlProps, {
|
||||||
|
className,
|
||||||
|
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||||
|
// @ts-ignore
|
||||||
|
htmlProps.onClick?.(event);
|
||||||
|
if (typeof onParticipantClick === 'function') {
|
||||||
|
const track = publication ?? p.getTrack(source);
|
||||||
|
onParticipantClick({ participant: p, track });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [htmlProps, source, onParticipantClick, p, publication]);
|
||||||
|
|
||||||
|
const isVideoMuted = useIsMuted(Track.Source.Camera, { participant });
|
||||||
|
const isAudioMuted = useIsMuted(Track.Source.Microphone, { participant });
|
||||||
|
const isSpeaking = useIsSpeaking(participant);
|
||||||
|
const facingMode = useFacingMode({ participant, publication, source });
|
||||||
|
|
||||||
|
mergedProps;
|
||||||
|
|
||||||
|
return {
|
||||||
|
elementProps: {
|
||||||
|
'data-lk-audio-muted': isAudioMuted,
|
||||||
|
'data-lk-video-muted': isVideoMuted,
|
||||||
|
'data-lk-speaking':
|
||||||
|
disableSpeakingIndicator === true ? false : isSpeaking,
|
||||||
|
'data-lk-local-participant': participant.isLocal,
|
||||||
|
'data-lk-source': source,
|
||||||
|
'data-lk-facing-mode': facingMode,
|
||||||
|
...mergedProps,
|
||||||
|
} as React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @public */
|
||||||
|
export function ParticipantContextIfNeeded(
|
||||||
|
props: React.PropsWithChildren<{
|
||||||
|
participant?: Participant;
|
||||||
|
}>
|
||||||
|
) {
|
||||||
|
const hasContext = !!useMaybeParticipantContext();
|
||||||
|
return props.participant && !hasContext ? (
|
||||||
|
<ParticipantContext.Provider value={props.participant}>
|
||||||
|
{props.children}
|
||||||
|
</ParticipantContext.Provider>
|
||||||
|
) : (
|
||||||
|
<>{props.children}</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ParticipantTile component is the base utility wrapper for displaying a visual representation of a participant.
|
||||||
|
* This component can be used as a child of the `TrackLoop` component or by spreading a track reference as properties.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <ParticipantTile source={Track.Source.Camera} />
|
||||||
|
*
|
||||||
|
* <ParticipantTile {...trackReference} />
|
||||||
|
* ```
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export const ParticipantTile = ({
|
||||||
|
participant,
|
||||||
|
children,
|
||||||
|
source = Track.Source.Camera,
|
||||||
|
onParticipantClick,
|
||||||
|
publication,
|
||||||
|
disableSpeakingIndicator,
|
||||||
|
...htmlProps
|
||||||
|
}: ParticipantTileProps) => {
|
||||||
|
const containerEl = React.useRef<HTMLDivElement>(null);
|
||||||
|
const p = useEnsureParticipant(participant);
|
||||||
|
const userId = p.identity;
|
||||||
|
const trackRef: TrackReferenceOrPlaceholder = useMaybeTrackContext() ?? {
|
||||||
|
participant: p,
|
||||||
|
source,
|
||||||
|
publication,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { elementProps } = useParticipantTile<HTMLDivElement>({
|
||||||
|
participant: trackRef.participant,
|
||||||
|
htmlProps,
|
||||||
|
source: trackRef.source,
|
||||||
|
publication: trackRef.publication,
|
||||||
|
disableSpeakingIndicator,
|
||||||
|
onParticipantClick,
|
||||||
|
});
|
||||||
|
|
||||||
|
const layoutContext = useMaybeLayoutContext();
|
||||||
|
|
||||||
|
const handleSubscribe = React.useCallback(
|
||||||
|
(subscribed: boolean) => {
|
||||||
|
if (
|
||||||
|
trackRef.source &&
|
||||||
|
!subscribed &&
|
||||||
|
layoutContext &&
|
||||||
|
layoutContext.pin.dispatch &&
|
||||||
|
isParticipantSourcePinned(
|
||||||
|
trackRef.participant,
|
||||||
|
trackRef.source,
|
||||||
|
layoutContext.pin.state
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
layoutContext.pin.dispatch({ msg: 'clear_pin' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[trackRef.participant, layoutContext, trackRef.source]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { width, height } = useSize(containerEl);
|
||||||
|
const avatarSize = useMemo(() => {
|
||||||
|
const min = Math.min(width, height);
|
||||||
|
if (min > 320) {
|
||||||
|
return 256;
|
||||||
|
} else if (min > 144) {
|
||||||
|
return 128;
|
||||||
|
} else {
|
||||||
|
return 56;
|
||||||
|
}
|
||||||
|
}, [width, height]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative' }} ref={containerEl} {...elementProps}>
|
||||||
|
<ParticipantContextIfNeeded participant={trackRef.participant}>
|
||||||
|
{children ?? (
|
||||||
|
<>
|
||||||
|
{trackRef.publication?.kind === 'video' ||
|
||||||
|
trackRef.source === Track.Source.Camera ||
|
||||||
|
trackRef.source === Track.Source.ScreenShare ? (
|
||||||
|
<VideoTrack
|
||||||
|
participant={trackRef.participant}
|
||||||
|
source={trackRef.source}
|
||||||
|
publication={trackRef.publication}
|
||||||
|
onSubscriptionStatusChanged={handleSubscribe}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AudioTrack
|
||||||
|
participant={trackRef.participant}
|
||||||
|
source={trackRef.source}
|
||||||
|
publication={trackRef.publication}
|
||||||
|
onSubscriptionStatusChanged={handleSubscribe}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="lk-participant-placeholder">
|
||||||
|
<UserAvatar userId={userId} size={avatarSize} />
|
||||||
|
</div>
|
||||||
|
<div className="lk-participant-metadata">
|
||||||
|
<div className="lk-participant-metadata-item">
|
||||||
|
{trackRef.source === Track.Source.Camera ? (
|
||||||
|
<>
|
||||||
|
<TrackMutedIndicator
|
||||||
|
source={Track.Source.Microphone}
|
||||||
|
show={'muted'}
|
||||||
|
/>
|
||||||
|
<ParticipantName />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ScreenShareIcon style={{ marginRight: '0.25rem' }} />
|
||||||
|
<ParticipantName>
|
||||||
|
{Translate.someonesScreen}
|
||||||
|
</ParticipantName>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<FocusToggle trackSource={trackRef.source} />
|
||||||
|
</ParticipantContextIfNeeded>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* Fork <VideoConference /> from "@livekit/components-react"
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { WidgetState } from '@livekit/components-core';
|
||||||
|
import {
|
||||||
|
isEqualTrackRef,
|
||||||
|
isTrackReference,
|
||||||
|
log,
|
||||||
|
isWeb,
|
||||||
|
} from '@livekit/components-core';
|
||||||
|
import { RoomEvent, Track } from 'livekit-client';
|
||||||
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core';
|
||||||
|
import {
|
||||||
|
ConnectionStateToast,
|
||||||
|
FocusLayout,
|
||||||
|
FocusLayoutContainer,
|
||||||
|
GridLayout,
|
||||||
|
LayoutContextProvider,
|
||||||
|
MessageFormatter,
|
||||||
|
RoomAudioRenderer,
|
||||||
|
useCreateLayoutContext,
|
||||||
|
usePinnedTracks,
|
||||||
|
useTracks,
|
||||||
|
} from '@livekit/components-react';
|
||||||
|
import { ParticipantTile } from './ParticipantTile';
|
||||||
|
import { CarouselLayout } from './CarouselLayout';
|
||||||
|
import { ControlBar } from './ControlBar';
|
||||||
|
import { Chat } from './Chat';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export interface VideoConferenceProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
chatMessageFormatter?: MessageFormatter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This component is the default setup of a classic LiveKit video conferencing app.
|
||||||
|
* It provides functionality like switching between participant grid view and focus view.
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* The component is implemented with other LiveKit components like `FocusContextProvider`,
|
||||||
|
* `GridLayout`, `ControlBar`, `FocusLayoutContainer` and `FocusLayout`.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <LiveKitRoom>
|
||||||
|
* <VideoConference />
|
||||||
|
* <LiveKitRoom>
|
||||||
|
* ```
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export const VideoConference: React.FC<VideoConferenceProps> = React.memo(
|
||||||
|
({ chatMessageFormatter, ...props }) => {
|
||||||
|
const [widgetState, setWidgetState] = React.useState<WidgetState>({
|
||||||
|
showChat: false,
|
||||||
|
});
|
||||||
|
const lastAutoFocusedScreenShareTrack =
|
||||||
|
React.useRef<TrackReferenceOrPlaceholder | null>(null);
|
||||||
|
|
||||||
|
const tracks = useTracks(
|
||||||
|
[
|
||||||
|
{ source: Track.Source.Camera, withPlaceholder: true },
|
||||||
|
{ source: Track.Source.ScreenShare, withPlaceholder: false },
|
||||||
|
],
|
||||||
|
{ updateOnlyOn: [RoomEvent.ActiveSpeakersChanged] }
|
||||||
|
);
|
||||||
|
|
||||||
|
const widgetUpdate = (state: WidgetState) => {
|
||||||
|
log.debug('updating widget state', state);
|
||||||
|
setWidgetState(state);
|
||||||
|
};
|
||||||
|
|
||||||
|
const layoutContext = useCreateLayoutContext();
|
||||||
|
|
||||||
|
const screenShareTracks = tracks
|
||||||
|
.filter(isTrackReference)
|
||||||
|
.filter((track) => track.publication.source === Track.Source.ScreenShare);
|
||||||
|
|
||||||
|
const focusTrack = usePinnedTracks(layoutContext)?.[0];
|
||||||
|
const carouselTracks = tracks.filter(
|
||||||
|
(track) => !isEqualTrackRef(track, focusTrack)
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
// If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
|
||||||
|
if (
|
||||||
|
screenShareTracks.length > 0 &&
|
||||||
|
lastAutoFocusedScreenShareTrack.current === null
|
||||||
|
) {
|
||||||
|
log.debug('Auto set screen share focus:', {
|
||||||
|
newScreenShareTrack: screenShareTracks[0],
|
||||||
|
});
|
||||||
|
layoutContext.pin.dispatch?.({
|
||||||
|
msg: 'set_pin',
|
||||||
|
trackReference: screenShareTracks[0],
|
||||||
|
});
|
||||||
|
lastAutoFocusedScreenShareTrack.current = screenShareTracks[0];
|
||||||
|
} else if (
|
||||||
|
lastAutoFocusedScreenShareTrack.current &&
|
||||||
|
!screenShareTracks.some(
|
||||||
|
(track) =>
|
||||||
|
track.publication.trackSid ===
|
||||||
|
lastAutoFocusedScreenShareTrack.current?.publication?.trackSid
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
log.debug('Auto clearing screen share focus.');
|
||||||
|
layoutContext.pin.dispatch?.({ msg: 'clear_pin' });
|
||||||
|
lastAutoFocusedScreenShareTrack.current = null;
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
screenShareTracks.map((ref) => ref.publication.trackSid).join(),
|
||||||
|
focusTrack?.publication?.trackSid,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lk-video-conference" {...props}>
|
||||||
|
{isWeb() && (
|
||||||
|
<LayoutContextProvider
|
||||||
|
value={layoutContext}
|
||||||
|
// onPinChange={handleFocusStateChange}
|
||||||
|
onWidgetChange={widgetUpdate}
|
||||||
|
>
|
||||||
|
<div className="lk-video-conference-inner">
|
||||||
|
{!focusTrack ? (
|
||||||
|
<div className="lk-grid-layout-wrapper">
|
||||||
|
<GridLayout tracks={tracks}>
|
||||||
|
<ParticipantTile />
|
||||||
|
</GridLayout>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="lk-focus-layout-wrapper">
|
||||||
|
<FocusLayoutContainer>
|
||||||
|
<CarouselLayout tracks={carouselTracks}>
|
||||||
|
<ParticipantTile />
|
||||||
|
</CarouselLayout>
|
||||||
|
{focusTrack && <FocusLayout track={focusTrack} />}
|
||||||
|
</FocusLayoutContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ControlBar controls={{ chat: true }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Chat
|
||||||
|
style={{ display: widgetState.showChat ? 'flex' : 'none' }}
|
||||||
|
messageFormatter={chatMessageFormatter}
|
||||||
|
/>
|
||||||
|
</LayoutContextProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RoomAudioRenderer />
|
||||||
|
|
||||||
|
<ConnectionStateToast />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
VideoConference.displayName = 'VideoConference';
|
@ -0,0 +1,25 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import type { SVGProps } from 'react';
|
||||||
|
|
||||||
|
export const ChatIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={16}
|
||||||
|
height={18}
|
||||||
|
fill="none"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M0 2.75A2.75 2.75 0 0 1 2.75 0h10.5A2.75 2.75 0 0 1 16 2.75v13.594a.75.75 0 0 1-1.234.572l-3.691-3.12a1.25 1.25 0 0 0-.807-.296H2.75A2.75 2.75 0 0 1 0 10.75v-8ZM2.75 1.5c-.69 0-1.25.56-1.25 1.25v8c0 .69.56 1.25 1.25 1.25h7.518c.65 0 1.279.23 1.775.65l2.457 2.077V2.75c0-.69-.56-1.25-1.25-1.25H2.75Z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M3 4.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5Zm0 2a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5Zm0 2a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 0 1h-5a.5.5 0 0 1-.5-.5Z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
@ -0,0 +1,25 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import type { SVGProps } from 'react';
|
||||||
|
|
||||||
|
export const LeaveIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
fill="none"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M2 2.75A2.75 2.75 0 0 1 4.75 0h6.5A2.75 2.75 0 0 1 14 2.75v10.5A2.75 2.75 0 0 1 11.25 16h-6.5A2.75 2.75 0 0 1 2 13.25v-.5a.75.75 0 0 1 1.5 0v.5c0 .69.56 1.25 1.25 1.25h6.5c.69 0 1.25-.56 1.25-1.25V2.75c0-.69-.56-1.25-1.25-1.25h-6.5c-.69 0-1.25.56-1.25 1.25v.5a.75.75 0 0 1-1.5 0v-.5Z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M8.78 7.47a.75.75 0 0 1 0 1.06l-2.25 2.25a.75.75 0 1 1-1.06-1.06l.97-.97H1.75a.75.75 0 0 1 0-1.5h4.69l-.97-.97a.75.75 0 0 1 1.06-1.06l2.25 2.25Z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const ScreenShareIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={20}
|
||||||
|
height={16}
|
||||||
|
fill="none"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M0 2.75A2.75 2.75 0 0 1 2.75 0h14.5A2.75 2.75 0 0 1 20 2.75v10.5A2.75 2.75 0 0 1 17.25 16H2.75A2.75 2.75 0 0 1 0 13.25V2.75ZM2.75 1.5c-.69 0-1.25.56-1.25 1.25v10.5c0 .69.56 1.25 1.25 1.25h14.5c.69 0 1.25-.56 1.25-1.25V2.75c0-.69-.56-1.25-1.25-1.25H2.75Z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M9.47 4.22a.75.75 0 0 1 1.06 0l2.25 2.25a.75.75 0 0 1-1.06 1.06l-.97-.97v4.69a.75.75 0 0 1-1.5 0V6.56l-.97.97a.75.75 0 0 1-1.06-1.06l2.25-2.25Z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
@ -1,67 +1,19 @@
|
|||||||
import {
|
import { useGroupPanelContext } from '@capital/common';
|
||||||
showErrorToasts,
|
|
||||||
useEvent,
|
|
||||||
useGroupPanelContext,
|
|
||||||
} from '@capital/common';
|
|
||||||
import { GroupPanelContainer } from '@capital/component';
|
import { GroupPanelContainer } from '@capital/component';
|
||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import type { LocalUserChoices } from '@livekit/components-react';
|
import { LivekitView } from '../components/LivekitView';
|
||||||
import { PreJoinView } from '../components/PreJoinView';
|
|
||||||
import { LivekitContainer } from '../components/LivekitContainer';
|
|
||||||
import { ActiveRoom } from '../components/ActiveRoom';
|
|
||||||
import { useLivekitState } from '../store/useLivekitState';
|
|
||||||
|
|
||||||
export const LivekitPanel: React.FC = React.memo(() => {
|
export const LivekitGroupPanel: React.FC = React.memo(() => {
|
||||||
const { groupId, panelId } = useGroupPanelContext();
|
const { groupId, panelId } = useGroupPanelContext();
|
||||||
const [preJoinChoices, setPreJoinChoices] = useState<
|
|
||||||
LocalUserChoices | undefined
|
|
||||||
>(undefined);
|
|
||||||
const { setActive, setDeactive } = useLivekitState();
|
|
||||||
|
|
||||||
const handleError = useEvent((err: Error) => {
|
|
||||||
showErrorToasts('error while setting up prejoin');
|
|
||||||
console.log('error while setting up prejoin', err);
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleJoin = useEvent((userChoices: LocalUserChoices) => {
|
|
||||||
setPreJoinChoices(userChoices);
|
|
||||||
setActive(`/main/group/${groupId}/${panelId}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleLeave = useEvent(() => {
|
|
||||||
setPreJoinChoices(undefined);
|
|
||||||
setDeactive();
|
|
||||||
});
|
|
||||||
|
|
||||||
const roomName = `${groupId}#${panelId}`;
|
const roomName = `${groupId}#${panelId}`;
|
||||||
|
const url = `/main/group/${groupId}/${panelId}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GroupPanelContainer groupId={groupId} panelId={panelId}>
|
<GroupPanelContainer groupId={groupId} panelId={panelId}>
|
||||||
<LivekitContainer>
|
<LivekitView roomName={roomName} url={url} />
|
||||||
{roomName && preJoinChoices ? (
|
|
||||||
<ActiveRoom
|
|
||||||
roomName={roomName}
|
|
||||||
userChoices={preJoinChoices}
|
|
||||||
onLeave={handleLeave}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{ display: 'grid', placeItems: 'center', height: '100%' }}
|
|
||||||
>
|
|
||||||
<PreJoinView
|
|
||||||
onError={handleError}
|
|
||||||
defaults={{
|
|
||||||
videoEnabled: false,
|
|
||||||
audioEnabled: false,
|
|
||||||
}}
|
|
||||||
onSubmit={handleJoin}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</LivekitContainer>
|
|
||||||
</GroupPanelContainer>
|
</GroupPanelContainer>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
LivekitPanel.displayName = 'LivekitPanel';
|
LivekitGroupPanel.displayName = 'LivekitGroupPanel';
|
||||||
|
|
||||||
export default LivekitPanel;
|
export default LivekitGroupPanel;
|
||||||
|
@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export function cloneSingleChild(
|
||||||
|
children: React.ReactNode | React.ReactNode[],
|
||||||
|
props?: Record<string, any>,
|
||||||
|
key?: any
|
||||||
|
) {
|
||||||
|
return React.Children.map(children, (child) => {
|
||||||
|
// Checking isValidElement is the safe way and avoids a typescript
|
||||||
|
// error too.
|
||||||
|
if (React.isValidElement(child) && React.Children.only(children)) {
|
||||||
|
return React.cloneElement(child, { ...props, key });
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
}
|
@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Calls all functions in the order they were chained with the same arguments.
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function chain(...callbacks: any[]): (...args: any[]) => void {
|
||||||
|
return (...args: any[]) => {
|
||||||
|
for (const callback of callbacks) {
|
||||||
|
if (typeof callback === 'function') {
|
||||||
|
callback(...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// taken from: https://stackoverflow.com/questions/51603250/typescript-3-parameter-list-intersection-type/51604379#51604379
|
||||||
|
type TupleTypes<T> = { [P in keyof T]: T[P] } extends { [key: number]: infer V }
|
||||||
|
? V
|
||||||
|
: never;
|
||||||
|
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
|
||||||
|
k: infer I
|
||||||
|
) => void
|
||||||
|
? I
|
||||||
|
: never;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges multiple props objects together. Event handlers are chained,
|
||||||
|
* classNames are combined, and ids are deduplicated - different ids
|
||||||
|
* will trigger a side-effect and re-render components hooked up with `useId`.
|
||||||
|
* For all other props, the last prop object overrides all previous ones.
|
||||||
|
* @param args - Multiple sets of props to merge together.
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function mergeProps<T extends Props[]>(
|
||||||
|
...args: T
|
||||||
|
): UnionToIntersection<TupleTypes<T>> {
|
||||||
|
// Start with a base clone of the first argument. This is a lot faster than starting
|
||||||
|
// with an empty object and adding properties as we go.
|
||||||
|
const result: Props = { ...args[0] };
|
||||||
|
for (let i = 1; i < args.length; i++) {
|
||||||
|
const props = args[i];
|
||||||
|
for (const key in props) {
|
||||||
|
const a = result[key];
|
||||||
|
const b = props[key];
|
||||||
|
|
||||||
|
// Chain events
|
||||||
|
if (
|
||||||
|
typeof a === 'function' &&
|
||||||
|
typeof b === 'function' &&
|
||||||
|
// This is a lot faster than a regex.
|
||||||
|
key[0] === 'o' &&
|
||||||
|
key[1] === 'n' &&
|
||||||
|
key.charCodeAt(2) >= /* 'A' */ 65 &&
|
||||||
|
key.charCodeAt(2) <= /* 'Z' */ 90
|
||||||
|
) {
|
||||||
|
result[key] = chain(a, b);
|
||||||
|
|
||||||
|
// Merge classnames, sometimes classNames are empty string which eval to false, so we just need to do a type check
|
||||||
|
} else if (
|
||||||
|
(key === 'className' || key === 'UNSAFE_className') &&
|
||||||
|
typeof a === 'string' &&
|
||||||
|
typeof b === 'string'
|
||||||
|
) {
|
||||||
|
result[key] = `${a} ${b}`;
|
||||||
|
} else {
|
||||||
|
result[key] = b !== undefined ? b : a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result as UnionToIntersection<TupleTypes<T>>;
|
||||||
|
}
|
@ -0,0 +1,45 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
/**
|
||||||
|
* Implementation used from https://github.com/juliencrn/usehooks-ts
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function useMediaQuery(query: string): boolean {
|
||||||
|
const getMatches = (query: string): boolean => {
|
||||||
|
// Prevents SSR issues
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return window.matchMedia(query).matches;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [matches, setMatches] = React.useState<boolean>(getMatches(query));
|
||||||
|
|
||||||
|
function handleChange() {
|
||||||
|
setMatches(getMatches(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const matchMedia = window.matchMedia(query);
|
||||||
|
|
||||||
|
// Triggered at the first client-side load and if query changes
|
||||||
|
handleChange();
|
||||||
|
|
||||||
|
// Listen matchMedia
|
||||||
|
if (matchMedia.addListener) {
|
||||||
|
matchMedia.addListener(handleChange);
|
||||||
|
} else {
|
||||||
|
matchMedia.addEventListener('change', handleChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (matchMedia.removeListener) {
|
||||||
|
matchMedia.removeListener(handleChange);
|
||||||
|
} else {
|
||||||
|
matchMedia.removeEventListener('change', handleChange);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
type Observable<T> = any; // import type { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function useObservableState<T>(
|
||||||
|
observable: Observable<T> | undefined,
|
||||||
|
startWith: T
|
||||||
|
) {
|
||||||
|
const [state, setState] = React.useState<T>(startWith);
|
||||||
|
React.useEffect(() => {
|
||||||
|
// observable state doesn't run in SSR
|
||||||
|
if (typeof window === 'undefined' || !observable) return;
|
||||||
|
const subscription = observable.subscribe(setState);
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
}, [observable]);
|
||||||
|
return state;
|
||||||
|
}
|
@ -0,0 +1,121 @@
|
|||||||
|
/* eslint-disable no-return-assign */
|
||||||
|
/* eslint-disable no-underscore-dangle */
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useUpdateRef } from '@capital/common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A React hook that fires a callback whenever ResizeObserver detects a change to its size
|
||||||
|
* code extracted from https://github.com/jaredLunde/react-hook/blob/master/packages/resize-observer/src/index.tsx in order to not include the polyfill for resize-observer
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function useResizeObserver<T extends HTMLElement>(
|
||||||
|
target: React.RefObject<T>,
|
||||||
|
callback: UseResizeObserverCallback
|
||||||
|
) {
|
||||||
|
const resizeObserver = getResizeObserver();
|
||||||
|
const storedCallback = useUpdateRef(callback);
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
let didUnsubscribe = false;
|
||||||
|
|
||||||
|
const targetEl = target.current;
|
||||||
|
if (!targetEl) return;
|
||||||
|
|
||||||
|
function cb(entry: ResizeObserverEntry, observer: ResizeObserver) {
|
||||||
|
if (didUnsubscribe) return;
|
||||||
|
storedCallback.current(entry, observer);
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeObserver?.subscribe(targetEl as HTMLElement, cb);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
didUnsubscribe = true;
|
||||||
|
resizeObserver?.unsubscribe(targetEl as HTMLElement, cb);
|
||||||
|
};
|
||||||
|
}, [target.current, resizeObserver, storedCallback]);
|
||||||
|
|
||||||
|
return resizeObserver?.observer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createResizeObserver() {
|
||||||
|
let ticking = false;
|
||||||
|
let allEntries: ResizeObserverEntry[] = [];
|
||||||
|
|
||||||
|
const callbacks: Map<unknown, Array<UseResizeObserverCallback>> = new Map();
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(
|
||||||
|
(entries: ResizeObserverEntry[], obs: ResizeObserver) => {
|
||||||
|
allEntries = allEntries.concat(entries);
|
||||||
|
if (!ticking) {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
const triggered = new Set<Element>();
|
||||||
|
for (let i = 0; i < allEntries.length; i++) {
|
||||||
|
if (triggered.has(allEntries[i].target)) continue;
|
||||||
|
triggered.add(allEntries[i].target);
|
||||||
|
const cbs = callbacks.get(allEntries[i].target);
|
||||||
|
cbs?.forEach((cb) => cb(allEntries[i], obs));
|
||||||
|
}
|
||||||
|
allEntries = [];
|
||||||
|
ticking = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ticking = true;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
observer,
|
||||||
|
subscribe(target: HTMLElement, callback: UseResizeObserverCallback) {
|
||||||
|
observer.observe(target);
|
||||||
|
const cbs = callbacks.get(target) ?? [];
|
||||||
|
cbs.push(callback);
|
||||||
|
callbacks.set(target, cbs);
|
||||||
|
},
|
||||||
|
unsubscribe(target: HTMLElement, callback: UseResizeObserverCallback) {
|
||||||
|
const cbs = callbacks.get(target) ?? [];
|
||||||
|
if (cbs.length === 1) {
|
||||||
|
observer.unobserve(target);
|
||||||
|
callbacks.delete(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cbIndex = cbs.indexOf(callback);
|
||||||
|
if (cbIndex !== -1) cbs.splice(cbIndex, 1);
|
||||||
|
callbacks.set(target, cbs);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let _resizeObserver: ReturnType<typeof createResizeObserver>;
|
||||||
|
|
||||||
|
const getResizeObserver = () =>
|
||||||
|
!_resizeObserver
|
||||||
|
? (_resizeObserver = createResizeObserver())
|
||||||
|
: _resizeObserver;
|
||||||
|
|
||||||
|
export type UseResizeObserverCallback = (
|
||||||
|
entry: ResizeObserverEntry,
|
||||||
|
observer: ResizeObserver
|
||||||
|
) => unknown;
|
||||||
|
|
||||||
|
export const useSize = (target: React.RefObject<HTMLDivElement>) => {
|
||||||
|
const [size, setSize] = React.useState({ width: 0, height: 0 });
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (target.current) {
|
||||||
|
const { width, height } = target.current.getBoundingClientRect();
|
||||||
|
setSize({ width, height });
|
||||||
|
}
|
||||||
|
}, [target.current]);
|
||||||
|
|
||||||
|
const resizeCallback = React.useCallback(
|
||||||
|
(entry: ResizeObserverEntry) => setSize(entry.contentRect),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
// Where the magic happens
|
||||||
|
useResizeObserver(target, resizeCallback);
|
||||||
|
return size;
|
||||||
|
};
|
Loading…
Reference in New Issue