diff --git a/packages/website/components/contexts/tabGroupChoiceContext.js b/packages/website/components/contexts/tabGroupChoiceContext.js new file mode 100644 index 0000000000..0c8a536d84 --- /dev/null +++ b/packages/website/components/contexts/tabGroupChoiceContext.js @@ -0,0 +1,105 @@ +import React, { createContext, useState, useCallback, useEffect, useMemo, useContext } from 'react'; + +const TAB_CHOICE_PREFIX = 'web3storage.tab.'; + +/** + * @typedef {object} ContextValue + * @property {boolean} ready - True if tab choices have been restored from storage + * @property {Record} tabGroupChoices - A map of `groupId` to the chosen tab `value` + * @property {(groupId: string, value: string) => void} setTabGroupChoice - Set the tab `value` for a given `groupId` + */ + +/** @type {ContextValue} */ +const initialValue = { + ready: false, + tabGroupChoices: {}, + setTabGroupChoice: () => {}, +}; + +const TabGroupContext = createContext(initialValue); + +/** + * Stores the chosen `value` for a given tab `groupId` in local storage. Browser only. + */ +function storeTabGroupChoice(groupId, value) { + if (typeof window === 'undefined') { + console.error('localStorage is not available during server rendering'); + return; + } + const key = `${TAB_CHOICE_PREFIX}${groupId}`; + window.localStorage.setItem(key, value); +} + +/** + * Loads all tab group choices from local storage. Browser only. + * @returns {Record} + */ +function getAllTabGroupChoices() { + if (typeof window === 'undefined') { + console.error('localStorage is not available during server rendering'); + return {}; + } + const storage = window.localStorage; + + /** @type {Record} */ + const choices = {}; + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i); + if (!key?.startsWith(TAB_CHOICE_PREFIX)) { + continue; + } + const groupId = key.substring(TAB_CHOICE_PREFIX.length); + choices[groupId] = storage.getItem(key) || ''; + } + + return choices; +} + +/** + * @returns {ContextValue} + */ +function useContextValue() { + const [ready, setReady] = useState(false); + const [tabGroupChoices, setChoices] = useState(/** @type {Record} */ {}); + const setChoiceSyncWithLocalStorage = useCallback((groupId, value) => { + storeTabGroupChoice(groupId, value); + }, []); + + useEffect(() => { + try { + const choices = getAllTabGroupChoices(); + setChoices(choices); + console.log('tab choices ready:', choices); + } catch (err) { + console.error('error loading tab choices from local storage:', err); + } + + setReady(true); + }, []); + + const setTabGroupChoice = useCallback( + (groupId, value) => { + setChoices(oldChoices => ({ ...oldChoices, [groupId]: value })); + setChoiceSyncWithLocalStorage(groupId, value); + }, + [setChoiceSyncWithLocalStorage] + ); + + return useMemo(() => ({ ready, tabGroupChoices, setTabGroupChoice }), [ready, tabGroupChoices, setTabGroupChoice]); +} + +export function TabGroupChoiceProvider({ children }) { + const value = useContextValue(); + return {children}; +} + +/** + * @returns {ContextValue} + */ +export function useTabGroupChoices() { + const context = useContext(TabGroupContext); + if (context == null) { + throw new Error(`Hook useTabGroupChoice was called outside the provider `); + } + return context; +} diff --git a/packages/website/components/general/appProviders.js b/packages/website/components/general/appProviders.js index 06864db2b5..fe7b9233b9 100644 --- a/packages/website/components/general/appProviders.js +++ b/packages/website/components/general/appProviders.js @@ -6,6 +6,7 @@ import { UserProvider } from 'components/contexts/userContext'; import { UploadsProvider } from 'components/contexts/uploadsContext'; import { PinRequestsProvider } from 'components/contexts/pinRequestsContext'; import { TokensProvider } from 'components/contexts/tokensContext'; +import { TabGroupChoiceProvider } from 'components/contexts/tabGroupChoiceContext'; const queryClient = new QueryClient({ defaultOptions: { @@ -29,7 +30,9 @@ const AppProviders = ({ authorizationProps, children }) => { - {children} + + {children} + diff --git a/packages/website/components/tabs/tabs.js b/packages/website/components/tabs/tabs.js index 76169fd694..08ed1f2b61 100644 --- a/packages/website/components/tabs/tabs.js +++ b/packages/website/components/tabs/tabs.js @@ -1,5 +1,9 @@ -import React, { useState, cloneElement, Children, isValidElement } from 'react'; +import React, { useState, cloneElement, Children, isValidElement, useEffect, useCallback } from 'react'; import clsx from 'clsx'; +import { useRouter } from 'next/router'; + +import { useTabGroupChoices } from 'components/contexts/tabGroupChoiceContext'; +import { useEvent } from 'lib/utils'; /** * @param {React.ReactElement} comp @@ -9,6 +13,35 @@ function isTabItem(comp) { return typeof comp.props.value !== 'undefined'; } +/** + * @param {string|undefined} groupId + */ +function useTabQueryString(groupId) { + const router = useRouter(); + const { query, pathname } = router; + const get = useCallback(() => (groupId ? query[groupId] : undefined), [groupId, query]); + + const set = useCallback( + value => { + if (!groupId) { + return; + } + const newQuery = { ...query, [groupId]: encodeURIComponent(value) }; + router.replace( + { + pathname, + query: newQuery, + }, + undefined, + { shallow: true } + ); + }, + [groupId, router, pathname, query] + ); + + return { get, set }; +} + /** * @typedef {object} TabItemProps * @property {React.ReactNode} children @@ -37,6 +70,7 @@ export function TabItem(props) { * @typedef {object} TabsProps * @property {React.ReactNode} children * @property {string} [className] + * @property {string} [groupId] if present, `` with the same groupId will synchronize their active tab. */ /** @@ -44,6 +78,7 @@ export function TabItem(props) { * @param {TabsProps} props */ export function Tabs(props) { + const { groupId } = props; const children = Children.map(props.children, child => { if (isValidElement(child) && isTabItem(child)) { @@ -64,6 +99,9 @@ export function Tabs(props) { throw new Error(`All children of a component must have a unique 'value' prop`); } + const { ready: tabChoicesReady, tabGroupChoices, setTabGroupChoice } = useTabGroupChoices(); + const tabQueryString = useTabQueryString(groupId); + const defaultValue = values.length > 0 ? values[0].value : null; const [selectedValue, setSelectedValue] = useState(defaultValue); @@ -72,6 +110,27 @@ export function Tabs(props) { */ const tabRefs = []; + // Lazily restore the saved tab choices (if they exist) + // We can't use localStorage on first render, since it's not availble server side + // and would cause a hydration mismatch. + const restoreTabChoice = useEvent(() => { + if (!tabChoicesReady) { + return; + } + const toRestore = tabQueryString.get() ?? (groupId && tabGroupChoices[groupId]); + const isValid = toRestore && values.some(v => v.value === toRestore); + if (isValid) { + setSelectedValue(toRestore); + } + }); + + useEffect(() => { + // wait for localStorage values to be set + if (tabChoicesReady) { + restoreTabChoice(); + } + }, [tabChoicesReady, restoreTabChoice]); + /** * @param {React.FocusEvent | React.MouseEvent} e */ @@ -83,6 +142,10 @@ export function Tabs(props) { const newValue = values[newTabIndex].value; if (newValue !== selectedValue) { setSelectedValue(newValue); + tabQueryString.set(newValue); + if (groupId) { + setTabGroupChoice(groupId, newValue); + } } }; diff --git a/packages/website/lib/utils.js b/packages/website/lib/utils.js index 382ce6a43b..6435f35571 100644 --- a/packages/website/lib/utils.js +++ b/packages/website/lib/utils.js @@ -1,3 +1,5 @@ +import { useEffect, useLayoutEffect, useRef, useCallback } from 'react' + /** * If it's a different day, it returns the day, otherwise it returns the hour * @param {*} timestamp @@ -104,3 +106,44 @@ export const elementIsInViewport = element => { } return false; }; + + +/** + * This hook is like `useLayoutEffect`, but without the SSR warning. + * It seems hacky but it's used in many React libs (Redux, Formik...). + * Also mentioned here: https://github.com/facebook/react/issues/16956 + * + * It is useful when you need to update a ref as soon as possible after a React + * render (before `useEffect`). + */ +export const useIsomorphicLayoutEffect = (typeof window !== 'undefined') + ? useLayoutEffect + : useEffect; + +/** + * Temporary userland implementation until an official hook is implemented + * See RFC: https://github.com/reactjs/rfcs/pull/220 + * + * Permits to transform an unstable callback (like an arrow function provided as + * props) to a "stable" callback that is safe to use in a `useEffect` dependency + * array. Useful to avoid React stale closure problems + avoid useless effect + * re-executions. + * + * This generally works but has some potential drawbacks, such as + * https://github.com/facebook/react/issues/16956#issuecomment-536636418 + * + * @template {(...args: never[]) => unknown} T + * @param {T} callback + * @returns {T} + */ +export function useEvent(callback) { + const ref = useRef(callback); + + useIsomorphicLayoutEffect(() => { + ref.current = callback; + }, [callback]); + + // @ts-expect-error: TS is right that this callback may be a supertype of T, + // but good enough for our use + return useCallback((...args) => ref.current(...args), []); +} \ No newline at end of file diff --git a/packages/website/pages/docs/intro.md b/packages/website/pages/docs/intro.md index f1902b1caa..e8c68c0432 100644 --- a/packages/website/pages/docs/intro.md +++ b/packages/website/pages/docs/intro.md @@ -52,7 +52,7 @@ node --version && npm --version You need a web3.storage account to get your API token and manage your stored data. You can sign up **for free** using your email address or GitHub. - + ##### Sign up using email 1. Go to [web3.storage/login](https://web3.storage/login) to get started.