/* Copyright 2024 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import { ComponentProps, forwardRef, useCallback, useEffect, useRef, useState, } from "react"; import { Glass } from "@vector-im/compound-web"; import ExpandIcon from "@vector-im/compound-design-tokens/icons/expand.svg?react"; import CollapseIcon from "@vector-im/compound-design-tokens/icons/collapse.svg?react"; import ChevronLeftIcon from "@vector-im/compound-design-tokens/icons/chevron-left.svg?react"; import ChevronRightIcon from "@vector-im/compound-design-tokens/icons/chevron-right.svg?react"; import { animated } from "@react-spring/web"; import { state, useStateObservable } from "@react-rxjs/core"; import { Observable, map, of } from "rxjs"; import { useTranslation } from "react-i18next"; import classNames from "classnames"; import { MediaView } from "./MediaView"; import styles from "./SpotlightTile.module.css"; import { subscribe } from "../state/subscribe"; import { LocalUserMediaViewModel, MediaViewModel, RemoteUserMediaViewModel, useNameData, } from "../state/MediaViewModel"; import { useInitial } from "../useInitial"; import { useMergedRefs } from "../useMergedRefs"; import { useObservableRef } from "../state/useObservable"; import { useReactiveState } from "../useReactiveState"; import { useLatest } from "../useLatest"; // Screen share video is always enabled const videoEnabledDefault = state(of(true)); // Never mirror screen share video const mirrorDefault = state(of(false)); // Never crop screen share video const cropVideoDefault = state(of(false)); interface SpotlightItemProps { vm: MediaViewModel; targetWidth: number; targetHeight: number; intersectionObserver: Observable; /** * Whether this item should act as a scroll snapping point. */ snap: boolean; } const SpotlightItem = subscribe( ({ vm, targetWidth, targetHeight, intersectionObserver, snap }, theirRef) => { const ourRef = useRef(null); const ref = useMergedRefs(ourRef, theirRef); const { displayName, nameTag } = useNameData(vm); const video = useStateObservable(vm.video); const videoEnabled = useStateObservable( vm instanceof LocalUserMediaViewModel || vm instanceof RemoteUserMediaViewModel ? vm.videoEnabled : videoEnabledDefault, ); const mirror = useStateObservable( vm instanceof LocalUserMediaViewModel ? vm.mirror : mirrorDefault, ); const cropVideo = useStateObservable( vm instanceof LocalUserMediaViewModel || vm instanceof RemoteUserMediaViewModel ? vm.cropVideo : cropVideoDefault, ); const unencryptedWarning = useStateObservable(vm.unencryptedWarning); // Hook this item up to the intersection observer useEffect(() => { const element = ourRef.current!; let prevIo: IntersectionObserver | null = null; const subscription = intersectionObserver.subscribe((io) => { prevIo?.unobserve(element); io.observe(element); prevIo = io; }); return (): void => { subscription.unsubscribe(); prevIo?.unobserve(element); }; }, [intersectionObserver]); return ( ); }, ); interface Props { vms: MediaViewModel[]; maximised: boolean; fullscreen: boolean; onToggleFullscreen: () => void; targetWidth: number; targetHeight: number; className?: string; style?: ComponentProps["style"]; } export const SpotlightTile = forwardRef( ( { vms, maximised, fullscreen, onToggleFullscreen, targetWidth, targetHeight, className, style, }, theirRef, ) => { const { t } = useTranslation(); const [root, ourRef] = useObservableRef(null); const ref = useMergedRefs(ourRef, theirRef); const [visibleId, setVisibleId] = useState(vms[0].id); const latestVms = useLatest(vms); const latestVisibleId = useLatest(visibleId); const canGoBack = visibleId !== vms[0].id; const canGoToNext = visibleId !== vms[vms.length - 1].id; // To keep track of which item is visible, we need an intersection observer // hooked up to the root element and the items. Because the items will run // their effects before their parent does, we need to do this dance with an // Observable to actually give them the intersection observer. const intersectionObserver = useInitial>( () => root.pipe( map( (r) => new IntersectionObserver( (entries) => { const visible = entries.find((e) => e.isIntersecting); if (visible !== undefined) setVisibleId(visible.target.getAttribute("data-id")!); }, { root: r, threshold: 0.5 }, ), ), ), ); const [scrollToId, setScrollToId] = useReactiveState( (prev) => prev == null || prev === visibleId || vms.every((vm) => vm.id !== prev) ? null : prev, [visibleId], ); const onBackClick = useCallback(() => { const vms = latestVms.current; const visibleIndex = vms.findIndex( (vm) => vm.id === latestVisibleId.current, ); if (visibleIndex > 0) setScrollToId(vms[visibleIndex - 1].id); }, [latestVisibleId, latestVms, setScrollToId]); const onNextClick = useCallback(() => { const vms = latestVms.current; const visibleIndex = vms.findIndex( (vm) => vm.id === latestVisibleId.current, ); if (visibleIndex !== -1 && visibleIndex !== vms.length - 1) setScrollToId(vms[visibleIndex + 1].id); }, [latestVisibleId, latestVms, setScrollToId]); const FullScreenIcon = fullscreen ? CollapseIcon : ExpandIcon; // We need a wrapper element because Glass doesn't provide an animated.div return ( {canGoBack && ( )} {/* Similarly we need a wrapper element here because Glass expects a single child */}
{vms.map((vm) => ( ))}
{canGoToNext && ( )}
); }, ); SpotlightTile.displayName = "SpotlightTile";