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