import { ComponentAction, FeatureStatus, SNComponent, dateToLocalizedString, ApplicationEvent, } from '@standardnotes/snjs'; import { WebApplication } from '@/ui_models/application'; import { FunctionalComponent } from 'preact'; import { toDirective } from '@/components/utils'; import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; import { observer } from 'mobx-react-lite'; import { isDesktopApplication } from '@/utils'; import { OfflineRestricted } from '@/components/ComponentView/OfflineRestricted'; import { UrlMissing } from '@/components/ComponentView/UrlMissing'; import { IsDeprecated } from '@/components/ComponentView/IsDeprecated'; import { IsExpired } from '@/components/ComponentView/IsExpired'; import { IssueOnLoading } from '@/components/ComponentView/IssueOnLoading'; import { AppState } from '@/ui_models/app_state'; import { openSubscriptionDashboard } from '@/hooks/manageSubscription'; interface IProps { application: WebApplication; appState: AppState; componentUuid: string; onLoad?: (component: SNComponent) => void; templateComponent?: SNComponent; manualDealloc?: boolean; } /** * The maximum amount of time we'll wait for a component * to load before displaying error */ const MaxLoadThreshold = 4000; const VisibilityChangeKey = 'visibilitychange'; const avoidFlickerTimeout = 7; export const ComponentView: FunctionalComponent = observer( ({ application, onLoad, componentUuid, templateComponent }) => { const iframeRef = useRef(null); const excessiveLoadingTimeout = useRef< ReturnType | undefined >(undefined); const [hasIssueLoading, setHasIssueLoading] = useState(false); const [isLoading, setIsLoading] = useState(true); const [isReloading, setIsReloading] = useState(false); const [component] = useState( application.findItem(componentUuid) as SNComponent ); const [featureStatus, setFeatureStatus] = useState( application.getFeatureStatus(component.identifier) ); const [isComponentValid, setIsComponentValid] = useState(true); const [error, setError] = useState< 'offline-restricted' | 'url-missing' | undefined >(undefined); const [isDeprecated, setIsDeprecated] = useState(false); const [deprecationMessage, setDeprecationMessage] = useState< string | undefined >(undefined); const [isDeprecationMessageDismissed, setIsDeprecationMessageDismissed] = useState(false); const [didAttemptReload, setDidAttemptReload] = useState(false); const [contentWindow, setContentWindow] = useState(null); const manageSubscription = useCallback(() => { openSubscriptionDashboard(application); }, [application]); const reloadIframe = () => { setTimeout(() => { setIsReloading(true); setTimeout(() => { setIsReloading(false); }); }); }; useEffect(() => { const loadTimeout = setTimeout(() => { handleIframeTakingTooLongToLoad(); }, MaxLoadThreshold); excessiveLoadingTimeout.current = loadTimeout; return () => { excessiveLoadingTimeout.current && clearTimeout(excessiveLoadingTimeout.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const reloadValidityStatus = useCallback(() => { const offlineRestricted = component.offlineOnly && !isDesktopApplication(); const hasUrlError = (function () { if (isDesktopApplication()) { return !component.local_url && !component.hasValidHostedUrl(); } else { return !component.hasValidHostedUrl(); } })(); const readonlyState = application.componentManager.getReadonlyStateForComponent(component); if (!readonlyState.lockReadonly) { application.componentManager.setReadonlyStateForComponent( component, featureStatus !== FeatureStatus.Entitled ); } setIsComponentValid(!offlineRestricted && !hasUrlError); if (!isComponentValid) { setIsLoading(false); } if (offlineRestricted) { setError('offline-restricted'); } else if (hasUrlError) { setError('url-missing'); } else { setError(undefined); } setIsDeprecated(component.isDeprecated); setDeprecationMessage(component.package_info.deprecation_message); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { reloadValidityStatus(); }, [reloadValidityStatus]); const dismissDeprecationMessage = () => { setIsDeprecationMessageDismissed(true); }; const onVisibilityChange = useCallback(() => { if (document.visibilityState === 'hidden') { return; } if (hasIssueLoading) { reloadIframe(); } }, [hasIssueLoading]); const handleIframeTakingTooLongToLoad = useCallback(async () => { setIsLoading(false); setHasIssueLoading(true); if (!didAttemptReload) { setDidAttemptReload(true); reloadIframe(); } else { document.addEventListener(VisibilityChangeKey, onVisibilityChange); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const handleIframeLoad = useCallback(async (iframe: HTMLIFrameElement) => { let hasDesktopError = false; const canAccessWindowOrigin = isDesktopApplication(); if (canAccessWindowOrigin) { try { const contentWindow = iframe.contentWindow as Window; if (!contentWindow.origin || contentWindow.origin === 'null') { hasDesktopError = true; } // eslint-disable-next-line no-empty } catch (e) {} } excessiveLoadingTimeout.current && clearTimeout(excessiveLoadingTimeout.current); setContentWindow(iframe.contentWindow); setTimeout(() => { setIsLoading(false); setHasIssueLoading(hasDesktopError); onLoad?.(component); }, avoidFlickerTimeout); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { if (contentWindow) { application.componentManager.registerComponentWindow( component, contentWindow ); } return () => { application.componentManager.onComponentIframeDestroyed(component.uuid); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [contentWindow]); useEffect(() => { if (!iframeRef.current) { setContentWindow(null); return; } iframeRef.current.onload = () => { const iframe = application.componentManager.iframeForComponent( component.uuid ); if (iframe) { setTimeout(() => { handleIframeLoad(iframe); }); } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [iframeRef.current]); useEffect(() => { const removeFeaturesChangedObserver = application.addEventObserver( async () => { setFeatureStatus(application.getFeatureStatus(component.identifier)); }, ApplicationEvent.FeaturesUpdated ); return () => { removeFeaturesChangedObserver(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { if (!componentUuid) { application.componentManager.addTemporaryTemplateComponent( templateComponent as SNComponent ); } return () => { if (templateComponent) { /** componentManager can be destroyed already via locking */ application.componentManager?.removeTemporaryTemplateComponent( templateComponent ); } document.removeEventListener(VisibilityChangeKey, onVisibilityChange); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { const unregisterComponentHandler = application.componentManager.registerHandler({ identifier: 'component-view-' + Math.random(), areas: [component.area], actionHandler: (component, action, data) => { switch (action) { case ComponentAction.SetSize: application.componentManager.handleSetSizeEvent( component, data ); break; case ComponentAction.KeyDown: application.io.handleComponentKeyDown(data.keyboardModifier); break; case ComponentAction.KeyUp: application.io.handleComponentKeyUp(data.keyboardModifier); break; case ComponentAction.Click: application.getAppState().notes.setContextMenuOpen(false); break; default: return; } }, }); return () => { unregisterComponentHandler(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [component]); useEffect(() => { const unregisterDesktopObserver = application .getDesktopService() .registerUpdateObserver((component: SNComponent) => { if (component.uuid === component.uuid && component.active) { reloadIframe(); } }); return () => { unregisterDesktopObserver(); }; }, [application]); return ( <> {hasIssueLoading && ( )} {featureStatus !== FeatureStatus.Entitled && ( )} {isDeprecated && !isDeprecationMessageDismissed && ( )} {error == 'offline-restricted' && ( )} {error == 'url-missing' && ( )} {component.uuid && !isReloading && isComponentValid && ( )} {isLoading &&
} ); } ); export const ComponentViewDirective = toDirective(ComponentView, { onLoad: '=', componentUuid: '=', templateComponent: '=', manualDealloc: '=', });