diff options
Diffstat (limited to 'src/lib/hooks')
-rw-r--r-- | src/lib/hooks/useAccountSwitcher.ts | 74 | ||||
-rw-r--r-- | src/lib/hooks/useAnimatedScrollHandler_FIXED.ts | 15 | ||||
-rw-r--r-- | src/lib/hooks/useAnimatedScrollHandler_FIXED.web.ts | 44 | ||||
-rw-r--r-- | src/lib/hooks/useCustomFeed.ts | 18 | ||||
-rw-r--r-- | src/lib/hooks/useDesktopRightNavItems.ts | 51 | ||||
-rw-r--r-- | src/lib/hooks/useFollowProfile.ts | 55 | ||||
-rw-r--r-- | src/lib/hooks/useHomeTabs.ts | 29 | ||||
-rw-r--r-- | src/lib/hooks/useMinimalShellMode.tsx | 53 | ||||
-rw-r--r-- | src/lib/hooks/useNonReactiveCallback.ts | 23 | ||||
-rw-r--r-- | src/lib/hooks/useOTAUpdate.ts | 14 | ||||
-rw-r--r-- | src/lib/hooks/useOnMainScroll.ts | 156 | ||||
-rw-r--r-- | src/lib/hooks/useSetTitle.ts | 12 | ||||
-rw-r--r-- | src/lib/hooks/useToggleMutationQueue.ts | 98 | ||||
-rw-r--r-- | src/lib/hooks/useWebMediaQueries.tsx | 4 |
14 files changed, 360 insertions, 286 deletions
diff --git a/src/lib/hooks/useAccountSwitcher.ts b/src/lib/hooks/useAccountSwitcher.ts index 1ddb181a8..8a1dea5fe 100644 --- a/src/lib/hooks/useAccountSwitcher.ts +++ b/src/lib/hooks/useAccountSwitcher.ts @@ -1,43 +1,55 @@ -import {useCallback, useState} from 'react' -import {useStores} from 'state/index' -import {useAnalytics} from 'lib/analytics/analytics' -import {StackActions, useNavigation} from '@react-navigation/native' -import {NavigationProp} from 'lib/routes/types' -import {AccountData} from 'state/models/session' -import {reset as resetNavigation} from '../../Navigation' -import * as Toast from 'view/com/util/Toast' -import {useSetDrawerOpen} from '#/state/shell/drawer-open' +import {useCallback} from 'react' +import {useNavigation} from '@react-navigation/native' -export function useAccountSwitcher(): [ - boolean, - (v: boolean) => void, - (acct: AccountData) => Promise<void>, -] { +import {isWeb} from '#/platform/detection' +import {NavigationProp} from '#/lib/routes/types' +import {useAnalytics} from '#/lib/analytics/analytics' +import {useSessionApi, SessionAccount} from '#/state/session' +import * as Toast from '#/view/com/util/Toast' +import {useCloseAllActiveElements} from '#/state/util' +import {useLoggedOutViewControls} from '#/state/shell/logged-out' + +export function useAccountSwitcher() { const {track} = useAnalytics() - const store = useStores() - const setDrawerOpen = useSetDrawerOpen() - const [isSwitching, setIsSwitching] = useState(false) + const {selectAccount, clearCurrentAccount} = useSessionApi() + const closeAllActiveElements = useCloseAllActiveElements() const navigation = useNavigation<NavigationProp>() + const {setShowLoggedOut} = useLoggedOutViewControls() const onPressSwitchAccount = useCallback( - async (acct: AccountData) => { + async (account: SessionAccount) => { track('Settings:SwitchAccountButtonClicked') - setIsSwitching(true) - const success = await store.session.resumeSession(acct) - setDrawerOpen(false) - store.shell.closeAllActiveElements() - if (success) { - resetNavigation() - Toast.show(`Signed in as ${acct.displayName || acct.handle}`) - } else { + + try { + if (account.accessJwt) { + closeAllActiveElements() + navigation.navigate(isWeb ? 'Home' : 'HomeTab') + await selectAccount(account) + setTimeout(() => { + Toast.show(`Signed in as @${account.handle}`) + }, 100) + } else { + closeAllActiveElements() + setShowLoggedOut(true) + Toast.show( + `Please sign in as @${account.handle}`, + 'circle-exclamation', + ) + } + } catch (e) { Toast.show('Sorry! We need you to enter your password.') - navigation.navigate('HomeTab') - navigation.dispatch(StackActions.popToTop()) - store.session.clear() + clearCurrentAccount() // back user out to login } }, - [track, setIsSwitching, navigation, store, setDrawerOpen], + [ + track, + clearCurrentAccount, + selectAccount, + closeAllActiveElements, + navigation, + setShowLoggedOut, + ], ) - return [isSwitching, setIsSwitching, onPressSwitchAccount] + return {onPressSwitchAccount} } diff --git a/src/lib/hooks/useAnimatedScrollHandler_FIXED.ts b/src/lib/hooks/useAnimatedScrollHandler_FIXED.ts new file mode 100644 index 000000000..56a1e8b11 --- /dev/null +++ b/src/lib/hooks/useAnimatedScrollHandler_FIXED.ts @@ -0,0 +1,15 @@ +// Be warned. This Hook is very buggy unless used in a very constrained way. +// To use it safely: +// +// - DO NOT pass its return value as a prop to any user-defined component. +// - DO NOT pass its return value to more than a single component. +// +// In other words, the only safe way to use it is next to the leaf Reanimated View. +// +// Relevant bug reports: +// - https://github.com/software-mansion/react-native-reanimated/issues/5345 +// - https://github.com/software-mansion/react-native-reanimated/issues/5360 +// - https://github.com/software-mansion/react-native-reanimated/issues/5364 +// +// It's great when it works though. +export {useAnimatedScrollHandler} from 'react-native-reanimated' diff --git a/src/lib/hooks/useAnimatedScrollHandler_FIXED.web.ts b/src/lib/hooks/useAnimatedScrollHandler_FIXED.web.ts new file mode 100644 index 000000000..98e05a8ce --- /dev/null +++ b/src/lib/hooks/useAnimatedScrollHandler_FIXED.web.ts @@ -0,0 +1,44 @@ +import {useRef, useEffect} from 'react' +import {useAnimatedScrollHandler as useAnimatedScrollHandler_BUGGY} from 'react-native-reanimated' + +export const useAnimatedScrollHandler: typeof useAnimatedScrollHandler_BUGGY = ( + config, + deps, +) => { + const ref = useRef(config) + useEffect(() => { + ref.current = config + }) + return useAnimatedScrollHandler_BUGGY( + { + onBeginDrag(e, ctx) { + if (typeof ref.current !== 'function' && ref.current.onBeginDrag) { + ref.current.onBeginDrag(e, ctx) + } + }, + onEndDrag(e, ctx) { + if (typeof ref.current !== 'function' && ref.current.onEndDrag) { + ref.current.onEndDrag(e, ctx) + } + }, + onMomentumBegin(e, ctx) { + if (typeof ref.current !== 'function' && ref.current.onMomentumBegin) { + ref.current.onMomentumBegin(e, ctx) + } + }, + onMomentumEnd(e, ctx) { + if (typeof ref.current !== 'function' && ref.current.onMomentumEnd) { + ref.current.onMomentumEnd(e, ctx) + } + }, + onScroll(e, ctx) { + if (typeof ref.current === 'function') { + ref.current(e, ctx) + } else if (ref.current.onScroll) { + ref.current.onScroll(e, ctx) + } + }, + }, + deps, + ) +} diff --git a/src/lib/hooks/useCustomFeed.ts b/src/lib/hooks/useCustomFeed.ts deleted file mode 100644 index 04201b9a1..000000000 --- a/src/lib/hooks/useCustomFeed.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {useEffect, useState} from 'react' -import {useStores} from 'state/index' -import {FeedSourceModel} from 'state/models/content/feed-source' - -export function useCustomFeed(uri: string): FeedSourceModel | undefined { - const store = useStores() - const [item, setItem] = useState<FeedSourceModel | undefined>() - useEffect(() => { - async function buildFeedItem() { - const model = new FeedSourceModel(store, uri) - await model.setup() - setItem(model) - } - buildFeedItem() - }, [store, uri]) - - return item -} diff --git a/src/lib/hooks/useDesktopRightNavItems.ts b/src/lib/hooks/useDesktopRightNavItems.ts deleted file mode 100644 index f27efd28f..000000000 --- a/src/lib/hooks/useDesktopRightNavItems.ts +++ /dev/null @@ -1,51 +0,0 @@ -import {useEffect, useState} from 'react' -import {useStores} from 'state/index' -import isEqual from 'lodash.isequal' -import {AtUri} from '@atproto/api' -import {FeedSourceModel} from 'state/models/content/feed-source' - -interface RightNavItem { - uri: string - href: string - hostname: string - collection: string - rkey: string - displayName: string -} - -export function useDesktopRightNavItems(uris: string[]): RightNavItem[] { - const store = useStores() - const [items, setItems] = useState<RightNavItem[]>([]) - const [lastUris, setLastUris] = useState<string[]>([]) - - useEffect(() => { - if (isEqual(uris, lastUris)) { - // no changes - return - } - - async function fetchFeedInfo() { - const models = uris - .slice(0, 25) - .map(uri => new FeedSourceModel(store, uri)) - await Promise.all(models.map(m => m.setup())) - setItems( - models.map(model => { - const {hostname, collection, rkey} = new AtUri(model.uri) - return { - uri: model.uri, - href: model.href, - hostname, - collection, - rkey, - displayName: model.displayName, - } - }), - ) - setLastUris(uris) - } - fetchFeedInfo() - }, [store, uris, lastUris, setLastUris, setItems]) - - return items -} diff --git a/src/lib/hooks/useFollowProfile.ts b/src/lib/hooks/useFollowProfile.ts deleted file mode 100644 index 98dd63f5f..000000000 --- a/src/lib/hooks/useFollowProfile.ts +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react' -import {AppBskyActorDefs} from '@atproto/api' -import {useStores} from 'state/index' -import {FollowState} from 'state/models/cache/my-follows' -import {logger} from '#/logger' - -export function useFollowProfile(profile: AppBskyActorDefs.ProfileViewBasic) { - const store = useStores() - const state = store.me.follows.getFollowState(profile.did) - - return { - state, - following: state === FollowState.Following, - toggle: React.useCallback(async () => { - if (state === FollowState.Following) { - try { - await store.agent.deleteFollow( - store.me.follows.getFollowUri(profile.did), - ) - store.me.follows.removeFollow(profile.did) - return { - state: FollowState.NotFollowing, - following: false, - } - } catch (e: any) { - logger.error('Failed to delete follow', {error: e}) - throw e - } - } else if (state === FollowState.NotFollowing) { - try { - const res = await store.agent.follow(profile.did) - store.me.follows.addFollow(profile.did, { - followRecordUri: res.uri, - did: profile.did, - handle: profile.handle, - displayName: profile.displayName, - avatar: profile.avatar, - }) - return { - state: FollowState.Following, - following: true, - } - } catch (e: any) { - logger.error('Failed to create follow', {error: e}) - throw e - } - } - - return { - state: FollowState.Unknown, - following: false, - } - }, [store, profile, state]), - } -} diff --git a/src/lib/hooks/useHomeTabs.ts b/src/lib/hooks/useHomeTabs.ts deleted file mode 100644 index 69183e627..000000000 --- a/src/lib/hooks/useHomeTabs.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {useEffect, useState} from 'react' -import {useStores} from 'state/index' -import isEqual from 'lodash.isequal' -import {FeedSourceModel} from 'state/models/content/feed-source' - -export function useHomeTabs(uris: string[]): string[] { - const store = useStores() - const [tabs, setTabs] = useState<string[]>(['Following']) - const [lastUris, setLastUris] = useState<string[]>([]) - - useEffect(() => { - if (isEqual(uris, lastUris)) { - // no changes - return - } - - async function fetchFeedInfo() { - const models = uris - .slice(0, 25) - .map(uri => new FeedSourceModel(store, uri)) - await Promise.all(models.map(m => m.setup())) - setTabs(['Following'].concat(models.map(f => f.displayName))) - setLastUris(uris) - } - fetchFeedInfo() - }, [store, uris, lastUris, setLastUris, setTabs]) - - return tabs -} diff --git a/src/lib/hooks/useMinimalShellMode.tsx b/src/lib/hooks/useMinimalShellMode.tsx index ada934a26..e81fc434f 100644 --- a/src/lib/hooks/useMinimalShellMode.tsx +++ b/src/lib/hooks/useMinimalShellMode.tsx @@ -1,60 +1,43 @@ -import React from 'react' -import {autorun} from 'mobx' -import { - Easing, - interpolate, - useAnimatedStyle, - useSharedValue, - withTiming, -} from 'react-native-reanimated' - +import {interpolate, useAnimatedStyle} from 'react-native-reanimated' import {useMinimalShellMode as useMinimalShellModeState} from '#/state/shell/minimal-mode' +import {useShellLayout} from '#/state/shell/shell-layout' export function useMinimalShellMode() { - const minimalShellMode = useMinimalShellModeState() - const minimalShellInterp = useSharedValue(0) + const mode = useMinimalShellModeState() + const {footerHeight, headerHeight} = useShellLayout() + const footerMinimalShellTransform = useAnimatedStyle(() => { return { - opacity: interpolate(minimalShellInterp.value, [0, 1], [1, 0]), + pointerEvents: mode.value === 0 ? 'auto' : 'none', + opacity: Math.pow(1 - mode.value, 2), transform: [ - {translateY: interpolate(minimalShellInterp.value, [0, 1], [0, 25])}, + { + translateY: interpolate(mode.value, [0, 1], [0, footerHeight.value]), + }, ], } }) const headerMinimalShellTransform = useAnimatedStyle(() => { return { - opacity: interpolate(minimalShellInterp.value, [0, 1], [1, 0]), + pointerEvents: mode.value === 0 ? 'auto' : 'none', + opacity: Math.pow(1 - mode.value, 2), transform: [ - {translateY: interpolate(minimalShellInterp.value, [0, 1], [0, -25])}, + { + translateY: interpolate(mode.value, [0, 1], [0, -headerHeight.value]), + }, ], } }) const fabMinimalShellTransform = useAnimatedStyle(() => { return { transform: [ - {translateY: interpolate(minimalShellInterp.value, [0, 1], [-44, 0])}, + { + translateY: interpolate(mode.value, [0, 1], [-44, 0]), + }, ], } }) - - React.useEffect(() => { - return autorun(() => { - if (minimalShellMode) { - minimalShellInterp.value = withTiming(1, { - duration: 125, - easing: Easing.bezier(0.25, 0.1, 0.25, 1), - }) - } else { - minimalShellInterp.value = withTiming(0, { - duration: 125, - easing: Easing.bezier(0.25, 0.1, 0.25, 1), - }) - } - }) - }, [minimalShellInterp, minimalShellMode]) - return { - minimalShellMode, footerMinimalShellTransform, headerMinimalShellTransform, fabMinimalShellTransform, diff --git a/src/lib/hooks/useNonReactiveCallback.ts b/src/lib/hooks/useNonReactiveCallback.ts new file mode 100644 index 000000000..4b3d6abb9 --- /dev/null +++ b/src/lib/hooks/useNonReactiveCallback.ts @@ -0,0 +1,23 @@ +import {useCallback, useInsertionEffect, useRef} from 'react' + +// This should be used sparingly. It erases reactivity, i.e. when the inputs +// change, the function itself will remain the same. This means that if you +// use this at a higher level of your tree, and then some state you read in it +// changes, there is no mechanism for anything below in the tree to "react" +// to this change (e.g. by knowing to call your function again). +// +// Also, you should avoid calling the returned function during rendering +// since the values captured by it are going to lag behind. +export function useNonReactiveCallback<T extends Function>(fn: T): T { + const ref = useRef(fn) + useInsertionEffect(() => { + ref.current = fn + }, [fn]) + return useCallback( + (...args: any) => { + const latestFn = ref.current + return latestFn(...args) + }, + [ref], + ) as unknown as T +} diff --git a/src/lib/hooks/useOTAUpdate.ts b/src/lib/hooks/useOTAUpdate.ts index 0ce97a4c8..55147329b 100644 --- a/src/lib/hooks/useOTAUpdate.ts +++ b/src/lib/hooks/useOTAUpdate.ts @@ -1,26 +1,26 @@ import * as Updates from 'expo-updates' import {useCallback, useEffect} from 'react' import {AppState} from 'react-native' -import {useStores} from 'state/index' import {logger} from '#/logger' +import {useModalControls} from '#/state/modals' +import {t} from '@lingui/macro' export function useOTAUpdate() { - const store = useStores() + const {openModal} = useModalControls() // HELPER FUNCTIONS const showUpdatePopup = useCallback(() => { - store.shell.openModal({ + openModal({ name: 'confirm', - title: 'Update Available', - message: - 'A new version of the app is available. Please update to continue using the app.', + title: t`Update Available`, + message: t`A new version of the app is available. Please update to continue using the app.`, onPressConfirm: async () => { Updates.reloadAsync().catch(err => { throw err }) }, }) - }, [store.shell]) + }, [openModal]) const checkForUpdate = useCallback(async () => { logger.debug('useOTAUpdate: Checking for update...') try { diff --git a/src/lib/hooks/useOnMainScroll.ts b/src/lib/hooks/useOnMainScroll.ts index 2eab4b250..2e7a79913 100644 --- a/src/lib/hooks/useOnMainScroll.ts +++ b/src/lib/hooks/useOnMainScroll.ts @@ -1,69 +1,125 @@ -import {useState, useCallback, useRef} from 'react' +import {useState, useCallback, useMemo} from 'react' import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native' -import {s} from 'lib/styles' -import {useWebMediaQueries} from './useWebMediaQueries' import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell' +import {useShellLayout} from '#/state/shell/shell-layout' +import {s} from 'lib/styles' +import {isWeb} from 'platform/detection' +import { + useSharedValue, + interpolate, + runOnJS, + ScrollHandlers, +} from 'react-native-reanimated' -const Y_LIMIT = 10 - -const useDeviceLimits = () => { - const {isDesktop} = useWebMediaQueries() - return { - dyLimitUp: isDesktop ? 30 : 10, - dyLimitDown: isDesktop ? 150 : 10, - } +function clamp(num: number, min: number, max: number) { + 'worklet' + return Math.min(Math.max(num, min), max) } export type OnScrollCb = ( event: NativeSyntheticEvent<NativeScrollEvent>, ) => void +export type OnScrollHandler = ScrollHandlers<any> export type ResetCb = () => void -export function useOnMainScroll(): [OnScrollCb, boolean, ResetCb] { - let lastY = useRef(0) - let [isScrolledDown, setIsScrolledDown] = useState(false) - const {dyLimitUp, dyLimitDown} = useDeviceLimits() - const minimalShellMode = useMinimalShellMode() - const setMinimalShellMode = useSetMinimalShellMode() +export function useOnMainScroll(): [OnScrollHandler, boolean, ResetCb] { + const {headerHeight} = useShellLayout() + const [isScrolledDown, setIsScrolledDown] = useState(false) + const mode = useMinimalShellMode() + const setMode = useSetMinimalShellMode() + const startDragOffset = useSharedValue<number | null>(null) + const startMode = useSharedValue<number | null>(null) - return [ - useCallback( - (event: NativeSyntheticEvent<NativeScrollEvent>) => { - const y = event.nativeEvent.contentOffset.y - const dy = y - (lastY.current || 0) - lastY.current = y + const onBeginDrag = useCallback( + (e: NativeScrollEvent) => { + 'worklet' + startDragOffset.value = e.contentOffset.y + startMode.value = mode.value + }, + [mode, startDragOffset, startMode], + ) - if (!minimalShellMode && dy > dyLimitDown && y > Y_LIMIT) { - setMinimalShellMode(true) - } else if (minimalShellMode && (dy < dyLimitUp * -1 || y <= Y_LIMIT)) { - setMinimalShellMode(false) - } + const onEndDrag = useCallback( + (e: NativeScrollEvent) => { + 'worklet' + startDragOffset.value = null + startMode.value = null + if (e.contentOffset.y < headerHeight.value / 2) { + // If we're close to the top, show the shell. + setMode(false) + } else { + // Snap to whichever state is the closest. + setMode(Math.round(mode.value) === 1) + } + }, + [startDragOffset, startMode, setMode, mode, headerHeight], + ) + + const onScroll = useCallback( + (e: NativeScrollEvent) => { + 'worklet' + // Keep track of whether we want to show "scroll to top". + if (!isScrolledDown && e.contentOffset.y > s.window.height) { + runOnJS(setIsScrolledDown)(true) + } else if (isScrolledDown && e.contentOffset.y < s.window.height) { + runOnJS(setIsScrolledDown)(false) + } - if ( - !isScrolledDown && - event.nativeEvent.contentOffset.y > s.window.height - ) { - setIsScrolledDown(true) - } else if ( - isScrolledDown && - event.nativeEvent.contentOffset.y < s.window.height - ) { - setIsScrolledDown(false) + if (startDragOffset.value === null || startMode.value === null) { + if (mode.value !== 0 && e.contentOffset.y < headerHeight.value) { + // If we're close enough to the top, always show the shell. + // Even if we're not dragging. + setMode(false) + return } - }, - [ - dyLimitDown, - dyLimitUp, - isScrolledDown, - minimalShellMode, - setMinimalShellMode, - ], - ), + if (isWeb) { + // On the web, there is no concept of "starting" the drag. + // When we get the first scroll event, we consider that the start. + startDragOffset.value = e.contentOffset.y + startMode.value = mode.value + } + return + } + + // The "mode" value is always between 0 and 1. + // Figure out how much to move it based on the current dragged distance. + const dy = e.contentOffset.y - startDragOffset.value + const dProgress = interpolate( + dy, + [-headerHeight.value, headerHeight.value], + [-1, 1], + ) + const newValue = clamp(startMode.value + dProgress, 0, 1) + if (newValue !== mode.value) { + // Manually adjust the value. This won't be (and shouldn't be) animated. + mode.value = newValue + } + if (isWeb) { + // On the web, there is no concept of "starting" the drag, + // so we don't have any specific anchor point to calculate the distance. + // Instead, update it continuosly along the way and diff with the last event. + startDragOffset.value = e.contentOffset.y + startMode.value = mode.value + } + }, + [headerHeight, mode, setMode, isScrolledDown, startDragOffset, startMode], + ) + + const scrollHandler: ScrollHandlers<any> = useMemo( + () => ({ + onBeginDrag, + onEndDrag, + onScroll, + }), + [onBeginDrag, onEndDrag, onScroll], + ) + + return [ + scrollHandler, isScrolledDown, useCallback(() => { setIsScrolledDown(false) - setMinimalShellMode(false) - lastY.current = 1e8 // NOTE we set this very high so that the onScroll logic works right -prf - }, [setIsScrolledDown, setMinimalShellMode]), + setMode(false) + }, [setMode]), ] } diff --git a/src/lib/hooks/useSetTitle.ts b/src/lib/hooks/useSetTitle.ts index c5c7a5ca1..129023f71 100644 --- a/src/lib/hooks/useSetTitle.ts +++ b/src/lib/hooks/useSetTitle.ts @@ -3,18 +3,14 @@ import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' import {bskyTitle} from 'lib/strings/headings' -import {useStores} from 'state/index' +import {useUnreadNotifications} from '#/state/queries/notifications/unread' -/** - * Requires consuming component to be wrapped in `observer`: - * https://stackoverflow.com/a/71488009 - */ export function useSetTitle(title?: string) { const navigation = useNavigation<NavigationProp>() - const {unreadCountLabel} = useStores().me.notifications + const numUnread = useUnreadNotifications() useEffect(() => { if (title) { - navigation.setOptions({title: bskyTitle(title, unreadCountLabel)}) + navigation.setOptions({title: bskyTitle(title, numUnread)}) } - }, [title, navigation, unreadCountLabel]) + }, [title, navigation, numUnread]) } diff --git a/src/lib/hooks/useToggleMutationQueue.ts b/src/lib/hooks/useToggleMutationQueue.ts new file mode 100644 index 000000000..28ae86142 --- /dev/null +++ b/src/lib/hooks/useToggleMutationQueue.ts @@ -0,0 +1,98 @@ +import {useState, useRef, useEffect, useCallback} from 'react' + +type Task<TServerState> = { + isOn: boolean + resolve: (serverState: TServerState) => void + reject: (e: unknown) => void +} + +type TaskQueue<TServerState> = { + activeTask: Task<TServerState> | null + queuedTask: Task<TServerState> | null +} + +function AbortError() { + const e = new Error() + e.name = 'AbortError' + return e +} + +export function useToggleMutationQueue<TServerState>({ + initialState, + runMutation, + onSuccess, +}: { + initialState: TServerState + runMutation: ( + prevState: TServerState, + nextIsOn: boolean, + ) => Promise<TServerState> + onSuccess: (finalState: TServerState) => void +}) { + // We use the queue as a mutable object. + // This is safe becuase it is not used for rendering. + const [queue] = useState<TaskQueue<TServerState>>({ + activeTask: null, + queuedTask: null, + }) + + async function processQueue() { + if (queue.activeTask) { + // There is another active processQueue call iterating over tasks. + // It will handle any newly added tasks, so we should exit early. + return + } + // To avoid relying on the rendered state, capture it once at the start. + // From that point on, and until the queue is drained, we'll use the real server state. + let confirmedState: TServerState = initialState + try { + while (queue.queuedTask) { + const prevTask = queue.activeTask + const nextTask = queue.queuedTask + queue.activeTask = nextTask + queue.queuedTask = null + if (prevTask?.isOn === nextTask.isOn) { + // Skip multiple requests to update to the same value in a row. + prevTask.reject(new (AbortError as any)()) + continue + } + try { + // The state received from the server feeds into the next task. + // This lets us queue deletions of not-yet-created resources. + confirmedState = await runMutation(confirmedState, nextTask.isOn) + nextTask.resolve(confirmedState) + } catch (e) { + nextTask.reject(e) + } + } + } finally { + onSuccess(confirmedState) + queue.activeTask = null + queue.queuedTask = null + } + } + + function queueToggle(isOn: boolean): Promise<TServerState> { + return new Promise((resolve, reject) => { + // This is a toggle, so the next queued value can safely replace the queued one. + if (queue.queuedTask) { + queue.queuedTask.reject(new (AbortError as any)()) + } + queue.queuedTask = {isOn, resolve, reject} + processQueue() + }) + } + + const queueToggleRef = useRef(queueToggle) + useEffect(() => { + queueToggleRef.current = queueToggle + }) + const queueToggleStable = useCallback( + (isOn: boolean): Promise<TServerState> => { + const queueToggleLatest = queueToggleRef.current + return queueToggleLatest(isOn) + }, + [], + ) + return queueToggleStable +} diff --git a/src/lib/hooks/useWebMediaQueries.tsx b/src/lib/hooks/useWebMediaQueries.tsx index 3f43a0aaf..71a96a89b 100644 --- a/src/lib/hooks/useWebMediaQueries.tsx +++ b/src/lib/hooks/useWebMediaQueries.tsx @@ -3,8 +3,8 @@ import {isNative} from 'platform/detection' export function useWebMediaQueries() { const isDesktop = useMediaQuery({minWidth: 1300}) - const isTablet = useMediaQuery({minWidth: 800, maxWidth: 1300}) - const isMobile = useMediaQuery({maxWidth: 800}) + const isTablet = useMediaQuery({minWidth: 800, maxWidth: 1300 - 1}) + const isMobile = useMediaQuery({maxWidth: 800 - 1}) const isTabletOrMobile = isMobile || isTablet const isTabletOrDesktop = isDesktop || isTablet if (isNative) { |