import React, { useCallback, useEffect, useRef, useState, useMemo, } from "react"; import { useLocation, useNavigationType, useParams } from "react-router-dom"; import { DEFAULT_LOCALE } from "../../libs/constants"; import { isValidLocale } from "../../libs/locale-utils"; import { FeatureId } from "./constants"; import { OFFLINE_SETTINGS_KEY, useUserData } from "./user-context"; import { Theme } from "./types/theme"; // This is a bit of a necessary hack! // The only reason this list is needed is because of the PageNotFound rendering. // If someone requests `https://domain/some-random-word` what will happen is that // Lambda@Edge will send the `build/en-us/_spas/404.html` rendered page. That // rendered page has all the React stuff like routing. // The component can know what it needs to render but what's problematic // is that all and any other app will think that the locale is `some-random-word` // just because it's the first portion of the URL. // So, for example, the top navbar will think it can use the `useLocale()` hook // and get the current locale from the react-router context. Now the navbar menu // items, for example, will think the locale is `some-random-word` and make links // like `/some-random-word/docs/Web`. export function useLocale() { const { locale } = useParams(); return isValidLocale(locale) ? locale : DEFAULT_LOCALE; } export function useOnClickOutside(ref, handler) { React.useEffect( () => { const listener = (event) => { // Do nothing if clicking ref's element or descendent elements if (!ref.current || ref.current.contains(event.target)) { return; } handler(event); }; document.addEventListener("mousedown", listener); document.addEventListener("touchstart", listener); return () => { document.removeEventListener("mousedown", listener); document.removeEventListener("touchstart", listener); }; }, // Add ref and handler to effect dependencies // It's worth noting that because passed in handler is a new ... // ... function on every render that will cause this effect ... // ... callback/cleanup to run every render. It's not a big deal ... // ... but to optimize you can wrap handler in useCallback before ... // ... passing it into this hook. [ref, handler] ); } function getCurrentTheme(): Theme { const { classList } = document.documentElement; const themes: Theme[] = ["os-default", "dark", "light"]; for (const theme of themes) { if (classList.contains(theme)) { return theme; } } // Fallback. return "light"; } export function useTheme() { const isServer = useIsServer(); const [theme, setTheme] = useState(); useEffect(() => { if (isServer) { return; } const updateScheme = () => setTheme(getCurrentTheme()); // Update once. updateScheme(); // Listen for changes. const observer = new MutationObserver(updateScheme); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"], }); return () => observer.disconnect(); }, [isServer]); return theme; } export function useOnlineStatus(): { isOnline: boolean; isOffline: boolean } { const isServer = useIsServer(); const trueStatus = useTrueOnlineStatus(); // ensure we don't get a hydration error due to mismatched markup: return isServer ? { isOnline: true, isOffline: false } : trueStatus; } export function useTrueOnlineStatus(): { isOnline: boolean; isOffline: boolean; } { const [isOnline, setIsOnline] = useState( typeof window === "undefined" ? false : window.navigator.onLine ); const isOffline = useMemo(() => !isOnline, [isOnline]); useEffect(() => { const handleOnline = () => setIsOnline(true); const handleOffline = () => setIsOnline(false); window.addEventListener("offline", handleOffline); window.addEventListener("online", handleOnline); return () => { window.removeEventListener("offline", handleOffline); window.removeEventListener("online", handleOnline); }; }, []); return { isOnline, isOffline }; } /** * If we want to render different markup on client/server, we have to delay this * until the first client render. Otherwise, hydration will throw an error, or * more dangerously, correlate its v-dom with the wrong markup. */ export function useIsServer(): boolean { const [isServer, setIsServer] = useState(true); useEffect(() => setIsServer(false), []); return isServer; } export function useScrollToTop() { const navigationType = useNavigationType(); const location = useLocation(); useEffect(() => { if (navigationType === "PUSH") document.documentElement.scrollTo(0, 0); }, [navigationType, location]); } export function useViewedState() { const isServer = useIsServer(); const key = (id: FeatureId) => `viewed.${id}`; return { isViewed: (id: FeatureId) => { if (isServer) { // Avoids the dot from popping up quickly on each load. return true; } try { return !!window?.localStorage?.getItem(key(id)); } catch (e) { console.warn("Unable to read viewed state from localStorage", e); return false; } }, setViewed: (id: FeatureId) => { if (isServer) { return; } try { window?.localStorage?.setItem(key(id), Date.now().toString()); } catch (e) { console.warn("Unable to write viewed state to localStorage", e); } }, }; } export function usePing() { const { isOnline } = useTrueOnlineStatus(); const user = useUserData(); React.useEffect(() => { try { const nextPing = new Date(localStorage.getItem("next-ping") || 0); if ( navigator.sendBeacon && isOnline && user?.isAuthenticated && nextPing < new Date() ) { const params = new URLSearchParams(); // fetch offline settings from local storage as its // values are very inconsistent in the user context const offlineSettings = JSON.parse( localStorage.getItem(OFFLINE_SETTINGS_KEY) || "{}" ); if (offlineSettings?.offline) params.set("offline", "true"); navigator.sendBeacon("/api/v1/ping", params); const newNextPing = new Date(); newNextPing.setUTCDate(newNextPing.getUTCDate() + 1); newNextPing.setUTCHours(0); newNextPing.setUTCMinutes(0); newNextPing.setUTCSeconds(0); newNextPing.setUTCMilliseconds(0); localStorage.setItem("next-ping", newNextPing.toISOString()); } } catch (e) { console.error("Failed to send ping", e); } }, [isOnline, user]); } function getIsDocumentHidden() { if (typeof document !== "undefined") { return !document.hidden; } return false; } export function usePageVisibility() { const [isVisible, setIsVisible] = React.useState(getIsDocumentHidden()); const onVisibilityChange = () => setIsVisible(getIsDocumentHidden()); useEffect(() => { if (typeof document !== "undefined") { const visibilityChange = "visibilitychange"; document.addEventListener(visibilityChange, onVisibilityChange, false); return () => { document.removeEventListener(visibilityChange, onVisibilityChange); }; } }); return isVisible; } export function useIsIntersecting( node: HTMLElement | undefined, options: IntersectionObserverInit ) { const [isIntersectingState, setIsIntersectingState] = useState(false); useEffect(() => { if (node && window.IntersectionObserver) { const intersectionObserver = new IntersectionObserver((entries) => { const [{ isIntersecting = false } = {}] = entries; setIsIntersectingState(isIntersecting); }, options); intersectionObserver.observe(node); return () => { intersectionObserver.disconnect(); }; } }, [node, options]); return isIntersectingState; } export const useScrollToAnchor = () => { const scrolledRef = React.useRef(false); const { hash } = useLocation(); React.useEffect(() => { if (hash && !scrolledRef.current) { const element = document.getElementById(hash.replace("#", "")); if (element) { element.scrollIntoView({ behavior: "instant" }); scrolledRef.current = true; } } }); }; interface ViewedTimer { timeout: number | null; } export function useViewed( callback: Function, intersectionObserverOptions: IntersectionObserverInit = { root: null, rootMargin: "0px", threshold: 0.5, } ) { const timer = useRef({ timeout: null }); const isVisible = usePageVisibility(); const [node, setNode] = useState(); const isIntersecting = useIsIntersecting(node, intersectionObserverOptions); useEffect(() => { if (timer.current.timeout !== -1) { // timeout !== -1 means the viewed has not been sent if (isVisible && isIntersecting) { if (timer.current.timeout === null) { timer.current = { timeout: window.setTimeout(() => { timer.current = { timeout: -1 }; callback(); }, 1000), }; } } } return () => { if (timer.current.timeout !== null && timer.current.timeout !== -1) { clearTimeout(timer.current.timeout); timer.current = { timeout: null }; } }; }, [isVisible, isIntersecting, callback]); return useCallback((node: HTMLElement | null) => { if (node) { setNode(node); } }, []); }