mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-13 20:27:01 +00:00
fd1715bacf
* Set the aria-label to something meaningful, * Set the delete btn aria label
963 lines
32 KiB
TypeScript
963 lines
32 KiB
TypeScript
import { type LocalVideoTrack, Track } from 'livekit-client'
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import {
|
|
BackgroundProcessorFactory,
|
|
BackgroundProcessorInterface,
|
|
ProcessorConfig,
|
|
ProcessorType,
|
|
} from '../blur'
|
|
import { css } from '@/styled-system/css'
|
|
import { Button, Dialog, H, P, Text, ToggleButton } from '@/primitives'
|
|
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
|
import { HStack, styled } from '@/styled-system/jsx'
|
|
import { BlurOn } from '@/components/icons/BlurOn'
|
|
import { BlurOnStrong } from '@/components/icons/BlurOnStrong'
|
|
import { useTrackToggle } from '@livekit/components-react'
|
|
import { Loader } from '@/primitives/Loader'
|
|
import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
|
|
import { FunnyEffects } from './FunnyEffects'
|
|
import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess'
|
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
|
import {
|
|
ListFilesParams,
|
|
useListMyFiles,
|
|
} from '@/features/files/api/listFiles.ts'
|
|
import { useCreateFile } from '@/features/files/api/createFile.ts'
|
|
import { FileTrigger } from 'react-aria-components'
|
|
import { RiDeleteBinLine, RiImageAddFill } from '@remixicon/react'
|
|
import { useDeleteFile } from '@/features/files/api/deleteFile.ts'
|
|
import { useUser } from '@/features/auth/api/useUser'
|
|
import { ApiFileItem } from '@/features/files/api/types.ts'
|
|
import { useConfig } from '@/api/useConfig.ts'
|
|
import { proxy, useSnapshot } from 'valtio'
|
|
import { Spinner } from '@/primitives/Spinner.tsx'
|
|
import { userChoicesStore, saveProcessorConfig } from '@/stores/userChoices'
|
|
|
|
enum BlurRadius {
|
|
NONE = 0,
|
|
LIGHT = 5,
|
|
NORMAL = 10,
|
|
}
|
|
|
|
const isSupported = BackgroundProcessorFactory.isSupported()
|
|
|
|
const Information = styled('div', {
|
|
base: {
|
|
backgroundColor: 'orange.50',
|
|
borderRadius: '4px',
|
|
padding: '0.75rem 0.75rem',
|
|
alignItems: 'start',
|
|
},
|
|
})
|
|
|
|
export type EffectsConfigurationProps = {
|
|
isDisabled?: boolean
|
|
videoTrack: LocalVideoTrack
|
|
layout?: 'vertical' | 'horizontal'
|
|
}
|
|
|
|
const listFilesQueryParams: ListFilesParams = {
|
|
filters: {
|
|
type: 'background_image',
|
|
upload_state: 'ready',
|
|
is_creator_me: true,
|
|
is_deleted: false,
|
|
},
|
|
pagination: {
|
|
page: 1,
|
|
pageSize: 20,
|
|
},
|
|
}
|
|
|
|
function deriveIdFromProcessorConfig(config: ProcessorConfig) {
|
|
if (config.type === ProcessorType.BLUR) {
|
|
return `blur-${config.blurRadius}`
|
|
} else if (config.type === ProcessorType.VIRTUAL) {
|
|
// the imagePath is not stable for custom backgrounds
|
|
// so we try first with the fileId
|
|
if (config.fileId) {
|
|
return `virtual-${config.fileId}`
|
|
}
|
|
return `virtual-${config.imagePath}`
|
|
} else if (config.type === ProcessorType.FACE_LANDMARKS) {
|
|
return 'face-landmarks'
|
|
}
|
|
throw new Error(`Unknown config type in config: ${config}`)
|
|
}
|
|
|
|
// We use a valtio store so that the state is persisted between the join room
|
|
// and the actual room
|
|
const uploadNotPossibleLocalState = proxy({
|
|
imageBackgroundConfig: null as null | {
|
|
type: ProcessorType.VIRTUAL
|
|
imagePath: string
|
|
label: string
|
|
},
|
|
})
|
|
|
|
export const EffectsConfiguration = ({
|
|
isDisabled,
|
|
videoTrack,
|
|
layout = 'horizontal',
|
|
}: EffectsConfigurationProps) => {
|
|
const videoRef = useRef<HTMLVideoElement>(null)
|
|
const blurLightRef = useRef<HTMLButtonElement | null>(null)
|
|
const { t } = useTranslation('rooms', { keyPrefix: 'effects' })
|
|
const { toggle, enabled } = useTrackToggle({ source: Track.Source.Camera })
|
|
const [processorPending, setProcessorPending] = useState(false)
|
|
const processorPendingReveal = useSyncAfterDelay(processorPending)
|
|
const hasFunnyEffectsAccess = useHasFunnyEffectsAccess()
|
|
const announce = useScreenReaderAnnounce()
|
|
const effectAnnouncementTimeout = useRef<ReturnType<
|
|
typeof setTimeout
|
|
> | null>(null)
|
|
const effectAnnouncementId = useRef(0)
|
|
|
|
const { processorConfig } = useSnapshot(userChoicesStore)
|
|
|
|
const selectedId = useMemo(
|
|
() =>
|
|
processorConfig ? deriveIdFromProcessorConfig(processorConfig) : 'none',
|
|
[processorConfig]
|
|
)
|
|
|
|
const uploadNotPossibleSnap = useSnapshot(uploadNotPossibleLocalState)
|
|
|
|
const announceEffectStatusMessage = useCallback(
|
|
(message: string) => {
|
|
effectAnnouncementId.current += 1
|
|
const currentId = effectAnnouncementId.current
|
|
|
|
if (effectAnnouncementTimeout.current) {
|
|
clearTimeout(effectAnnouncementTimeout.current)
|
|
}
|
|
|
|
effectAnnouncementTimeout.current = setTimeout(() => {
|
|
if (currentId !== effectAnnouncementId.current) return
|
|
announce(message)
|
|
}, 80)
|
|
},
|
|
[announce]
|
|
)
|
|
|
|
const getVirtualBackgroundName = useCallback(
|
|
(imagePath?: string) => {
|
|
if (!imagePath) return ''
|
|
const match = imagePath.match(/\/backgrounds\/(\d+)\.jpg$/)
|
|
if (!match) return ''
|
|
const index = Number(match[1]) - 1
|
|
if (Number.isNaN(index)) return ''
|
|
return t(`virtual.presets.descriptions.${index}`)
|
|
},
|
|
[t]
|
|
)
|
|
|
|
const updateEffectStatusMessage = useCallback(
|
|
(config: ProcessorConfig, wasSelectedBeforeToggle: boolean) => {
|
|
if (wasSelectedBeforeToggle) {
|
|
announceEffectStatusMessage(t('blur.status.none'))
|
|
return
|
|
}
|
|
|
|
if (config.type === ProcessorType.BLUR) {
|
|
const message =
|
|
config.blurRadius === BlurRadius.LIGHT
|
|
? t('blur.status.light')
|
|
: t('blur.status.strong')
|
|
announceEffectStatusMessage(message)
|
|
return
|
|
}
|
|
|
|
if (config.type === ProcessorType.VIRTUAL) {
|
|
const backgroundName = getVirtualBackgroundName(config.imagePath)
|
|
if (backgroundName) {
|
|
announceEffectStatusMessage(
|
|
`${t('virtual.selectedLabel')} ${backgroundName}`
|
|
)
|
|
return
|
|
}
|
|
}
|
|
},
|
|
[announceEffectStatusMessage, getVirtualBackgroundName, t]
|
|
)
|
|
|
|
const toggleEffect = useCallback(
|
|
async (config: ProcessorConfig) => {
|
|
setProcessorPending(true)
|
|
const wasSelectedBeforeToggle =
|
|
selectedId === deriveIdFromProcessorConfig(config)
|
|
|
|
if (!videoTrack) {
|
|
/**
|
|
* Special case: if no video track is available, then we must pass directly the processor into the
|
|
* toggle call. Otherwise, the rest of the function below would not have a videoTrack to call
|
|
* setProcessor on.
|
|
*
|
|
* We arrive in this condition when we enter the room with the camera already off.
|
|
*/
|
|
const newProcessorTmp = BackgroundProcessorFactory.getProcessor(config)!
|
|
await toggle(true, {
|
|
processor: newProcessorTmp,
|
|
})
|
|
setTimeout(() => setProcessorPending(false))
|
|
return
|
|
}
|
|
|
|
if (!enabled) {
|
|
await toggle(true)
|
|
}
|
|
|
|
const processor =
|
|
videoTrack?.getProcessor() as BackgroundProcessorInterface
|
|
try {
|
|
if (wasSelectedBeforeToggle) {
|
|
// Stop processor.
|
|
await videoTrack.stopProcessor()
|
|
saveProcessorConfig(undefined)
|
|
} else if (
|
|
!processor ||
|
|
(processor.options.type !== config.type &&
|
|
!BackgroundProcessorFactory.hasModernApiSupport())
|
|
) {
|
|
// Change processor.
|
|
const newProcessor = BackgroundProcessorFactory.getProcessor(config)!
|
|
// IMPORTANT: Must explicitly stop previous processor before setting a new one
|
|
// in browsers without modern API support to prevent UI crashes.
|
|
// This workaround is needed until this issue is resolved:
|
|
// https://github.com/livekit/track-processors-js/issues/85
|
|
if (!BackgroundProcessorFactory.hasModernApiSupport()) {
|
|
await videoTrack.stopProcessor()
|
|
}
|
|
await videoTrack.setProcessor(newProcessor)
|
|
saveProcessorConfig(config)
|
|
} else {
|
|
await processor?.update(config)
|
|
saveProcessorConfig(config)
|
|
}
|
|
|
|
updateEffectStatusMessage(config, wasSelectedBeforeToggle)
|
|
} catch (error) {
|
|
console.error('Error applying effect:', error)
|
|
} finally {
|
|
// Without setTimeout the DOM is not refreshing when updating the options.
|
|
setTimeout(() => setProcessorPending(false))
|
|
}
|
|
},
|
|
[enabled, selectedId, toggle, updateEffectStatusMessage, videoTrack]
|
|
)
|
|
|
|
const { data: appConfig } = useConfig()
|
|
const { isLoggedIn } = useUser()
|
|
const canUploadBackground =
|
|
isLoggedIn === true &&
|
|
appConfig?.background_image?.upload_is_enabled === true
|
|
// We split the error state in 2 parts so that there are no visual glitches when closing the alert
|
|
const [personalBackgroundHasError, setPersonalBackgroundHasError] =
|
|
useState<boolean>(false)
|
|
const [personalBackgroundError, setPersonalBackgroundError] = useState<
|
|
'file_too_large' | 'invalid_file_type' | null
|
|
>(null)
|
|
const createFileMutation = useCreateFile()
|
|
const fileBeingUploadedObjectUrlRef = useRef<string | null>(null)
|
|
useEffect(() => {
|
|
return () => {
|
|
if (fileBeingUploadedObjectUrlRef.current) {
|
|
URL.revokeObjectURL(fileBeingUploadedObjectUrlRef.current)
|
|
fileBeingUploadedObjectUrlRef.current = null
|
|
}
|
|
}
|
|
}, [])
|
|
const deleteFileMutation = useDeleteFile()
|
|
const filesQ = useListMyFiles(listFilesQueryParams)
|
|
const hasReachedMaxNbBackgrounds =
|
|
(canUploadBackground &&
|
|
appConfig &&
|
|
filesQ.data &&
|
|
filesQ.data.count >= appConfig.background_image.max_count_by_user) ??
|
|
false
|
|
|
|
const getHandleSelectChangeFile = useCallback(
|
|
(file: ApiFileItem) => {
|
|
return async () => {
|
|
await toggleEffect({
|
|
type: ProcessorType.VIRTUAL,
|
|
imagePath: file.url!,
|
|
fileId: file.id,
|
|
})
|
|
}
|
|
},
|
|
[toggleEffect]
|
|
)
|
|
|
|
const handleNewBackgroundFilePicked = useCallback(
|
|
async (file: File) => {
|
|
if (
|
|
!(
|
|
appConfig?.background_image?.allowed_mimetypes?.includes(file.type) ??
|
|
false
|
|
)
|
|
) {
|
|
setPersonalBackgroundError('invalid_file_type')
|
|
setPersonalBackgroundHasError(true)
|
|
return
|
|
}
|
|
if (file.size > (appConfig?.background_image?.max_size ?? 0)) {
|
|
setPersonalBackgroundError('file_too_large')
|
|
setPersonalBackgroundHasError(true)
|
|
return
|
|
}
|
|
|
|
// When the user is not logged in, we fallback to just loading that image
|
|
if (!canUploadBackground) {
|
|
// For the preview to work, we somehow need to create a data URL from the raw file.
|
|
// revoking is handled with userChoicesStore
|
|
if (uploadNotPossibleSnap.imageBackgroundConfig) {
|
|
URL.revokeObjectURL(
|
|
uploadNotPossibleSnap.imageBackgroundConfig.imagePath
|
|
)
|
|
}
|
|
const imagePath = URL.createObjectURL(file)
|
|
|
|
// We concatenate with the image path so that the constructed file-id
|
|
// is unique for local files.
|
|
const fileId = `local-image-${imagePath}`
|
|
await toggleEffect({
|
|
type: ProcessorType.VIRTUAL,
|
|
imagePath,
|
|
fileId,
|
|
})
|
|
uploadNotPossibleLocalState.imageBackgroundConfig = {
|
|
type: ProcessorType.VIRTUAL,
|
|
label: file.name.split('.')[0],
|
|
imagePath,
|
|
}
|
|
} else {
|
|
// Otherwise we create the file in the backend and automatically select it
|
|
// when it's uploaded.
|
|
// We create a local version so that we can quickly display it in the frontend.
|
|
if (fileBeingUploadedObjectUrlRef.current) {
|
|
URL.revokeObjectURL(fileBeingUploadedObjectUrlRef.current)
|
|
}
|
|
fileBeingUploadedObjectUrlRef.current = URL.createObjectURL(file)
|
|
createFileMutation.mutate(
|
|
{
|
|
file,
|
|
onProgress: (progress) => {
|
|
console.debug('upload-progress', progress)
|
|
},
|
|
},
|
|
{
|
|
onSuccess: (file) => {
|
|
// We automatically select that created file
|
|
getHandleSelectChangeFile(file)()
|
|
},
|
|
}
|
|
)
|
|
}
|
|
},
|
|
[
|
|
appConfig?.background_image?.allowed_mimetypes,
|
|
appConfig?.background_image?.max_size,
|
|
canUploadBackground,
|
|
createFileMutation,
|
|
getHandleSelectChangeFile,
|
|
toggleEffect,
|
|
uploadNotPossibleSnap.imageBackgroundConfig,
|
|
]
|
|
)
|
|
|
|
const filePickerErrorContext = useMemo(
|
|
() => ({
|
|
allowedExtension: appConfig?.background_image?.allowed_extensions ?? [],
|
|
maxSize: (appConfig?.background_image?.max_size ?? 0) / (1024 * 1024),
|
|
}),
|
|
[appConfig?.background_image]
|
|
)
|
|
|
|
const processorOptions = useMemo<{
|
|
isDisabled: boolean
|
|
blurBased: {
|
|
radius: BlurRadius
|
|
Icon: React.FC
|
|
ref?: React.Ref<HTMLButtonElement>
|
|
tooltip: string
|
|
id: string
|
|
config: ProcessorConfig
|
|
isSelected: boolean
|
|
}[]
|
|
virtualBackgrounds: {
|
|
id: string
|
|
config: ProcessorConfig
|
|
isSelected: boolean
|
|
tooltip: string
|
|
ariaLabel: string
|
|
thumbnailPath: string
|
|
index: number
|
|
}[]
|
|
remoteCustomVirtualBackgrounds: {
|
|
id: string
|
|
config: ProcessorConfig
|
|
isSelected: boolean
|
|
tooltip: string
|
|
ariaLabel: string
|
|
ariaDeleteLabel: string
|
|
file: ApiFileItem
|
|
}[]
|
|
}>(() => {
|
|
return {
|
|
isDisabled: (processorPendingReveal || isDisabled) ?? false,
|
|
blurBased: [
|
|
{
|
|
key: 'light',
|
|
radius: BlurRadius.LIGHT,
|
|
icon: BlurOn,
|
|
ref: blurLightRef,
|
|
},
|
|
{
|
|
key: 'normal',
|
|
radius: BlurRadius.NORMAL,
|
|
icon: BlurOnStrong,
|
|
ref: undefined,
|
|
},
|
|
].map((item) => {
|
|
const config: ProcessorConfig = {
|
|
type: ProcessorType.BLUR,
|
|
blurRadius: item.radius,
|
|
}
|
|
const id = deriveIdFromProcessorConfig(config)
|
|
return {
|
|
id,
|
|
tooltip: t(
|
|
`blur.${item.key}.${selectedId === id ? 'clear' : 'apply'}`
|
|
),
|
|
radius: item.radius,
|
|
isSelected: selectedId === id,
|
|
Icon: item.icon,
|
|
ref: item.ref,
|
|
config,
|
|
}
|
|
}),
|
|
virtualBackgrounds: [...Array(8).keys()].map((index) => {
|
|
const imagePath = `/assets/backgrounds/${index + 1}.jpg`
|
|
const thumbnailPath = `/assets/backgrounds/thumbnails/${index + 1}.jpg`
|
|
const config: ProcessorConfig = {
|
|
type: ProcessorType.VIRTUAL,
|
|
imagePath,
|
|
}
|
|
const id = deriveIdFromProcessorConfig(config)
|
|
const isSelected = selectedId === id
|
|
const backgroundName = t(`virtual.presets.descriptions.${index}`)
|
|
const ariaLabelPrefix = t(
|
|
isSelected ? `virtual.selectedLabel` : `virtual.apply`
|
|
)
|
|
const ariaLabel = `${ariaLabelPrefix} ${backgroundName}`
|
|
|
|
return {
|
|
tooltip: backgroundName,
|
|
id,
|
|
config,
|
|
isSelected: selectedId === id,
|
|
thumbnailPath,
|
|
ariaLabel,
|
|
index,
|
|
}
|
|
}),
|
|
remoteCustomVirtualBackgrounds: (filesQ.data?.results ?? [])
|
|
.filter((file) => file.url)
|
|
.map((file) => {
|
|
const config: ProcessorConfig = {
|
|
type: ProcessorType.VIRTUAL,
|
|
imagePath: file.url!,
|
|
fileId: file.id,
|
|
}
|
|
|
|
const id = deriveIdFromProcessorConfig(config)
|
|
const isSelected = selectedId === id
|
|
const ariaLabel = t(
|
|
isSelected
|
|
? `virtual.personal.selectedLabel`
|
|
: `virtual.personal.apply`
|
|
)
|
|
const ariaDeleteLabel = t('virtual.personal.deleteLabel')
|
|
|
|
return {
|
|
tooltip: file.title,
|
|
id,
|
|
config,
|
|
isSelected,
|
|
file,
|
|
ariaLabel,
|
|
ariaDeleteLabel,
|
|
}
|
|
}),
|
|
}
|
|
}, [processorPendingReveal, isDisabled, filesQ.data?.results, t, selectedId])
|
|
|
|
useEffect(() => {
|
|
const videoElement = videoRef.current
|
|
if (!videoElement) return
|
|
|
|
const attachVideoTrack = async () => videoTrack?.attach(videoElement)
|
|
attachVideoTrack()
|
|
|
|
return () => {
|
|
if (!videoElement) return
|
|
videoTrack.detach(videoElement)
|
|
}
|
|
}, [videoTrack, videoTrack?.isMuted])
|
|
|
|
useEffect(() => {
|
|
if (!blurLightRef.current) return
|
|
|
|
const rafId = requestAnimationFrame(() => {
|
|
blurLightRef.current?.focus({ preventScroll: true })
|
|
})
|
|
|
|
return () => {
|
|
cancelAnimationFrame(rafId)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(
|
|
() => () => {
|
|
if (effectAnnouncementTimeout.current) {
|
|
clearTimeout(effectAnnouncementTimeout.current)
|
|
}
|
|
},
|
|
[]
|
|
)
|
|
|
|
return (
|
|
<div
|
|
className={css(
|
|
layout === 'vertical'
|
|
? {
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '1.5rem',
|
|
}
|
|
: {
|
|
display: 'flex',
|
|
gap: '1.5rem',
|
|
flexDirection: 'column',
|
|
md: {
|
|
flexDirection: 'row',
|
|
overflow: 'hidden',
|
|
},
|
|
}
|
|
)}
|
|
>
|
|
<div
|
|
className={css({
|
|
width: '100%',
|
|
aspectRatio: 16 / 9,
|
|
position: 'relative',
|
|
overflow: 'hidden',
|
|
borderRadius: '8px',
|
|
})}
|
|
>
|
|
{videoTrack && !videoTrack.isMuted ? (
|
|
<video
|
|
ref={videoRef}
|
|
width="100%"
|
|
muted
|
|
style={{
|
|
transform: 'rotateY(180deg)',
|
|
[layout === 'vertical' ? 'height' : 'minHeight']: '175px',
|
|
borderRadius: '8px',
|
|
}}
|
|
aria-hidden={true}
|
|
/>
|
|
) : (
|
|
<div
|
|
style={{
|
|
width: '100%',
|
|
height: '100%',
|
|
display: 'flex',
|
|
backgroundColor: 'black',
|
|
justifyContent: 'center',
|
|
flexDirection: 'column',
|
|
}}
|
|
>
|
|
<P
|
|
style={{
|
|
color: 'white',
|
|
textAlign: 'center',
|
|
textWrap: 'balance',
|
|
marginBottom: 0,
|
|
}}
|
|
>
|
|
{t(isDisabled ? 'cameraDisabled' : 'activateCamera')}
|
|
</P>
|
|
</div>
|
|
)}
|
|
{processorPendingReveal && (
|
|
<div
|
|
className={css({
|
|
position: 'absolute',
|
|
right: '8px',
|
|
bottom: '8px',
|
|
})}
|
|
>
|
|
<Loader />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div
|
|
className={css(
|
|
layout === 'horizontal'
|
|
? {
|
|
md: {
|
|
borderLeft: '1px solid greyscale.250',
|
|
paddingLeft: '1.5rem',
|
|
width: '420px',
|
|
flexShrink: 0,
|
|
},
|
|
}
|
|
: {}
|
|
)}
|
|
>
|
|
{hasFunnyEffectsAccess && (
|
|
<FunnyEffects
|
|
videoTrack={videoTrack}
|
|
isPending={processorPendingReveal}
|
|
onPending={setProcessorPending}
|
|
/>
|
|
)}
|
|
{isSupported ? (
|
|
<div>
|
|
<div>
|
|
<H
|
|
lvl={2}
|
|
style={{
|
|
marginBottom: '1rem',
|
|
}}
|
|
variant="bodyXsBold"
|
|
>
|
|
{t('blur.title')}
|
|
</H>
|
|
<div>
|
|
<div
|
|
className={css({
|
|
display: 'flex',
|
|
gap: '1.25rem',
|
|
})}
|
|
>
|
|
{processorOptions.blurBased.map(({ Icon, ...option }) => (
|
|
<ToggleButton
|
|
key={option.id}
|
|
ref={option.ref}
|
|
variant="bigSquare"
|
|
aria-label={option.tooltip}
|
|
tooltip={option.tooltip}
|
|
isDisabled={processorOptions.isDisabled}
|
|
onChange={() => toggleEffect(option.config)}
|
|
isSelected={option.isSelected}
|
|
data-attr={`toggle-${option.id}`}
|
|
>
|
|
<Icon />
|
|
</ToggleButton>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className={css({ marginTop: '1.5rem' })}>
|
|
<H
|
|
lvl={2}
|
|
style={{
|
|
marginBottom: '0.6rem',
|
|
}}
|
|
variant="bodyXsBold"
|
|
>
|
|
{t('virtual.title')}
|
|
</H>
|
|
</div>
|
|
|
|
<div
|
|
className={css({
|
|
marginBottom: '1rem',
|
|
})}
|
|
>
|
|
<H
|
|
lvl={3}
|
|
style={{
|
|
marginBottom: '0.4rem',
|
|
}}
|
|
variant="bodyXsMedium"
|
|
>
|
|
{t('virtual.personal.title')}
|
|
</H>
|
|
|
|
<div
|
|
className={css({
|
|
display: 'flex',
|
|
gap: '1.25rem',
|
|
paddingBottom: '0.5rem',
|
|
flexWrap: 'wrap',
|
|
})}
|
|
role="list"
|
|
>
|
|
{createFileMutation.isPending &&
|
|
fileBeingUploadedObjectUrlRef.current && (
|
|
<VisualOnlyTooltip
|
|
tooltip={t('virtual.personal.uploadInProgress')}
|
|
>
|
|
<div className={css({ position: 'relative' })}>
|
|
<ToggleButton
|
|
variant="bigSquare"
|
|
aria-label={t('virtual.personal.uploadInProgress')}
|
|
isDisabled={true}
|
|
data-attr={`virtual-upload-in-progress`}
|
|
className={css({
|
|
bgSize: 'cover',
|
|
filter: 'grayscale(1)',
|
|
})}
|
|
style={{
|
|
backgroundImage: `url(${fileBeingUploadedObjectUrlRef.current})`,
|
|
}}
|
|
/>
|
|
<div
|
|
className={css({
|
|
position: 'absolute',
|
|
top: '50%',
|
|
left: '50%',
|
|
transform: 'translate(-50%, -50%)',
|
|
})}
|
|
>
|
|
<Spinner size={24} />
|
|
</div>
|
|
</div>
|
|
</VisualOnlyTooltip>
|
|
)}
|
|
{canUploadBackground &&
|
|
processorOptions.remoteCustomVirtualBackgrounds.map(
|
|
(option) => (
|
|
<div
|
|
key={option.id}
|
|
className={
|
|
'hoverGroup ' + css({ position: 'relative' })
|
|
}
|
|
role="listitem"
|
|
>
|
|
<div role="group" aria-label={option.file.title}>
|
|
<VisualOnlyTooltip tooltip={option.tooltip}>
|
|
<ToggleButton
|
|
variant="bigSquare"
|
|
aria-label={option.ariaLabel}
|
|
isDisabled={processorOptions.isDisabled}
|
|
onChange={getHandleSelectChangeFile(
|
|
option.file
|
|
)}
|
|
isSelected={option.isSelected}
|
|
className={css({
|
|
bgSize: 'cover',
|
|
})}
|
|
style={{
|
|
backgroundImage: `url(${option.file.url!})`,
|
|
}}
|
|
data-attr={`toggle-virtual-${option.file.id}`}
|
|
/>
|
|
</VisualOnlyTooltip>
|
|
<Button
|
|
className={
|
|
'hoverGroupChild ' +
|
|
css({
|
|
position: 'absolute',
|
|
top: '-8px',
|
|
right: '-8px',
|
|
transition: 'opacity 0.2s ease-in-out',
|
|
})
|
|
}
|
|
aria-label={option.ariaDeleteLabel}
|
|
size={'xs'}
|
|
variant={'tertiary'}
|
|
onClick={() => {
|
|
if (option.isSelected) {
|
|
// we remove the current effect
|
|
toggleEffect(option.config)
|
|
}
|
|
deleteFileMutation.mutate({
|
|
fileId: option.file.id,
|
|
})
|
|
}}
|
|
isDisabled={deleteFileMutation.isPending}
|
|
>
|
|
<RiDeleteBinLine size={16} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
)}
|
|
{!canUploadBackground &&
|
|
uploadNotPossibleSnap.imageBackgroundConfig && (
|
|
<VisualOnlyTooltip
|
|
tooltip={
|
|
uploadNotPossibleSnap.imageBackgroundConfig.label
|
|
}
|
|
>
|
|
<ToggleButton
|
|
variant="bigSquare"
|
|
aria-label={`${t(
|
|
deriveIdFromProcessorConfig(
|
|
uploadNotPossibleSnap.imageBackgroundConfig
|
|
) === selectedId
|
|
? `virtual.selectedLabel`
|
|
: `virtual.apply`
|
|
)} ${uploadNotPossibleSnap.imageBackgroundConfig.label}`}
|
|
isDisabled={
|
|
processorOptions.isDisabled ||
|
|
createFileMutation.isPending
|
|
}
|
|
onChange={() => {
|
|
toggleEffect(
|
|
uploadNotPossibleSnap.imageBackgroundConfig!
|
|
)
|
|
}}
|
|
isSelected={
|
|
deriveIdFromProcessorConfig(
|
|
uploadNotPossibleSnap.imageBackgroundConfig
|
|
) === selectedId
|
|
}
|
|
className={css({
|
|
bgSize: 'cover',
|
|
})}
|
|
style={{
|
|
backgroundImage: `url(${uploadNotPossibleSnap.imageBackgroundConfig.imagePath})`,
|
|
}}
|
|
data-attr={`toggle-virtual-local`}
|
|
/>
|
|
</VisualOnlyTooltip>
|
|
)}
|
|
<FileTrigger
|
|
acceptedFileTypes={
|
|
appConfig?.background_image?.allowed_mimetypes ?? [
|
|
'image/png',
|
|
'image/jpeg',
|
|
]
|
|
}
|
|
onSelect={(e) => {
|
|
if (e && e.item(0)) {
|
|
const file = e.item(0) as File
|
|
handleNewBackgroundFilePicked(file)
|
|
}
|
|
}}
|
|
>
|
|
<Button
|
|
variant="bigSquare"
|
|
aria-label={t('virtual.personal.selectFileTooltip')}
|
|
tooltip={t('virtual.personal.selectFileTooltip')}
|
|
isDisabled={
|
|
(canUploadBackground &&
|
|
filesQ.data &&
|
|
filesQ.data.count >=
|
|
(appConfig?.background_image?.max_count_by_user ??
|
|
0)) ||
|
|
processorOptions.isDisabled ||
|
|
createFileMutation.isPending
|
|
}
|
|
data-attr="input-file-select-personal-background"
|
|
>
|
|
<RiImageAddFill />
|
|
</Button>
|
|
</FileTrigger>
|
|
</div>
|
|
{!isLoggedIn && (
|
|
<Text variant="xsNote">
|
|
{t('virtual.personal.notLoggedInWarning')}
|
|
</Text>
|
|
)}
|
|
{!canUploadBackground && isLoggedIn && (
|
|
<Text variant="xsNote">
|
|
{t('virtual.personal.warningUploadDisabled')}
|
|
</Text>
|
|
)}
|
|
{hasReachedMaxNbBackgrounds && (
|
|
<Text variant="xsNote">
|
|
{t('virtual.personal.uploadLimitReached')}
|
|
</Text>
|
|
)}
|
|
</div>
|
|
<div
|
|
className={css({
|
|
marginTop: '0.4rem',
|
|
})}
|
|
>
|
|
<H
|
|
lvl={3}
|
|
style={{
|
|
marginBottom: '0.4rem',
|
|
}}
|
|
variant="bodyXsMedium"
|
|
>
|
|
{t('virtual.presets.title')}
|
|
</H>
|
|
<div
|
|
className={css({
|
|
display: 'flex',
|
|
gap: '1.25rem',
|
|
paddingBottom: '0.5rem',
|
|
flexWrap: 'wrap',
|
|
})}
|
|
role="list"
|
|
>
|
|
{processorOptions.virtualBackgrounds.map((option) => (
|
|
<div role="listitem" key={option.id}>
|
|
<VisualOnlyTooltip tooltip={option.tooltip}>
|
|
<ToggleButton
|
|
variant="bigSquare"
|
|
aria-label={option.ariaLabel}
|
|
isDisabled={processorOptions.isDisabled}
|
|
onChange={() => toggleEffect(option.config)}
|
|
isSelected={option.isSelected}
|
|
className={css({
|
|
bgSize: 'cover',
|
|
})}
|
|
style={{
|
|
backgroundImage: `url(${option.thumbnailPath})`,
|
|
}}
|
|
data-attr={`toggle-virtual-preset-${option.index}`}
|
|
/>
|
|
</VisualOnlyTooltip>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<Information>
|
|
<Text variant="sm">{t('notAvailable')}</Text>
|
|
</Information>
|
|
)}
|
|
</div>
|
|
<Dialog
|
|
isOpen={personalBackgroundHasError}
|
|
type="alert"
|
|
title={t(`virtual.personal.errors.${personalBackgroundError}.title`)}
|
|
aria-label={t(
|
|
`virtual.personal.errors.${personalBackgroundError}.title`
|
|
)}
|
|
onClose={() => setPersonalBackgroundHasError(false)}
|
|
onOpenChange={() => setPersonalBackgroundHasError(false)}
|
|
>
|
|
<P>
|
|
{t(
|
|
`virtual.personal.errors.${personalBackgroundError}.description`,
|
|
filePickerErrorContext
|
|
)}
|
|
</P>
|
|
<HStack justifyContent="end" direction="row">
|
|
<Button
|
|
variant="text"
|
|
size="sm"
|
|
onPress={() => setPersonalBackgroundHasError(false)}
|
|
>
|
|
{t('virtual.personal.errors.close')}
|
|
</Button>
|
|
</HStack>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|