import React, { useEffect, useRef, useState, useMemo, useCallback, } from 'react'; import PropTypes from 'prop-types'; import { useMutation, useQuery } from '@apollo/client'; import { AssetRecordType, } from '@bigbluebutton/tldraw'; import { throttle } from 'radash'; import { CURRENT_PRESENTATION_PAGE_SUBSCRIPTION, CURRENT_PAGE_ANNOTATIONS_STREAM, CURRENT_PAGE_ANNOTATIONS_QUERY, CURRENT_PAGE_WRITERS_SUBSCRIPTION, } from './queries'; import { initDefaultPages, persistShape, notifyNotAllowedChange, notifyShapeNumberExceeded, toggleToolsAnimations, formatAnnotations, } from './service'; import { getSettingsSingletonInstance } from '/imports/ui/services/settings'; import Auth from '/imports/ui/services/auth'; import { layoutSelect, layoutDispatch, } from '/imports/ui/components/layout/context'; import FullscreenService from '/imports/ui/components/common/fullscreen-button/service'; import deviceInfo from '/imports/utils/deviceInfo'; import Whiteboard from './component'; import useCurrentUser from '/imports/ui/core/hooks/useCurrentUser'; import useMeeting from '/imports/ui/core/hooks/useMeeting'; import { PRESENTATION_SET_ZOOM, PRES_ANNOTATION_DELETE, PRES_ANNOTATION_SUBMIT, PRESENTATION_SET_PAGE, PRESENTATION_PUBLISH_CURSOR, } from '../presentation/mutations'; import { useMergedCursorData } from './hooks.ts'; import useDeduplicatedSubscription from '../../core/hooks/useDeduplicatedSubscription'; import MediaService from '/imports/ui/components/media/service'; import getFromUserSettings from '/imports/ui/services/users-settings'; import { debounce } from '/imports/utils/debounce'; const FORCE_RESTORE_PRESENTATION_ON_NEW_EVENTS = 'bbb_force_restore_presentation_on_new_events'; const WhiteboardContainer = (props) => { const { intl, zoomChanger, } = props; const WHITEBOARD_CONFIG = window.meetingClientSettings.public.whiteboard; const layoutContextDispatch = layoutDispatch(); const [annotations, setAnnotations] = useState([]); const [shapes, setShapes] = useState({}); const [currentPresentationPage, setCurrentPresentationPage] = useState(null); const meeting = useMeeting((m) => ({ lockSettings: m?.lockSettings, })); const { data: currentUser } = useCurrentUser((user) => ({ presenter: user.presenter, isModerator: user.isModerator, userId: user.userId, })); const isPresenter = currentUser?.presenter; const isModerator = currentUser?.isModerator; const { data: presentationPageData } = useDeduplicatedSubscription( CURRENT_PRESENTATION_PAGE_SUBSCRIPTION, ); const { pres_page_curr: presentationPageArray } = (presentationPageData || {}); const newPresentationPage = presentationPageArray && presentationPageArray[0]; useEffect(() => { if (newPresentationPage) { setCurrentPresentationPage(newPresentationPage); } }, [newPresentationPage]); const curPageNum = currentPresentationPage?.num; const curPageId = currentPresentationPage?.pageId; const isInfiniteWhiteboard = currentPresentationPage?.infiniteWhiteboard; const curPageIdRef = useRef(); React.useEffect(() => { curPageIdRef.current = curPageId; }, [curPageId]); const presentationId = currentPresentationPage?.presentationId; const { data: whiteboardWritersData } = useDeduplicatedSubscription( CURRENT_PAGE_WRITERS_SUBSCRIPTION, { variables: { pageId: curPageId }, skip: !curPageId, }, ); const whiteboardWriters = whiteboardWritersData?.pres_page_writers || []; const hasWBAccess = whiteboardWriters?.some((writer) => writer.userId === Auth.userID); const [presentationSetZoom] = useMutation(PRESENTATION_SET_ZOOM); const [presentationSetPage] = useMutation(PRESENTATION_SET_PAGE); const [presentationDeleteAnnotations] = useMutation(PRES_ANNOTATION_DELETE); const [presentationSubmitAnnotations] = useMutation(PRES_ANNOTATION_SUBMIT); const [presentationPublishCursor] = useMutation(PRESENTATION_PUBLISH_CURSOR); const setPresentationPage = (pageId) => { presentationSetPage({ variables: { presentationId, pageId, }, }); }; const skipToSlide = (slideNum) => { const slideId = `${presentationId}/${slideNum}`; setPresentationPage(slideId); }; const removeShapes = (shapeIds) => { presentationDeleteAnnotations({ variables: { pageId: curPageIdRef.current, annotationsIds: shapeIds, }, }); }; const zoomSlide = debounce(( widthRatio, heightRatio, xOffset, yOffset, currPage = currentPresentationPage, ) => { const { pageId, num } = currPage; presentationSetZoom({ variables: { presentationId, pageId, pageNum: num, xOffset, yOffset, widthRatio, heightRatio, }, }); }, 500); const submitAnnotations = async (newAnnotations) => { const isAnnotationSent = await presentationSubmitAnnotations({ variables: { pageId: curPageIdRef.current, annotations: newAnnotations, }, }); return isAnnotationSent?.data?.presAnnotationSubmit; }; const persistShapeWrapper = (shape, whiteboardId, amIModerator) => { persistShape(shape, whiteboardId, amIModerator, submitAnnotations); }; const publishCursorUpdate = useCallback((payload) => { const { whiteboardId, xPercent, yPercent } = payload; if (!whiteboardId || !xPercent || !yPercent || !(hasWBAccess || isPresenter)) return; presentationPublishCursor({ variables: { whiteboardId, xPercent, yPercent, }, }); }, [hasWBAccess, isPresenter]); const throttledPublishCursorUpdate = useMemo(() => throttle( { interval: WHITEBOARD_CONFIG.cursorInterval }, publishCursorUpdate, ), [publishCursorUpdate]); const isMultiUserActive = whiteboardWriters?.length > 0; const cursorArray = useMergedCursorData(); const { data: annotationStreamData } = useDeduplicatedSubscription( CURRENT_PAGE_ANNOTATIONS_STREAM, { variables: { lastUpdatedAt: new Date(0).toISOString() }, }, ); const { data: initialPageAnnotations, refetch: refetchInitialPageAnnotations } = useQuery( CURRENT_PAGE_ANNOTATIONS_QUERY, { skip: !curPageId, }, ); React.useEffect(() => { if (curPageIdRef.current) { refetchInitialPageAnnotations(); } }, [curPageIdRef.current]); const processAnnotations = (data) => { const newAnnotations = []; const annotationsToBeRemoved = []; data.forEach((item) => { if (item.annotationInfo === '') { annotationsToBeRemoved.push(item.annotationId); } else { newAnnotations.push(item); } }); const currentAnnotations = annotations.filter( (annotation) => !annotationsToBeRemoved.includes(annotation.annotationId), ); setAnnotations([...currentAnnotations, ...newAnnotations]); if (newAnnotations.length) { const restoreOnUpdate = getFromUserSettings( FORCE_RESTORE_PRESENTATION_ON_NEW_EVENTS, window.meetingClientSettings.public.presentation.restoreOnUpdate, ); if (restoreOnUpdate) { MediaService.setPresentationIsOpen(layoutContextDispatch, true); } } }; React.useEffect(() => { if (initialPageAnnotations && initialPageAnnotations.pres_annotation_curr) { processAnnotations(initialPageAnnotations.pres_annotation_curr); } }, [initialPageAnnotations]); useEffect(() => { const { pres_annotation_curr_stream: annotationStream } = annotationStreamData || {}; if (annotationStream) { processAnnotations(annotationStream); } }, [annotationStreamData]); const bgShape = []; React.useEffect(() => { const updatedShapes = formatAnnotations( annotations.filter((annotation) => annotation.pageId === curPageIdRef.current), intl, curPageNum, currentPresentationPage, ); setShapes(updatedShapes); }, [annotations, intl, curPageNum, currentPresentationPage]); const { isIphone } = deviceInfo; const assetId = AssetRecordType.createId(curPageNum); const assets = [{ id: assetId, typeName: 'asset', type: 'image', meta: {}, props: { w: currentPresentationPage?.scaledWidth, h: currentPresentationPage?.scaledHeight, src: currentPresentationPage?.svgUrl, name: '', isAnimated: false, mimeType: null, }, }]; const Settings = getSettingsSingletonInstance(); const { isRTL } = Settings.application; const width = layoutSelect((i) => i?.output?.presentation?.width); const height = layoutSelect((i) => i?.output?.presentation?.height); const sidebarNavigationWidth = layoutSelect( (i) => i?.output?.sidebarNavigation?.width, ); const { maxStickyNoteLength, maxNumberOfAnnotations } = WHITEBOARD_CONFIG; const fontFamily = WHITEBOARD_CONFIG.styles.text.family; const { colorStyle, dashStyle, fillStyle, fontStyle, sizeStyle, } = WHITEBOARD_CONFIG.styles; const handleToggleFullScreen = (ref) => FullscreenService.toggleFullScreen(ref); // use -0.5 offset to avoid white borders rounding erros bgShape.push({ x: -0.5, y: -0.5, rotation: 0, isLocked: true, opacity: 1, meta: {}, id: `shape:BG-${curPageNum}`, type: 'image', props: { w: currentPresentationPage?.scaledWidth + 1.5 || 1, h: currentPresentationPage?.scaledHeight + 1.5 || 1, assetId, playing: true, url: '', crop: null, }, parentId: `page:${curPageNum}`, index: 'a0', typeName: 'shape', }); if (!currentPresentationPage) return null; return ( ); }; WhiteboardContainer.propTypes = { intl: PropTypes.shape({ formatMessage: PropTypes.func.isRequired, }).isRequired, zoomChanger: PropTypes.func.isRequired, }; export default WhiteboardContainer;