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 {
 | 
			
		||||
  showErrorToasts,
 | 
			
		||||
  useEvent,
 | 
			
		||||
  useGroupPanelContext,
 | 
			
		||||
} from '@capital/common';
 | 
			
		||||
import { useGroupPanelContext } from '@capital/common';
 | 
			
		||||
import { GroupPanelContainer } from '@capital/component';
 | 
			
		||||
import React, { useState } from 'react';
 | 
			
		||||
import type { LocalUserChoices } from '@livekit/components-react';
 | 
			
		||||
import { PreJoinView } from '../components/PreJoinView';
 | 
			
		||||
import { LivekitContainer } from '../components/LivekitContainer';
 | 
			
		||||
import { ActiveRoom } from '../components/ActiveRoom';
 | 
			
		||||
import { useLivekitState } from '../store/useLivekitState';
 | 
			
		||||
import React from 'react';
 | 
			
		||||
import { LivekitView } from '../components/LivekitView';
 | 
			
		||||
 | 
			
		||||
export const LivekitPanel: React.FC = React.memo(() => {
 | 
			
		||||
export const LivekitGroupPanel: React.FC = React.memo(() => {
 | 
			
		||||
  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 url = `/main/group/${groupId}/${panelId}`;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <GroupPanelContainer groupId={groupId} panelId={panelId}>
 | 
			
		||||
      <LivekitContainer>
 | 
			
		||||
        {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>
 | 
			
		||||
      <LivekitView roomName={roomName} url={url} />
 | 
			
		||||
    </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