feat: People Panel (#8)

* chore: Add pre-commit

* feat: Add PeoplePanel

* feat: Add RightPanel wrapper to toggle PeoplePanel and ChatMessages

* feat: Use RightPanel component

* feat: Update ControlBar component to handle middle buttons and right buttons

* feat: Extract livekit layout context from the lib to handle new properties

* feat: Add PeoplePanelToggleButton

* feat: Add translations

* fix: Remove unused import

* feat: Update husky file permissions

* fix: Remove unused things

* fix: Remove unused container
This commit is contained in:
Gabriel Díaz 2023-08-15 12:23:50 -03:00 committed by GitHub
parent 1510f36439
commit ae1ac2d5bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 508 additions and 60 deletions

18
.husky/pre-commit Executable file
View File

@ -0,0 +1,18 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
FILES=$(git diff --cached --name-only --diff-filter=ACMR | sed 's| |\\ |g' | { grep -E '(js|ts|tsx|json|yml|md|html|css)$' || true; })
if [ -z "$FILES" ]; then
exit 0
fi
echo "Running prettier"
npm run pre-commit:fix:prettier -- $FILES
TS_FILES=$(git diff --cached --name-only --diff-filter=ACMR | sed 's| |\\ |g' | { grep -E '(js|ts|tsx)$' || true; })
if [[ ! -z "$TS_FILES" ]];then
echo "Running lints"
npm run pre-commit:fix:code -- $TS_FILES
fi

View File

@ -11,6 +11,7 @@
"check:code": "eslint -c .eslintrc.js src",
"fix:code": "npm run check:code -- --fix",
"pre-commit:fix:code": "eslint -c .eslintrc.js --fix",
"pre-commit:fix:prettier": "prettier --config .prettierrc.json --write",
"prepare": "husky install",
"test": "jest",
"test:coverage": "npm run test -- --coverage"
@ -88,4 +89,4 @@
"url": "https://github.com/decentraland/meet.git"
},
"homepage": ""
}
}

View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.7009 4.82044C19.3666 5.48724 19.3661 6.56785 18.6997 7.23403L6.75071 19.1803C6.08437 19.8464 5.00452 19.8459 4.3388 19.1791C3.67309 18.5123 3.67359 17.4317 4.33993 16.7655L16.289 4.81932C16.9553 4.15313 18.0351 4.15364 18.7009 4.82044Z" fill="#FCFCFC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.7009 19.1796C18.0351 19.8464 16.9553 19.8469 16.289 19.1807L4.33993 7.23446C3.67359 6.56828 3.67309 5.48768 4.33881 4.82088C5.00453 4.15407 6.08437 4.15357 6.75071 4.81975L18.6997 16.766C19.3661 17.4322 19.3666 18.5128 18.7009 19.1796Z" fill="#FCFCFC"/>
</svg>

After

Width:  |  Height:  |  Size: 717 B

View File

@ -1,13 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="icon/outline/darkmode/people">
<path id="Mask" fill-rule="evenodd" clip-rule="evenodd" d="M18 10C18 9.449 17.552 9 17 9C16.448 9 16 9.449 16 10C16 10.551 16.448 11 17 11C17.552 11 18 10.551 18 10ZM20 10C20 11.654 18.654 13 17 13C15.346 13 14 11.654 14 10C14 8.346 15.346 7 17 7C18.654 7 20 8.346 20 10ZM11 7C11 5.897 10.103 5 9 5C7.897 5 7 5.897 7 7C7 8.103 7.897 9 9 9C10.103 9 11 8.103 11 7ZM13 7C13 9.206 11.206 11 9 11C6.794 11 5 9.206 5 7C5 4.794 6.794 3 9 3C11.206 3 13 4.794 13 7ZM13.94 15.046C14.809 14.374 15.879 14 17 14C19.757 14 22 16.243 22 19C22 19.552 21.553 20 21 20C20.447 20 20 19.552 20 19C20 17.346 18.654 16 17 16C16.317 16 15.668 16.234 15.144 16.649C15.688 17.645 16 18.787 16 20C16 20.552 15.553 21 15 21C14.447 21 14 20.552 14 20C14 17.243 11.757 15 9 15C6.243 15 4 17.243 4 20C4 20.552 3.553 21 3 21C2.447 21 2 20.552 2 20C2 16.14 5.141 13 9 13C10.927 13 12.673 13.783 13.94 15.046Z" fill="#231F20"/>
<mask id="mask0_883_22542" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="2" y="3" width="20" height="18">
<path id="Mask_2" fill-rule="evenodd" clip-rule="evenodd" d="M18 10C18 9.449 17.552 9 17 9C16.448 9 16 9.449 16 10C16 10.551 16.448 11 17 11C17.552 11 18 10.551 18 10ZM20 10C20 11.654 18.654 13 17 13C15.346 13 14 11.654 14 10C14 8.346 15.346 7 17 7C18.654 7 20 8.346 20 10ZM11 7C11 5.897 10.103 5 9 5C7.897 5 7 5.897 7 7C7 8.103 7.897 9 9 9C10.103 9 11 8.103 11 7ZM13 7C13 9.206 11.206 11 9 11C6.794 11 5 9.206 5 7C5 4.794 6.794 3 9 3C11.206 3 13 4.794 13 7ZM13.94 15.046C14.809 14.374 15.879 14 17 14C19.757 14 22 16.243 22 19C22 19.552 21.553 20 21 20C20.447 20 20 19.552 20 19C20 17.346 18.654 16 17 16C16.317 16 15.668 16.234 15.144 16.649C15.688 17.645 16 18.787 16 20C16 20.552 15.553 21 15 21C14.447 21 14 20.552 14 20C14 17.243 11.757 15 9 15C6.243 15 4 17.243 4 20C4 20.552 3.553 21 3 21C2.447 21 2 20.552 2 20C2 16.14 5.141 13 9 13C10.927 13 12.673 13.783 13.94 15.046Z" fill="white"/>
</mask>
<g mask="url(#mask0_883_22542)">
<g id="&#240;&#159;&#142;&#168; Color">
<rect id="Base" width="24" height="24" fill="white"/>
</g>
</g>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="25" viewBox="0 0 28 25" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.0003 11.1667C12.9417 11.1667 15.3337 8.77467 15.3337 5.83333C15.3337 2.892 12.9417 0.5 10.0003 0.5C7.05899 0.5 4.66699 2.892 4.66699 5.83333C4.66699 8.77467 7.05899 11.1667 10.0003 11.1667ZM20.667 13.8333C22.8723 13.8333 24.667 12.0387 24.667 9.83333C24.667 7.628 22.8723 5.83333 20.667 5.83333C18.4617 5.83333 16.667 7.628 16.667 9.83333C16.667 12.0387 18.4617 13.8333 20.667 13.8333ZM27.3337 21.8333C27.3337 22.5693 26.7377 23.1667 26.0003 23.1667H19.3337C19.3337 23.9027 18.7377 24.5 18.0003 24.5H2.00033C1.26299 24.5 0.666992 23.9027 0.666992 23.1667C0.666992 18.02 4.85499 13.8333 10.0003 13.8333C12.5697 13.8333 14.8977 14.8773 16.587 16.5613C17.7457 15.6653 19.1723 15.1667 20.667 15.1667C24.343 15.1667 27.3337 18.1573 27.3337 21.8333Z" fill="none"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 916 B

View File

@ -0,0 +1,15 @@
import * as React from 'react'
import type { SVGProps } from 'react'
const PeopleIcon = (props: SVGProps<SVGSVGElement>) => (
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="25" viewBox="0 0 28 25" fill="none" {...props}>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10.0003 11.1667C12.9417 11.1667 15.3337 8.77467 15.3337 5.83333C15.3337 2.892 12.9417 0.5 10.0003 0.5C7.05899 0.5 4.66699 2.892 4.66699 5.83333C4.66699 8.77467 7.05899 11.1667 10.0003 11.1667ZM20.667 13.8333C22.8723 13.8333 24.667 12.0387 24.667 9.83333C24.667 7.628 22.8723 5.83333 20.667 5.83333C18.4617 5.83333 16.667 7.628 16.667 9.83333C16.667 12.0387 18.4617 13.8333 20.667 13.8333ZM27.3337 21.8333C27.3337 22.5693 26.7377 23.1667 26.0003 23.1667H19.3337C19.3337 23.9027 18.7377 24.5 18.0003 24.5H2.00033C1.26299 24.5 0.666992 23.9027 0.666992 23.1667C0.666992 18.02 4.85499 13.8333 10.0003 13.8333C12.5697 13.8333 14.8977 14.8773 16.587 16.5613C17.7457 15.6653 19.1723 15.1667 20.667 15.1667C24.343 15.1667 27.3337 18.1573 27.3337 21.8333Z"
fill={props.fill ?? 'white'}
/>
</svg>
)
export default PeopleIcon

View File

@ -0,0 +1,12 @@
.ControlBarContainer {
position: relative;
}
.ControlBarRightButtonGroup {
position: absolute;
right: 0;
display: flex;
gap: 0.5rem;
align-items: center;
justify-content: center;
}

View File

@ -1,5 +1,4 @@
import * as React from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { supportsScreenSharing } from '@livekit/components-core'
import {
ChatToggle,
@ -8,16 +7,18 @@ import {
StartAudio,
TrackToggle,
useLocalParticipantPermissions,
useMaybeLayoutContext,
useRoomContext
} from '@livekit/components-react'
import { LocalAudioTrack, LocalVideoTrack, Track } from 'livekit-client'
import ChatIcon from '../../../assets/icons/ChatIcon'
import LeaveIcon from '../../../assets/icons/LeaveIcon'
import { useLayoutContext } from '../../../hooks/useLayoutContext'
import { useMediaQuery } from '../../../hooks/useMediaQuery'
import { usePreviewTracks } from '../../../hooks/usePreviewTracks'
import { mergeProps } from '../../../utils/mergeProps'
import PeoplePanelToggleButton from './PeoplePanelToggleButton'
import { ControlBarProps, DEFAULT_USER_CHOICES } from './ControlBar.types'
import styles from './ControlBar.module.css'
/**
* The ControlBar prefab component gives the user the basic user interface
@ -37,7 +38,7 @@ import { ControlBarProps, DEFAULT_USER_CHOICES } from './ControlBar.types'
*/
export function ControlBar({ variation, controls, ...props }: ControlBarProps) {
const [isChatOpen, setIsChatOpen] = useState(false)
const layoutContext = useMaybeLayoutContext()
const layoutContext = useLayoutContext()
const {
options: { videoCaptureDefaults, audioCaptureDefaults }
} = useRoomContext()
@ -73,7 +74,7 @@ export function ControlBar({ variation, controls, ...props }: ControlBarProps) {
setIsScreenShareEnabled(enabled)
}
const htmlProps = mergeProps({ className: 'lk-control-bar' }, props)
const htmlProps = mergeProps({ className: `lk-control-bar ${styles.ControlBarContainer}` }, props)
const [videoEnabled, setVideoEnabled] = useState<boolean>(visibleControls.camera ?? DEFAULT_USER_CHOICES.videoEnabled)
const initialVideoDeviceId = (videoCaptureDefaults?.deviceId as string) ?? DEFAULT_USER_CHOICES.videoDeviceId
@ -166,12 +167,6 @@ export function ControlBar({ variation, controls, ...props }: ControlBarProps) {
{showText && (isScreenShareEnabled ? 'Stop screen share' : 'Share screen')}
</TrackToggle>
)}
{visibleControls.chat && (
<ChatToggle>
{showIcon && <ChatIcon />}
{showText && 'Chat'}
</ChatToggle>
)}
{visibleControls.leave && (
<DisconnectButton>
{showIcon && <LeaveIcon />}
@ -179,6 +174,15 @@ export function ControlBar({ variation, controls, ...props }: ControlBarProps) {
</DisconnectButton>
)}
<StartAudio label="Start Audio" />
<div className={styles.ControlBarRightButtonGroup}>
{visibleControls.chat && (
<ChatToggle>
{showIcon && <ChatIcon />}
{showText && 'Chat'}
</ChatToggle>
)}
{visibleControls.peoplePanel && <PeoplePanelToggleButton />}
</div>
</div>
)
}

View File

@ -8,7 +8,9 @@ export const DEFAULT_USER_CHOICES = {
}
/** @public */
export type ControlBarControls = BaseControlBarControls
export type ControlBarControls = BaseControlBarControls & {
peoplePanel?: boolean
}
/** @public */
export interface ControlBarProps extends BaseControlBarProps {

View File

@ -0,0 +1,13 @@
import { connect } from 'react-redux'
import { getData as getProfiles } from 'decentraland-dapps/dist/modules/profile/selectors'
import { RootState } from '../../../../modules/reducer'
import { PeoplePanelToggleButton } from './PeoplePanelToggleButton'
import type { MapStateProps } from './PeoplePanelToggleButton.types'
const mapStateToProps = (state: RootState): MapStateProps => {
return {
peopleCount: Object.keys(getProfiles(state)).filter(address => !!address).length
}
}
export default connect(mapStateToProps)(PeoplePanelToggleButton)

View File

@ -0,0 +1,20 @@
.button {
background-color: transparent;
padding: 0.625rem 1rem;
}
.buttonActive {
background-color: var(--lk-control-active-bg);
}
.peopleIcon {
height: 18px;
width: 18px;
}
.label {
position: relative;
top: -16px;
left: -14px;
min-width: 40px;
}

View File

@ -0,0 +1,36 @@
import React, { useCallback, useMemo } from 'react'
import classNames from 'classnames'
import { Label } from 'decentraland-ui'
import PeopleIcon from '../../../../assets/icons/PeopleIcon'
import { WidgetState, useLayoutContext } from '../../../../hooks/useLayoutContext'
import type { Props } from './PeoplePanelToggleButton.types'
import styles from './PeoplePanelToggleButton.module.css'
export const PeoplePanelToggleButton: React.FC<Props> = ({ peopleCount = 0, onClick }) => {
const layoutContext = useLayoutContext()
const isPeoplePanelActive = useMemo(() => {
return (layoutContext.widget.state as WidgetState).showPeoplePanel
}, [layoutContext.widget.state])
const handleTogglePeoplePanel = useCallback(() => {
const { dispatch } = layoutContext.widget
if (dispatch) {
dispatch({ msg: 'toggle_people_panel' })
}
}, [layoutContext])
return (
<>
<button
className={classNames('lk-button', styles.button, { [styles.buttonActive]: isPeoplePanelActive })}
onClick={onClick ?? handleTogglePeoplePanel}
>
<PeopleIcon className={styles.peopleIcon} />
</button>
<Label size="small" circular className={styles.label} content={peopleCount} />
</>
)
}
export default React.memo(PeoplePanelToggleButton)

View File

@ -0,0 +1,6 @@
export type Props = {
peopleCount?: number
onClick?: () => void
}
export type MapStateProps = Pick<Props, 'peopleCount'>

View File

@ -0,0 +1,3 @@
import PeoplePanelToggleButton from './PeoplePanelToggleButton.container'
export default PeoplePanelToggleButton

View File

@ -0,0 +1,6 @@
.container {
height: 100%;
width: 100%;
flex-direction: column;
align-items: stretch;
}

View File

@ -1,8 +1,9 @@
import * as React from 'react'
import type { ChatMessage, ReceivedChatMessage } from '@livekit/components-core'
import { MessageFormatter, useLocalParticipant, useMaybeLayoutContext, useRoomContext } from '@livekit/components-react'
import * as React from 'react'
import ChatEntry from './ChatEntry'
import { cloneSingleChild, setupChat, useObservableState } from './utils'
import { ChatEntry } from './ChatEntry'
import styles from './Chat.module.css'
export type { ChatMessage, ReceivedChatMessage }
@ -89,7 +90,7 @@ export default function Chat({ messageFormatter, ...props }: ChatProps) {
const localParticipant = useLocalParticipant().localParticipant
return (
<div {...props} className="lk-chat">
<div {...props} className={styles.container}>
<ul className="lk-list lk-chat-messages" ref={ulRef}>
{props.children
? chatMessages.map((msg, idx) =>

View File

@ -1,7 +1,7 @@
import { tokenize, createDefaultGrammar, ReceivedChatMessage } from '@livekit/components-core'
import * as React from 'react'
import Profile from 'decentraland-dapps/dist/containers/Profile'
import { tokenize, createDefaultGrammar, ReceivedChatMessage } from '@livekit/components-core'
import { MessageFormatter } from '@livekit/components-react'
import Profile from 'decentraland-dapps/dist/containers/Profile'
/**
* ChatEntry composes the HTML div element under the hood, so you can pass all its props.

View File

@ -0,0 +1,3 @@
import { ChatEntry } from './ChatEntry'
export default ChatEntry

View File

@ -0,0 +1,13 @@
import { connect } from 'react-redux'
import { getData as getProfiles } from 'decentraland-dapps/dist/modules/profile/selectors'
import { RootState } from '../../../../modules/reducer'
import PeoplePanel from './PeoplePanel'
import type { MapStateProps } from './PeoplePanel.types'
const mapStateToProps = (state: RootState): MapStateProps => {
return {
profiles: getProfiles(state)
}
}
export default connect(mapStateToProps)(PeoplePanel)

View File

@ -0,0 +1,71 @@
.container {
display: none;
flex-direction: column;
flex-shrink: 0;
width: 100%;
}
.open {
display: flex;
}
.headerContainer {
display: flex;
justify-content: space-between;
width: 100%;
margin-bottom: 16px;
}
.close:global(.ui.button) {
padding: 0;
min-width: 24px;
width: 24px;
background: url('../../../../assets/icons/Close.svg');
color: var(--toast-text);
}
.close:global(.ui.button):hover {
transform: none;
box-shadow: none;
}
.title:global(.ui.header) {
font-size: 24px;
margin-bottom: 0px;
font-size: 24px;
font-weight: 600;
line-height: 24px;
color: var(--toast-text);
}
.subtitle:global(.ui.header) {
margin-top: 0;
margin-bottom: 18px;
font-size: 20px;
font-weight: 400;
line-height: 24px;
color: var(--toast-text);
}
.profile:not(:last-child) {
margin-bottom: 18px;
}
:global(div.ProfileContainer > span.Profile) {
display: flex;
padding: 2px 181px 2px 2px;
align-items: center;
gap: 12px;
}
:global(div.ProfileContainer > span.Profile > div.dcl.avatar-face.tiny.inline) {
width: 40px;
height: 40px;
}
:global(div.ProfileContainer > span.Profile > span.name) {
color: var(--toast-text);
font-size: 16px;
font-weight: 600;
line-height: 24px;
}

View File

@ -0,0 +1,51 @@
import React, { useCallback } from 'react'
import classNames from 'classnames'
import { t } from 'decentraland-dapps/dist/modules/translation/utils'
import { Button, Header, Profile } from 'decentraland-ui'
import { useLayoutContext } from '../../../../hooks/useLayoutContext'
import type { Props } from './PeoplePanel.types'
import styles from './PeoplePanel.module.css'
/**
* The PeoplePanel component shows all the participants in a list.
*
* @example
* ```tsx
* <PeoplePanel />
* ```
* @public
*/
export const PeoplePanel: React.FC<Props> = ({ profiles, isOpen }: Props) => {
const layoutContext = useLayoutContext()
const handleClosePanel = useCallback(() => {
const { dispatch } = layoutContext.widget
if (dispatch) {
dispatch({ msg: 'hide_people_panel' })
}
}, [layoutContext])
return (
<div className={classNames(styles.container, { [styles['open']]: isOpen })}>
<div className={styles.headerContainer}>
<Header className={styles.title} size="medium">
{t('people_panel.title')}
</Header>
<Button className={styles.close} onClick={handleClosePanel} />
</div>
<Header className={styles.subtitle} size="medium">
{t('people_panel.subtitle')}
</Header>
{Object.entries(profiles).map(([address, profile]) =>
address ? (
<div className={`${styles.profile} ProfileContainer`}>
<Profile key={address} address={address} avatar={profile?.avatars[0]} />
</div>
) : null
)}
</div>
)
}
export default React.memo(PeoplePanel)

View File

@ -0,0 +1,6 @@
export type Props = {
profiles: ReturnType<typeof import('decentraland-dapps/dist/modules/profile/selectors').getData>
isOpen: boolean
}
export type MapStateProps = Pick<Props, 'profiles'>

View File

@ -0,0 +1,3 @@
import PeoplePanel from './PeoplePanel.container'
export default PeoplePanel

View File

@ -0,0 +1,12 @@
.container {
display: none;
height: 100%;
width: 441px;
padding: 16px 30px 0px 30px;
border-radius: 10px;
border: 1px solid var(--dcl-cloudy-sky, #716b7c);
}
.container.open {
display: flex;
}

View File

@ -0,0 +1,23 @@
import React, { useMemo } from 'react'
import classNames from 'classnames'
import { WidgetState, useLayoutContext } from '../../../hooks/useLayoutContext'
import Chat from './Chat'
import PeoplePanel from './PeoplePanel'
import styles from './RightPanel.module.css'
export const RightPanel = () => {
const layoutContextValue = useLayoutContext()
const { showChat, showPeoplePanel } = useMemo(() => {
return layoutContextValue.widget.state as WidgetState
}, [layoutContextValue.widget.state])
return (
<div className={classNames(styles.container, { [styles.open]: showChat || showPeoplePanel })}>
<PeoplePanel isOpen={showPeoplePanel} />
<Chat style={{ display: showChat ? 'flex' : 'none' }} />
</div>
)
}
export default React.memo(RightPanel)

View File

@ -0,0 +1 @@
export { default } from './RightPanel'

View File

@ -0,0 +1,7 @@
.LayoutWrapper {
flex-direction: row;
}
.GridLayout {
flex: 1;
}

View File

@ -1,4 +1,4 @@
import * as React from 'react'
import React, { useRef, useEffect } from 'react'
import { isEqualTrackRef, isTrackReference, log, isWeb } from '@livekit/components-core'
import {
CarouselView,
@ -8,16 +8,18 @@ import {
GridLayout,
LayoutContextProvider,
RoomAudioRenderer,
useCreateLayoutContext,
usePinnedTracks,
useTracks
} from '@livekit/components-react'
import classNames from 'classnames'
import { RoomEvent, Track } from 'livekit-client'
import Chat from '../Chat'
import { useCreateLayoutContext } from '../../../hooks/useLayoutContext'
import { ControlBar } from '../ControlBar'
import ParticipantTile from '../ParticipantTile'
import { VideoConferenceProps } from './VideoConference.types'
import type { TrackReferenceOrPlaceholder, WidgetState } from '@livekit/components-core'
import RightPanel from '../RightPanel'
import type { VideoConferenceProps } from './VideoConference.types'
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import styles from './VideoConference.module.css'
/**
* This component is the default setup of a classic LiveKit video conferencing app.
@ -35,9 +37,8 @@ import type { TrackReferenceOrPlaceholder, WidgetState } from '@livekit/componen
* ```
* @public
*/
export function VideoConference({ chatMessageFormatter, ...props }: VideoConferenceProps) {
const [widgetState, setWidgetState] = React.useState<WidgetState>({ showChat: false, unreadMessages: 0 })
const lastAutoFocusedScreenShareTrack = React.useRef<TrackReferenceOrPlaceholder | null>(null)
export function VideoConference(props: VideoConferenceProps) {
const lastAutoFocusedScreenShareTrack = useRef<TrackReferenceOrPlaceholder | null>(null)
const tracks = useTracks(
[
@ -47,11 +48,6 @@ export function VideoConference({ chatMessageFormatter, ...props }: VideoConfere
{ 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)
@ -59,7 +55,7 @@ export function VideoConference({ chatMessageFormatter, ...props }: VideoConfere
const focusTrack = usePinnedTracks(layoutContext)?.[0]
const carouselTracks = tracks.filter(track => !isEqualTrackRef(track, focusTrack))
React.useEffect(() => {
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] })
@ -78,17 +74,14 @@ export function VideoConference({ chatMessageFormatter, ...props }: VideoConfere
return (
<div className="lk-video-conference" {...props}>
{isWeb() && (
<LayoutContextProvider
value={layoutContext}
// onPinChange={handleFocusStateChange}
onWidgetChange={widgetUpdate}
>
<LayoutContextProvider value={layoutContext}>
<div className="lk-video-conference-inner">
{!focusTrack ? (
<div className="lk-grid-layout-wrapper">
<GridLayout tracks={tracks}>
<div className={classNames('lk-grid-layout-wrapper', styles.LayoutWrapper)}>
<GridLayout tracks={tracks} className={styles.GridLayout}>
<ParticipantTile imageSize="massive" />
</GridLayout>
<RightPanel />
</div>
) : (
<div className="lk-focus-layout-wrapper">
@ -100,9 +93,8 @@ export function VideoConference({ chatMessageFormatter, ...props }: VideoConfere
</FocusLayoutContainer>
</div>
)}
<ControlBar controls={{ chat: true }} variation="minimal" />
<ControlBar controls={{ chat: true, peoplePanel: true }} variation="minimal" />
</div>
<Chat style={{ display: widgetState.showChat ? 'flex' : 'none' }} messageFormatter={chatMessageFormatter} />
</LayoutContextProvider>
)}
<RoomAudioRenderer />

View File

@ -1,8 +1,4 @@
import { MessageFormatter } from '@livekit/components-react'
/**
* @public
*/
export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElement> {
chatMessageFormatter?: MessageFormatter
}
export type VideoConferenceProps = React.HTMLAttributes<HTMLDivElement>

View File

@ -1,3 +1,3 @@
export { default as Chat } from './Chat'
export { default as Chat } from './RightPanel/Chat'
export { default as ParticipantTile } from './ParticipantTile'
export { VideoConference } from './VideoConference'

View File

@ -0,0 +1,127 @@
import React, { useContext, useReducer } from 'react'
import { WIDGET_DEFAULT_STATE, PIN_DEFAULT_STATE } from '@livekit/components-core'
import { LayoutContext } from '@livekit/components-react'
import type { WidgetState as LivekitWidgetState, PinState, TrackReference } from '@livekit/components-core'
export type PinAction =
| {
msg: 'set_pin'
trackReference: TrackReference
}
| { msg: 'clear_pin' }
export type PinContextType = {
dispatch?: React.Dispatch<PinAction>
state?: PinState
}
export function pinReducer(state: PinState, action: PinAction): PinState {
if (action.msg === 'set_pin') {
return [action.trackReference]
} else if (action.msg === 'clear_pin') {
return []
} else {
return { ...state }
}
}
export type WidgetState = LivekitWidgetState & {
showPeoplePanel: boolean
}
const widgetDefaultState: WidgetState = {
...WIDGET_DEFAULT_STATE,
showPeoplePanel: false
}
type WidgetContextAction =
| { msg: 'show_chat' }
| { msg: 'hide_chat' }
| { msg: 'toggle_chat' }
| { msg: 'unread_msg'; count: number }
| { msg: 'show_people_panel' }
| { msg: 'hide_people_panel' }
| { msg: 'toggle_people_panel' }
export type WidgetContextType = {
dispatch?: React.Dispatch<WidgetContextAction>
state?: WidgetState
}
function widgetReducer(state: WidgetState, action: WidgetContextAction): WidgetState {
switch (action.msg) {
case 'show_chat': {
return { ...state, showChat: true, unreadMessages: 0 }
}
case 'hide_chat': {
return { ...state, showChat: false }
}
case 'toggle_chat': {
const newState = { ...state, showChat: !state.showChat }
if (newState.showChat === true) {
newState.unreadMessages = 0
newState.showPeoplePanel = false
}
return newState
}
case 'unread_msg': {
return { ...state, unreadMessages: action.count }
}
case 'show_people_panel': {
return { ...state, showPeoplePanel: true }
}
case 'hide_people_panel': {
return { ...state, showPeoplePanel: false }
}
case 'toggle_people_panel': {
const newState = { ...state, showPeoplePanel: !state.showPeoplePanel }
if (newState.showPeoplePanel === true) {
newState.showChat = false
}
return newState
}
default: {
return { ...state }
}
}
}
export type LayoutContextType = {
pin: PinContextType
widget: WidgetContextType
}
/**
* @public
*/
export function useCreateLayoutContext(): LayoutContextType {
const [pinState, pinDispatch] = useReducer(pinReducer, PIN_DEFAULT_STATE)
const [widgetState, widgetDispatch] = useReducer(widgetReducer, widgetDefaultState)
return {
pin: { dispatch: pinDispatch, state: pinState },
widget: { dispatch: widgetDispatch, state: widgetState }
}
}
export function useEnsureCreateLayoutContext(layoutContext?: LayoutContextType): LayoutContextType {
const [pinState, pinDispatch] = useReducer(pinReducer, PIN_DEFAULT_STATE)
const [widgetState, widgetDispatch] = useReducer(widgetReducer, widgetDefaultState)
return (
layoutContext ?? {
pin: { dispatch: pinDispatch, state: pinState },
widget: { dispatch: widgetDispatch, state: widgetState }
}
)
}
/**
* @public
*/
export function useLayoutContext(): LayoutContextType {
const layoutContext = useContext(LayoutContext)
if (!layoutContext) {
throw Error('Tried to access LayoutContext context outside a LayoutContextProvider provider.')
}
return layoutContext as LayoutContextType
}

View File

@ -6,5 +6,9 @@
"cta": "Connect",
"input_label": "World name",
"input_placeholder": "example:<name>.dcl.eth"
},
"people_panel": {
"title": "People",
"subtitle": "People in this meeting"
}
}

View File

@ -6,5 +6,9 @@
"cta": "Conectar",
"input_label": "Nombre del mundo",
"input_placeholder": "ejemplo:<name>.dcl.eth"
},
"people_panel": {
"title": "Personas",
"subtitle": "Personas en esta reunión"
}
}

View File

@ -6,5 +6,9 @@
"cta": "连接",
"input_label": "世界名称",
"input_placeholder": "example:<name>.dcl.eth"
},
"people_panel": {
"title": "人们",
"subtitle": "这次会议的人"
}
}