Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c85c176f1 | |||
| d91f8bb6e1 | |||
| d612f9b26b | |||
| 042be17cfa | |||
| d00f4fa695 |
+5
-7
@@ -11,16 +11,10 @@ and this project adheres to
|
||||
### Changed
|
||||
|
||||
- ♿️(frontend) sync html lang attribute with i18n for screen readers #1111
|
||||
- ♿️(frontend) improve MoreLink a11y and UX on home page #1112
|
||||
|
||||
## [1.10.0] - 2026-03-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🩹(frontend) remove incorrect reference to ProConnect on the prejoin #1080
|
||||
- ✨(frontend) add Ctrl+Shift+/ to open shortcuts settings #1050
|
||||
- ♿(frontend) announce selected state to screen readers #1081
|
||||
- 💄(frontend) truncate long names with ellipsis in reaction overlay #1099
|
||||
|
||||
### Changed
|
||||
|
||||
- 🔒️(backend) enhance API input validation to strengthen security #1053
|
||||
@@ -34,6 +28,10 @@ and this project adheres to
|
||||
- 💄(frontend) truncate pinned participant name with ellipsis on overflow #1056
|
||||
- ♿(frontend) prevent focus ring clipping on invite dialog #1078
|
||||
- ♿(frontend) dynamic tab title when connected to meeting #1060
|
||||
- 🩹(frontend) remove incorrect reference to ProConnect on the prejoin #1080
|
||||
- ✨(frontend) add Ctrl+Shift+/ to open shortcuts settings #1050
|
||||
- ♿(frontend) announce selected state to screen readers #1081
|
||||
- 💄(frontend) truncate long names with ellipsis in reaction overlay #1099
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
@@ -2,23 +2,25 @@ import { A, Text } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
const appTitle = import.meta.env.VITE_APP_TITLE ?? 'LaSuite Meet'
|
||||
|
||||
export const MoreLink = () => {
|
||||
const { t } = useTranslation('home')
|
||||
const { data } = useConfig()
|
||||
|
||||
if (!data?.manifest_link) return
|
||||
if (!data?.manifest_link) return null
|
||||
|
||||
return (
|
||||
<Text as={'p'} variant={'sm'} style={{ padding: '1rem 0' }}>
|
||||
<Text as="p" variant="sm" style={{ padding: '1rem 0' }}>
|
||||
<A
|
||||
href={data?.manifest_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t('moreLinkLabel')}
|
||||
externalIcon
|
||||
aria-label={t('moreLinkLabel', { appTitle })}
|
||||
>
|
||||
{t('moreLink')}
|
||||
</A>{' '}
|
||||
{t('moreAbout', { appTitle: `${import.meta.env.VITE_APP_TITLE}` })}
|
||||
{t('moreLink')} {t('moreAbout', { appTitle })}
|
||||
</A>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -258,23 +258,10 @@ export const Home = () => {
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
<Separator />
|
||||
<div
|
||||
className={css({
|
||||
display: { base: 'none', lg: 'inline' },
|
||||
})}
|
||||
>
|
||||
<MoreLink />
|
||||
</div>
|
||||
<MoreLink />
|
||||
</LeftColumn>
|
||||
<RightColumn>
|
||||
<IntroSlider />
|
||||
<div
|
||||
className={css({
|
||||
display: { base: 'inline', lg: 'none' },
|
||||
})}
|
||||
>
|
||||
<MoreLink />
|
||||
</div>
|
||||
</RightColumn>
|
||||
</Columns>
|
||||
<LaterMeetingDialog
|
||||
|
||||
@@ -10,20 +10,14 @@ import { decodeNotificationDataReceived } from './utils'
|
||||
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
|
||||
import { ToastProvider, toastQueue } from './components/ToastProvider'
|
||||
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
|
||||
import {
|
||||
Emoji,
|
||||
Reaction,
|
||||
} from '@/features/rooms/livekit/components/controls/ReactionsToggle'
|
||||
import {
|
||||
ANIMATION_DURATION,
|
||||
ReactionPortals,
|
||||
} from '@/features/rooms/livekit/components/ReactionPortal'
|
||||
import { Emoji } from '@/features/rooms/livekit/components/controls/ReactionsToggle'
|
||||
import { ANIMATION_DURATION } from '@/features/rooms/livekit/components/ReactionPortal'
|
||||
import { reactionsStore } from '@/stores/reaction.ts'
|
||||
|
||||
export const MainNotificationToast = () => {
|
||||
const room = useRoomContext()
|
||||
const { triggerNotificationSound } = useNotificationSound()
|
||||
|
||||
const [reactions, setReactions] = useState<Reaction[]>([])
|
||||
const instanceIdRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -51,16 +45,22 @@ export const MainNotificationToast = () => {
|
||||
const handleEmoji = (emoji: string, participant: Participant) => {
|
||||
if (!emoji || !Object.values(Emoji).includes(emoji as Emoji)) return
|
||||
const id = instanceIdRef.current++
|
||||
setReactions((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
emoji,
|
||||
participant,
|
||||
},
|
||||
])
|
||||
|
||||
const newReaction = {
|
||||
id: `distant-${id}`,
|
||||
emoji: emoji,
|
||||
isLocalParticipant: false,
|
||||
participantName: participant.name || `${participant.identity}`,
|
||||
}
|
||||
|
||||
reactionsStore.reactions.push(newReaction)
|
||||
|
||||
// Remove this reaction after animation
|
||||
setTimeout(() => {
|
||||
setReactions((prev) => prev.filter((instance) => instance.id !== id))
|
||||
const index = reactionsStore.reactions.findIndex(
|
||||
(r) => r.id === newReaction.id
|
||||
)
|
||||
if (index !== -1) reactionsStore.reactions.splice(index, 1)
|
||||
}, ANIMATION_DURATION)
|
||||
}
|
||||
|
||||
@@ -238,7 +238,6 @@ export const MainNotificationToast = () => {
|
||||
<Div position="absolute" bottom={0} right={5} zIndex={1000}>
|
||||
<ToastProvider />
|
||||
<WaitingParticipantNotification />
|
||||
<ReactionPortals reactions={reactions} />
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
|
||||
import { accessibilityStore } from '@/stores/accessibility'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
import { reactionsStore } from '@/stores/reaction.ts'
|
||||
|
||||
export const ANIMATION_DURATION = 3000
|
||||
export const ANIMATION_DISTANCE = 300
|
||||
@@ -118,12 +119,13 @@ export function FloatingReaction({
|
||||
|
||||
export function ReactionPortal({
|
||||
emoji,
|
||||
participant,
|
||||
participantName,
|
||||
isLocalParticipant,
|
||||
}: {
|
||||
emoji: string
|
||||
participant: Participant
|
||||
participantName: string
|
||||
isLocalParticipant: boolean
|
||||
}) {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||
const speed = useMemo(() => Math.random() * 1.5 + 0.5, [])
|
||||
const scale = useMemo(() => Math.max(Math.random() + 0.5, 1), [])
|
||||
return createPortal(
|
||||
@@ -141,17 +143,18 @@ export function ReactionPortal({
|
||||
emoji={emoji}
|
||||
speed={speed}
|
||||
scale={scale}
|
||||
name={participant?.isLocal ? t('you') : participant.name}
|
||||
isLocal={participant?.isLocal}
|
||||
name={participantName}
|
||||
isLocal={isLocalParticipant}
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
|
||||
export const ReactionPortals = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||
const { announceReactions } = useSnapshot(accessibilityStore)
|
||||
const { reactions } = useSnapshot(reactionsStore)
|
||||
const [lastAnnouncedId, setLastAnnouncedId] = useState<number | null>(null)
|
||||
const announce = useScreenReaderAnnounce()
|
||||
|
||||
@@ -181,7 +184,8 @@ export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
|
||||
<ReactionPortal
|
||||
key={instance.id}
|
||||
emoji={instance.emoji}
|
||||
participant={instance.participant}
|
||||
participantName={instance.participantName}
|
||||
isLocalParticipant={instance.isLocalParticipant}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -14,6 +14,11 @@ import { Admin } from './Admin'
|
||||
import { Tools } from './Tools'
|
||||
import { Info } from './Info'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import {
|
||||
WIP_ADDITIONAL_HEIGHT_REACTION,
|
||||
WIP_MIN_HEIGHT,
|
||||
} from '@/features/rooms/livekit/prefabs/VideoConference'
|
||||
|
||||
type StyledSidePanelProps = {
|
||||
title: string
|
||||
@@ -35,6 +40,7 @@ const StyledSidePanel = ({
|
||||
isClosed,
|
||||
closeButtonTooltip,
|
||||
isSubmenu = false,
|
||||
isReactionOpen,
|
||||
onBack,
|
||||
backButtonLabel,
|
||||
}: StyledSidePanelProps) => (
|
||||
@@ -56,12 +62,14 @@ const StyledSidePanel = ({
|
||||
gap: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: '80px',
|
||||
width: '360px',
|
||||
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
|
||||
})}
|
||||
style={{
|
||||
transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
|
||||
bottom: isReactionOpen
|
||||
? `${WIP_MIN_HEIGHT + WIP_ADDITIONAL_HEIGHT_REACTION}px`
|
||||
: `${WIP_MIN_HEIGHT}px`,
|
||||
}}
|
||||
aria-hidden={isClosed}
|
||||
aria-label={ariaLabel}
|
||||
@@ -150,6 +158,9 @@ export const SidePanel = () => {
|
||||
} = useSidePanel()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
||||
|
||||
const layoutSnap = useSnapshot(layoutStore)
|
||||
const isReactionOpen = layoutSnap.showReaction
|
||||
|
||||
return (
|
||||
<StyledSidePanel
|
||||
title={t(`heading.${activeSubPanelId || activePanelId}`)}
|
||||
@@ -165,6 +176,7 @@ export const SidePanel = () => {
|
||||
isSubmenu={isSubPanelOpen}
|
||||
backButtonLabel={t('backToTools')}
|
||||
onBack={() => (layoutStore.activeSubPanelId = null)}
|
||||
isReactionOpen={isReactionOpen}
|
||||
>
|
||||
<Panel isOpen={isParticipantsOpen}>
|
||||
<ParticipantsList />
|
||||
|
||||
@@ -12,14 +12,11 @@ import {
|
||||
} from '@/features/rooms/livekit/components/ReactionPortal'
|
||||
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import {
|
||||
Popover as RACPopover,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
} from 'react-aria-components'
|
||||
import { FocusScope } from '@react-aria/focus'
|
||||
import { Participant } from 'livekit-client'
|
||||
import { FocusScope, useFocusManager } from '@react-aria/focus'
|
||||
import useRateLimiter from '@/hooks/useRateLimiter'
|
||||
import { layoutStore } from '@/stores/layout.ts'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { reactionsStore } from '@/stores/reaction.ts'
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export enum Emoji {
|
||||
@@ -36,22 +33,117 @@ export enum Emoji {
|
||||
export interface Reaction {
|
||||
id: number
|
||||
emoji: string
|
||||
participant: Participant
|
||||
isLocalParticipant: boolean
|
||||
participantName: string
|
||||
}
|
||||
|
||||
export const ReactionsToggle = () => {
|
||||
const getFirstControlBarFocusable = () =>
|
||||
document
|
||||
.getElementById('desktop-control-bar')
|
||||
?.querySelector<HTMLButtonElement>('button:not([disabled])') ?? null
|
||||
|
||||
const ReactionButton = ({ emoji, debouncedSendReaction, index }) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||
const [reactions, setReactions] = useState<Reaction[]>([])
|
||||
const focusManager = useFocusManager()
|
||||
let onKeyDown = (e) => {
|
||||
console.log(e)
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
focusManager?.focusNext({ wrap: true })
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
focusManager?.focusPrevious({ wrap: true })
|
||||
break
|
||||
case 'Escape':
|
||||
layoutStore.showReaction = false
|
||||
break
|
||||
case 'Tab':
|
||||
console.log(getFirstControlBarFocusable)
|
||||
if (!e.shiftKey) getFirstControlBarFocusable()?.focus()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onPress={() => debouncedSendReaction(emoji)}
|
||||
onKeyDown={onKeyDown}
|
||||
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
|
||||
variant="primaryTextDark"
|
||||
size="sm"
|
||||
square
|
||||
round
|
||||
tabIndex={index != 0 && -1}
|
||||
data-attr={`send-reaction-${emoji}`}
|
||||
>
|
||||
<img
|
||||
src={`/assets/reactions/${emoji}.png`}
|
||||
alt=""
|
||||
className={css({
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const ForceFocusFirst = (props) => {
|
||||
const focusManager = useFocusManager()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.2rem',
|
||||
})}
|
||||
onFocus={(e) => {
|
||||
// Only trigger when focus enters from outside this div
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||
const w = focusManager?.focusFirst()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Toolbar = (props) => {
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
borderRadius: '20px',
|
||||
padding: '0.15rem',
|
||||
|
||||
backgroundColor: 'primaryDark.100',
|
||||
'&[data-entering]': {
|
||||
animation: 'fade 200ms ease',
|
||||
},
|
||||
'&[data-exiting]': {
|
||||
animation: 'fade 200ms ease-in reverse',
|
||||
},
|
||||
'@media (min-width: 610px)': {
|
||||
marginRight: '59px',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<FocusScope autoFocus>
|
||||
<ForceFocusFirst>{props.children}</ForceFocusFirst>
|
||||
</FocusScope>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ReactionToolbar = () => {
|
||||
const instanceIdRef = useRef(0)
|
||||
const room = useRoomContext()
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'reaction',
|
||||
handler: () => setIsOpen((prev) => !prev),
|
||||
})
|
||||
|
||||
const sendReaction = async (emoji: string) => {
|
||||
const encoder = new TextEncoder()
|
||||
const payload: NotificationPayload = {
|
||||
@@ -64,17 +156,20 @@ export const ReactionsToggle = () => {
|
||||
await room.localParticipant.publishData(data, { reliable: true })
|
||||
|
||||
const newReaction = {
|
||||
id: instanceIdRef.current++,
|
||||
id: `local-${instanceIdRef.current++}`,
|
||||
emoji,
|
||||
participant: room.localParticipant,
|
||||
isLocalParticipant: room.localParticipant.isLocal,
|
||||
participantName: room.localParticipant.name,
|
||||
}
|
||||
setReactions((prev) => [...prev, newReaction])
|
||||
|
||||
reactionsStore.reactions.push(newReaction)
|
||||
|
||||
// Remove this reaction after animation
|
||||
setTimeout(() => {
|
||||
setReactions((prev) =>
|
||||
prev.filter((instance) => instance.id !== newReaction.id)
|
||||
const index = reactionsStore.reactions.findIndex(
|
||||
(r) => r.id === newReaction.id
|
||||
)
|
||||
if (index !== -1) reactionsStore.reactions.splice(index, 1)
|
||||
}, ANIMATION_DURATION)
|
||||
}
|
||||
|
||||
@@ -84,78 +179,49 @@ export const ReactionsToggle = () => {
|
||||
windowMs: 1000,
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Toolbar>
|
||||
{Object.values(Emoji).map((emoji, index) => (
|
||||
<ReactionButton
|
||||
key={index}
|
||||
index={index}
|
||||
emoji={emoji}
|
||||
debouncedSendReaction={debouncedSendReaction}
|
||||
/>
|
||||
))}
|
||||
</Toolbar>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ReactionsToggle = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||
|
||||
const layoutSnap = useSnapshot(layoutStore)
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'reaction',
|
||||
handler: () => (layoutStore.showReaction = !layoutSnap.showReaction),
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={css({ position: 'relative' })}>
|
||||
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<ToggleButton
|
||||
square
|
||||
variant="primaryDark"
|
||||
aria-label={t('button')}
|
||||
tooltip={t('button')}
|
||||
isSelected={isOpen}
|
||||
onChange={setIsOpen}
|
||||
>
|
||||
<RiEmotionLine />
|
||||
</ToggleButton>
|
||||
<RACPopover
|
||||
placement="top"
|
||||
offset={8}
|
||||
isNonModal
|
||||
shouldCloseOnInteractOutside={() => false}
|
||||
className={css({
|
||||
borderRadius: '8px',
|
||||
padding: '0.35rem',
|
||||
backgroundColor: 'primaryDark.50',
|
||||
'&[data-entering]': {
|
||||
animation: 'fade 200ms ease',
|
||||
},
|
||||
'&[data-exiting]': {
|
||||
animation: 'fade 200ms ease-in reverse',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Dialog className={css({ outline: 'none' })}>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus -- FocusScope autoFocus is programmatic focus for overlays, not the HTML autofocus attribute */}
|
||||
<FocusScope contain autoFocus restoreFocus>
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-orientation="horizontal"
|
||||
aria-label={t('button')}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{Object.values(Emoji).map((emoji, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
onPress={() => debouncedSendReaction(emoji)}
|
||||
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
|
||||
variant="primaryTextDark"
|
||||
size="sm"
|
||||
square
|
||||
data-attr={`send-reaction-${emoji}`}
|
||||
>
|
||||
<img
|
||||
src={`/assets/reactions/${emoji}.png`}
|
||||
alt=""
|
||||
className={css({
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
})}
|
||||
/>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</FocusScope>
|
||||
</Dialog>
|
||||
</RACPopover>
|
||||
</DialogTrigger>
|
||||
<ToggleButton
|
||||
square
|
||||
variant="primaryDark"
|
||||
aria-label={t('button')}
|
||||
tooltip={t('button')}
|
||||
isSelected={layoutSnap.showReaction}
|
||||
onChange={() => {
|
||||
layoutStore.showReaction = !layoutSnap.showReaction
|
||||
}}
|
||||
>
|
||||
<RiEmotionLine />
|
||||
</ToggleButton>
|
||||
</div>
|
||||
<ReactionPortals reactions={reactions} />
|
||||
<ReactionPortals />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@ export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
export function ControlBar({ onDeviceError }: ControlBarProps) {
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
if (isMobile) {
|
||||
return <MobileControlBar onDeviceError={onDeviceError} />
|
||||
}
|
||||
// if (isMobile) {
|
||||
// return <MobileControlBar onDeviceError={onDeviceError} />
|
||||
// }
|
||||
return <DesktopControlBar onDeviceError={onDeviceError} />
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ import { ControlBarAuxProps } from './ControlBar'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { LeaveButton } from '../../components/controls/LeaveButton'
|
||||
import { Track } from 'livekit-client'
|
||||
import { ReactionsToggle } from '../../components/controls/ReactionsToggle'
|
||||
import {
|
||||
ReactionsToggle,
|
||||
ReactionToolbar,
|
||||
} from '../../components/controls/ReactionsToggle'
|
||||
import { HandToggle } from '../../components/controls/HandToggle'
|
||||
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
|
||||
import { SubtitlesToggle } from '../../components/controls/SubtitlesToggle'
|
||||
@@ -15,6 +18,8 @@ import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKey
|
||||
import { useFullScreen } from '../../hooks/useFullScreen'
|
||||
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
||||
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { layoutStore } from '@/stores/layout.ts'
|
||||
|
||||
export function DesktopControlBar({
|
||||
onDeviceError,
|
||||
@@ -24,6 +29,8 @@ export function DesktopControlBar({
|
||||
|
||||
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
|
||||
|
||||
const layoutSnap = useSnapshot(layoutStore)
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'focus-toolbar',
|
||||
handler: () => {
|
||||
@@ -44,61 +51,76 @@ export function DesktopControlBar({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={desktopControlBarEl}
|
||||
className={css({
|
||||
width: '100vw',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
position: 'absolute',
|
||||
padding: '1.125rem',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
})}
|
||||
>
|
||||
{layoutSnap.showReaction && <ReactionToolbar />}
|
||||
<div
|
||||
ref={desktopControlBarEl}
|
||||
className={css({
|
||||
width: '100vw',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
flex: '1 1 33%',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
marginLeft: '0.5rem',
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
flex: '1 1 33%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
display: 'flex',
|
||||
gap: '0.65rem',
|
||||
padding: '1.125rem',
|
||||
})}
|
||||
style={{
|
||||
paddingTop: layoutSnap.showReaction ? '0.65rem' : undefined,
|
||||
}}
|
||||
>
|
||||
<AudioDevicesControl
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Microphone, error })
|
||||
}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
flex: '1 1 33%',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
marginLeft: '0.5rem',
|
||||
})}
|
||||
/>
|
||||
<VideoDeviceControl
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Camera, error })
|
||||
}
|
||||
/>
|
||||
<ReactionsToggle />
|
||||
{browserSupportsScreenSharing && (
|
||||
<ScreenShareToggle
|
||||
<div
|
||||
id="desktop-control-bar"
|
||||
className={css({
|
||||
flex: '1 1 33%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
display: 'flex',
|
||||
gap: '0.65rem',
|
||||
})}
|
||||
>
|
||||
<AudioDevicesControl
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.ScreenShare, error })
|
||||
onDeviceError?.({ source: Track.Source.Microphone, error })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SubtitlesToggle />
|
||||
<HandToggle />
|
||||
<OptionsButton />
|
||||
<LeaveButton />
|
||||
<StartMediaButton />
|
||||
<VideoDeviceControl
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Camera, error })
|
||||
}
|
||||
/>
|
||||
<ReactionsToggle />
|
||||
{browserSupportsScreenSharing && (
|
||||
<ScreenShareToggle
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.ScreenShare, error })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SubtitlesToggle />
|
||||
<HandToggle />
|
||||
<OptionsButton />
|
||||
<LeaveButton />
|
||||
<StartMediaButton />
|
||||
</div>
|
||||
<MoreOptions parentElement={desktopControlBarEl} />
|
||||
</div>
|
||||
<MoreOptions parentElement={desktopControlBarEl} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
log,
|
||||
} from '@livekit/components-core'
|
||||
import { Participant, RoomEvent, Track } from 'livekit-client'
|
||||
import React, { useCallback, useRef, useState, useEffect } from 'react'
|
||||
import React, { useCallback, useRef, useState, useEffect, useMemo } from 'react'
|
||||
import {
|
||||
ConnectionStateToast,
|
||||
FocusLayoutContainer,
|
||||
@@ -44,6 +44,11 @@ import { GridLayout } from '../components/layout/GridLayout'
|
||||
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { layoutStore } from '@/stores/layout.ts'
|
||||
|
||||
export const WIP_MIN_HEIGHT = 80
|
||||
export const WIP_ADDITIONAL_HEIGHT_REACTION = 32
|
||||
|
||||
const LayoutWrapper = styled(
|
||||
'div',
|
||||
@@ -258,6 +263,15 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
|
||||
const [isShareErrorVisible, setIsShareErrorVisible] = useState(false)
|
||||
|
||||
const layoutSnap = useSnapshot(layoutStore)
|
||||
const isReactionOpen = layoutSnap.showReaction
|
||||
|
||||
const height = useMemo(() => {
|
||||
let h = WIP_MIN_HEIGHT
|
||||
if (isReactionOpen) return h + WIP_ADDITIONAL_HEIGHT_REACTION
|
||||
return h
|
||||
}, [isReactionOpen])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="lk-video-conference"
|
||||
@@ -281,8 +295,8 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: isSidePanelOpen
|
||||
? `var(--lk-grid-gap) calc(358px + 3rem) calc(80px + var(--lk-grid-gap)) 16px`
|
||||
: `var(--lk-grid-gap) var(--lk-grid-gap) calc(80px + var(--lk-grid-gap))`,
|
||||
? `var(--lk-grid-gap) calc(358px + 3rem) calc(${height}px + var(--lk-grid-gap)) 16px`
|
||||
: `var(--lk-grid-gap) var(--lk-grid-gap) calc(${height}px + var(--lk-grid-gap))`,
|
||||
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
|
||||
maxHeight: '100%',
|
||||
}}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"joinMeetingTipContent": "Sie können einem Meeting beitreten, indem Sie den vollständigen Link in die Adressleiste Ihres Browsers einfügen.",
|
||||
"joinMeetingTipHeading": "Wussten Sie schon?",
|
||||
"loginToCreateMeeting": "Melden Sie sich an, um ein Meeting zu erstellen",
|
||||
"moreLinkLabel": "Mehr erfahren – neues Tab",
|
||||
"moreLinkLabel": "Mehr erfahren über {{appTitle}} – neues Tab",
|
||||
"moreLink": "Mehr erfahren",
|
||||
"moreAbout": "über {{appTitle}}",
|
||||
"createMenu": {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"joinMeetingTipContent": "You can join a meeting by pasting its full link in the browser's address bar.",
|
||||
"joinMeetingTipHeading": "Did you know?",
|
||||
"loginToCreateMeeting": "Login to create a meeting",
|
||||
"moreLinkLabel": "Learn more - new tab",
|
||||
"moreLinkLabel": "Learn more about {{appTitle}} - new tab",
|
||||
"moreLink": "Learn more",
|
||||
"moreAbout": "about {{appTitle}}",
|
||||
"createMenu": {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"joinMeetingTipContent": "Vous pouvez rejoindre une réunion en copiant directement son lien complet dans la barre d'adresse du navigateur.",
|
||||
"joinMeetingTipHeading": "Astuce",
|
||||
"loginToCreateMeeting": "Connectez-vous pour créer une réunion",
|
||||
"moreLinkLabel": "En savoir plus - nouvelle fenêtre",
|
||||
"moreLinkLabel": "En savoir plus sur {{appTitle}} - nouvelle fenêtre",
|
||||
"moreLink": "En savoir plus",
|
||||
"moreAbout": "sur {{appTitle}}",
|
||||
"createMenu": {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"joinMeetingTipContent": "U kunt deelnemen aan een vergadering door de volledige link in de adresbalk van de browser te plakken.",
|
||||
"joinMeetingTipHeading": "Wist u dat?",
|
||||
"loginToCreateMeeting": "Log in om een vergadering te maken",
|
||||
"moreLinkLabel": "Meer informatie - nieuw tabblad",
|
||||
"moreLinkLabel": "Meer informatie over {{appTitle}} - nieuw tabblad",
|
||||
"moreLink": "Meer informatie",
|
||||
"moreAbout": "over {{appTitle}}",
|
||||
"createMenu": {
|
||||
|
||||
@@ -44,6 +44,11 @@ export const buttonRecipe = cva({
|
||||
paddingY: 'var(--square-padding)',
|
||||
},
|
||||
},
|
||||
round: {
|
||||
true: {
|
||||
borderRadius: '50%',
|
||||
},
|
||||
},
|
||||
variant: {
|
||||
primary: {
|
||||
backgroundColor: 'primary.800',
|
||||
|
||||
@@ -10,6 +10,7 @@ type State = {
|
||||
showSubtitles: boolean
|
||||
activePanelId: PanelId | null
|
||||
activeSubPanelId: SubPanelId | null
|
||||
showReaction: boolean
|
||||
}
|
||||
|
||||
export const layoutStore = proxy<State>({
|
||||
@@ -18,4 +19,5 @@ export const layoutStore = proxy<State>({
|
||||
showSubtitles: false,
|
||||
activePanelId: null,
|
||||
activeSubPanelId: null,
|
||||
showReaction: false,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { proxy } from 'valtio'
|
||||
import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle.tsx'
|
||||
|
||||
type State = {
|
||||
reactions: Reaction[]
|
||||
}
|
||||
|
||||
export const reactionsStore = proxy<State>({
|
||||
reactions: [],
|
||||
})
|
||||
Reference in New Issue
Block a user