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:
parent
1510f36439
commit
ae1ac2d5bf
18
.husky/pre-commit
Executable file
18
.husky/pre-commit
Executable 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
|
||||
@ -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": ""
|
||||
}
|
||||
}
|
||||
|
||||
4
src/assets/icons/Close.svg
Normal file
4
src/assets/icons/Close.svg
Normal 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 |
@ -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="🎨 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 |
15
src/assets/icons/PeopleIcon.tsx
Normal file
15
src/assets/icons/PeopleIcon.tsx
Normal 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
|
||||
@ -0,0 +1,12 @@
|
||||
.ControlBarContainer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ControlBarRightButtonGroup {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
@ -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;
|
||||
}
|
||||
@ -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)
|
||||
@ -0,0 +1,6 @@
|
||||
export type Props = {
|
||||
peopleCount?: number
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export type MapStateProps = Pick<Props, 'peopleCount'>
|
||||
@ -0,0 +1,3 @@
|
||||
import PeoplePanelToggleButton from './PeoplePanelToggleButton.container'
|
||||
|
||||
export default PeoplePanelToggleButton
|
||||
@ -0,0 +1,6 @@
|
||||
.container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
@ -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) =>
|
||||
@ -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.
|
||||
@ -0,0 +1,3 @@
|
||||
import { ChatEntry } from './ChatEntry'
|
||||
|
||||
export default ChatEntry
|
||||
@ -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)
|
||||
@ -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;
|
||||
}
|
||||
@ -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)
|
||||
@ -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'>
|
||||
@ -0,0 +1,3 @@
|
||||
import PeoplePanel from './PeoplePanel.container'
|
||||
|
||||
export default PeoplePanel
|
||||
@ -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;
|
||||
}
|
||||
23
src/components/VideoConference/RightPanel/RightPanel.tsx
Normal file
23
src/components/VideoConference/RightPanel/RightPanel.tsx
Normal 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)
|
||||
1
src/components/VideoConference/RightPanel/index.ts
Normal file
1
src/components/VideoConference/RightPanel/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './RightPanel'
|
||||
@ -0,0 +1,7 @@
|
||||
.LayoutWrapper {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.GridLayout {
|
||||
flex: 1;
|
||||
}
|
||||
@ -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 />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'
|
||||
|
||||
127
src/hooks/useLayoutContext.ts
Normal file
127
src/hooks/useLayoutContext.ts
Normal 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
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,5 +6,9 @@
|
||||
"cta": "连接",
|
||||
"input_label": "世界名称",
|
||||
"input_placeholder": "example:<name>.dcl.eth"
|
||||
},
|
||||
"people_panel": {
|
||||
"title": "人们",
|
||||
"subtitle": "这次会议的人"
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user