From a4ca3fe3ad637427a2558f1e7dba339526b9407c Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Thu, 23 Apr 2026 14:51:22 -0400 Subject: [PATCH 01/27] WIP --- shared/package.json | 2 +- shared/yarn.lock | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/shared/package.json b/shared/package.json index 684de86a7ce7..4c39e4501d8c 100644 --- a/shared/package.json +++ b/shared/package.json @@ -173,7 +173,7 @@ "eslint-plugin-promise": "7.2.1", "eslint-plugin-react": "7.37.5", "eslint-plugin-react-compiler": "19.1.0-rc.2", - "eslint-plugin-react-hooks": "7.0.1", + "eslint-plugin-react-hooks": "7.1.1", "fs-extra": "11.3.4", "html-webpack-plugin": "5.6.7", "jest": "30.3.0", diff --git a/shared/yarn.lock b/shared/yarn.lock index a718680429b3..b52a7dad2b75 100644 --- a/shared/yarn.lock +++ b/shared/yarn.lock @@ -6126,7 +6126,18 @@ eslint-plugin-react-compiler@19.1.0-rc.2: zod "^3.22.4" zod-validation-error "^3.0.3" -eslint-plugin-react-hooks@7.0.1, eslint-plugin-react-hooks@^7.0.1: +eslint-plugin-react-hooks@7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz#e6742cad75d970c0a3f30d7d3fa80a4784f55927" + integrity sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g== + dependencies: + "@babel/core" "^7.24.4" + "@babel/parser" "^7.24.4" + hermes-parser "^0.25.1" + zod "^3.25.0 || ^4.0.0" + zod-validation-error "^3.5.0 || ^4.0.0" + +eslint-plugin-react-hooks@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz#66e258db58ece50723ef20cc159f8aa908219169" integrity sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA== From 4a140e192ff2c01db1d6a5d127edc24f56850c0e Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 24 Apr 2026 17:00:31 -0400 Subject: [PATCH 02/27] WIP --- AGENTS.md | 1 + shared/app/index.native.tsx | 43 ++-- shared/chat/audio/audio-recorder.native.tsx | 18 +- .../conversation/list-area/index.desktop.tsx | 22 +- shared/constants/utils.tsx | 2 +- shared/profile/generic/proofs-list.tsx | 5 +- shared/teams/use-cached-resource.tsx | 200 ++++++++++++------ shared/teams/use-teams-list.tsx | 5 +- shared/util/use-debounce.tsx | 150 +++++++------ 9 files changed, 261 insertions(+), 185 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 014a83686ff7..d84bc62c67e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ - Do not add new exported functions, types, or constants unless they are required outside the file. Prefer file-local helpers for one-off implementation details and tests. - Under `shared/`, non-test TypeScript source files should use the `.tsx` extension. - Do not edit lockfiles by hand. They are generated artifacts. If you cannot regenerate one locally, leave it unchanged. +- Never disable lints to address lint failures. Fix the underlying issue instead. - Components must not mutate Zustand stores directly with `useXState.setState`, `getState()`-based writes, or similar ad hoc store mutation. If a component needs to affect store state, route it through a store dispatch action or move the state out of the store. - For server-owned state such as badges, Gregor-driven UI state, and other engine-fed state, prefer reflecting the latest server state instead of masking problems with optimistic local mutations. Do not add local state writes that make the UI look correct while drifting from what the server has actually told us. - When a Zustand store already uses `resetState: Z.defaultReset`, prefer calling `dispatch.resetState()` for full resets instead of manually reassigning each initial field in another dispatch action. diff --git a/shared/app/index.native.tsx b/shared/app/index.native.tsx index 8a2ed25a4ed9..c61dd7e86221 100644 --- a/shared/app/index.native.tsx +++ b/shared/app/index.native.tsx @@ -32,32 +32,31 @@ module.hot?.accept(() => { console.log('accepted update in shared/index.native') }) +const initDarkMode = () => { + const {setDarkModePreference, setSystemDarkMode, setSystemSupported} = + DarkMode.useDarkModeState.getState().dispatch + setSystemDarkMode(Appearance.getColorScheme() === 'dark') + setSystemSupported(darkModeSupported) + try { + const obj = JSON.parse(guiConfig) as {ui?: {darkMode?: string}} | undefined + const dm = obj?.ui?.darkMode + switch (dm) { + case 'system': // fallthrough + case 'alwaysDark': // fallthrough + case 'alwaysLight': + setDarkModePreference(dm, false) + break + default: + } + } catch {} +} + +initDarkMode() + const useDarkHookup = () => { - const initedRef = React.useRef(false) const appStateRef = React.useRef('active') const setSystemDarkMode = DarkMode.useDarkModeState(s => s.dispatch.setSystemDarkMode) const setMobileAppState = useShellState(s => s.dispatch.setMobileAppState) - const setSystemSupported = DarkMode.useDarkModeState(s => s.dispatch.setSystemSupported) - const setDarkModePreference = DarkMode.useDarkModeState(s => s.dispatch.setDarkModePreference) - - // once - if (!initedRef.current) { - initedRef.current = true - setSystemDarkMode(Appearance.getColorScheme() === 'dark') - setSystemSupported(darkModeSupported) - try { - const obj = JSON.parse(guiConfig) as {ui?: {darkMode?: string}} | undefined - const dm = obj?.ui?.darkMode - switch (dm) { - case 'system': // fallthrough - case 'alwaysDark': // fallthrough - case 'alwaysLight': - setDarkModePreference(dm, false) - break - default: - } - } catch {} - } React.useEffect(() => { const appStateChangeSub = AppState.addEventListener('change', nextAppState => { diff --git a/shared/chat/audio/audio-recorder.native.tsx b/shared/chat/audio/audio-recorder.native.tsx index 7bb526791da9..e368d2a17ab6 100644 --- a/shared/chat/audio/audio-recorder.native.tsx +++ b/shared/chat/audio/audio-recorder.native.tsx @@ -312,8 +312,9 @@ const useRecorder = (p: {ampSV: SVN; setShowAudioSend: (s: boolean) => void; sho const recordEndRef = React.useRef(0) const hasSetupRecording = React.useRef(false) const pathRef = React.useRef('') - const ampTracker = React.useRef(new AmpTracker()).current + const [ampTracker] = React.useState(() => new AmpTracker()) const [staged, setStaged] = React.useState(false) + const [stagedRecording, setStagedRecording] = React.useState({duration: 0, path: ''}) const recorder = useAudioRecorder(recordingOptions) const recorderState = useAudioRecorderState(recorder, 100) @@ -361,6 +362,7 @@ const useRecorder = (p: {ampSV: SVN; setShowAudioSend: (s: boolean) => void; sho } recordStartRef.current = 0 recordEndRef.current = 0 + setStagedRecording({duration: 0, path: ''}) setStaged(false) setShowAudioSend(false) } @@ -451,8 +453,8 @@ const useRecorder = (p: {ampSV: SVN; setShowAudioSend: (s: boolean) => void; sho ) : null @@ -460,6 +462,10 @@ const useRecorder = (p: {ampSV: SVN; setShowAudioSend: (s: boolean) => void; sho const stageRecording = () => { const impl = async () => { await stopRecording() + setStagedRecording({ + duration: (recordEndRef.current || recordStartRef.current) - recordStartRef.current, + path: pathRef.current, + }) setStaged(true) setShowAudioSend(true) } @@ -469,13 +475,11 @@ const useRecorder = (p: {ampSV: SVN; setShowAudioSend: (s: boolean) => void; sho } // on unmount cleanup - const onResetRef = React.useRef(onReset) - onResetRef.current = onReset + const onResetEvent = React.useEffectEvent(onReset) React.useEffect(() => { return () => { setShowAudioSend(false) - onResetRef - .current() + onResetEvent() .then(() => {}) .catch(() => {}) } diff --git a/shared/chat/conversation/list-area/index.desktop.tsx b/shared/chat/conversation/list-area/index.desktop.tsx index 920fcd8e165b..dcb93f3fdf2a 100644 --- a/shared/chat/conversation/list-area/index.desktop.tsx +++ b/shared/chat/conversation/list-area/index.desktop.tsx @@ -13,7 +13,6 @@ import {findLast} from '@/util/arrays' import {MessageRow} from '../messages/wrapper' import {globalMargins} from '@/styles/shared' import {FocusContext, ScrollContext} from '../normal/context' -import shallowEqual from '@/util/shallow-equal' import useResizeObserver from '@/util/use-resize-observer.desktop' import useIntersectionObserver from '@/util/use-intersection-observer' import {copyToClipboard} from '@/util/storeless-actions' @@ -388,8 +387,6 @@ const useItems = (p: { ) } - const wayOrdinalCachRef = React.useRef(new Map>()) - // TODO doesn't need all messageOrdinals in there, could just find buckets and push details down const items = (() => { const items: Array = [] @@ -419,16 +416,8 @@ const useItems = (p: { const chunks = chunk(ordinals, 10) chunks.forEach((toAdd, cidx) => { const key = `${lastBucket || ''}:${cidx + baseIndex}` - let wayOrdinals = toAdd - const existing = wayOrdinalCachRef.current.get(key) - if (existing && shallowEqual(existing, wayOrdinals)) { - wayOrdinals = existing - } else { - wayOrdinalCachRef.current.set(key, wayOrdinals) - } - items.push( - + ) }) // we pass previous so the OrdinalWaypoint can render the top item correctly @@ -439,19 +428,12 @@ const useItems = (p: { // If this is the centered ordinal, it goes into its own waypoint so we can easily scroll to it if (isCenteredOrdinal) { const key = scrollOrdinalKey - let wayOrdinals = [ordinal] - const existing = wayOrdinalCachRef.current.get(key) - if (existing && shallowEqual(existing, wayOrdinals)) { - wayOrdinals = existing - } else { - wayOrdinalCachRef.current.set(key, wayOrdinals) - } items.push( ) lastBucket = 0 diff --git a/shared/constants/utils.tsx b/shared/constants/utils.tsx index 645dd8577ed7..28be192c43f2 100644 --- a/shared/constants/utils.tsx +++ b/shared/constants/utils.tsx @@ -47,6 +47,6 @@ export {default as useRPC} from '@/util/use-rpc' export {produce} from 'immer' export * from './immer' export {default as featureFlags} from '../util/feature-flags' -export {useOnMountOnce, useOnUnMountOnce, useLogMount} from './react' +export {deferEffectUpdate, useOnMountOnce, useOnUnMountOnce, useLogMount} from './react' export {debugWarning} from '@/util/debug-warning' export {isNetworkErr, RPCError} from '@/util/errors' diff --git a/shared/profile/generic/proofs-list.tsx b/shared/profile/generic/proofs-list.tsx index 16885c14ac70..ad8d2d7bc9e1 100644 --- a/shared/profile/generic/proofs-list.tsx +++ b/shared/profile/generic/proofs-list.tsx @@ -132,7 +132,6 @@ const Container = ({platform, reason = 'profile'}: Props) => { const afterCheckProofRef = React.useRef void)>(undefined) const cancelCurrentRef = React.useRef void)>(undefined) const submitUsernameRef = React.useRef void)>(undefined) - const startProofRef = React.useRef<(proofPlatform: string, proofReason: 'appLink' | 'profile') => void>(() => {}) const [step, setStep] = React.useState(platform ? {kind: 'loading'} : {kind: 'pick'}) const setStepSafe = (next: Step) => { @@ -462,13 +461,13 @@ const Container = ({platform, reason = 'profile'}: Props) => { submitUsernameRef.current(normalized) } - startProofRef.current = startProof + const startProofEvent = React.useEffectEvent(startProof) React.useEffect(() => { const {platform: initialPlatform, reason: initialReason} = initialRouteRef.current if (initialPlatform && !initialProofStartedRef.current) { initialProofStartedRef.current = true - startProofRef.current(initialPlatform, initialReason) + startProofEvent(initialPlatform, initialReason) } }, []) diff --git a/shared/teams/use-cached-resource.tsx b/shared/teams/use-cached-resource.tsx index 11d106afe3b5..cce418a893bb 100644 --- a/shared/teams/use-cached-resource.tsx +++ b/shared/teams/use-cached-resource.tsx @@ -2,11 +2,16 @@ import * as C from '@/constants' import * as React from 'react' export type CachedResourceCache = { - data: T - generation: number - inFlight?: Promise - key: K - loadedAt: number + clearInFlight: (request: Promise) => void + getData: () => T + getGeneration: () => number + getInFlight: () => Promise | undefined + getKey: () => K + getLoadedAt: () => number + invalidate: (key: K) => void + reset: (data: T, key: K) => void + setDataLoaded: (data: T, generation: number) => void + setInFlight: (request: Promise) => void } type CachedResourceState = { @@ -32,12 +37,48 @@ const emptyState = (data: T): CachedResourceState => ({ loading: false, }) -export const createCachedResourceCache = (initialData: T, key: K): CachedResourceCache => ({ - data: initialData, - generation: 0, - key, - loadedAt: 0, -}) +export const createCachedResourceCache = (initialData: T, key: K): CachedResourceCache => { + let data = initialData + let generation = 0 + let inFlight: Promise | undefined + let loadedAt = 0 + let storedKey = key + + return { + clearInFlight: request => { + if (inFlight === request) { + inFlight = undefined + } + }, + getData: () => data, + getGeneration: () => generation, + getInFlight: () => inFlight, + getKey: () => storedKey, + getLoadedAt: () => loadedAt, + invalidate: nextKey => { + generation += 1 + inFlight = undefined + loadedAt = 0 + storedKey = nextKey + }, + reset: (nextData, nextKey) => { + data = nextData + generation += 1 + inFlight = undefined + loadedAt = 0 + storedKey = nextKey + }, + setDataLoaded: (nextData, requestGeneration) => { + if (generation === requestGeneration) { + data = nextData + loadedAt = Date.now() + } + }, + setInFlight: request => { + inFlight = request + }, + } +} export const getCachedResourceCache = ( map: Map>, @@ -56,8 +97,8 @@ export const getCachedResourceCache = ( export const useCachedResource = (props: Props) => { const {cache, cacheKey, enabled = true, initialData, load, onError, refreshKey, staleMs} = props const [state, setState] = React.useState>( - Object.is(cache.key, cacheKey) && cache.loadedAt - ? {data: cache.data, loaded: true, loading: false} + Object.is(cache.getKey(), cacheKey) && cache.getLoadedAt() + ? {data: cache.getData(), loaded: true, loading: false} : emptyState(initialData) ) const hasFocusedSinceMountRef = React.useRef(false) @@ -65,11 +106,7 @@ export const useCachedResource = (props: Props) => { const resetCache = React.useCallback( (nextKey: K) => { - cache.data = initialData - cache.generation += 1 - cache.inFlight = undefined - cache.key = nextKey - cache.loadedAt = 0 + cache.reset(initialData, nextKey) }, [cache, initialData] ) @@ -83,72 +120,101 @@ export const useCachedResource = (props: Props) => { [cacheKey, initialData, resetCache] ) - const loadResource = React.useEffectEvent(async (force: boolean) => { - if (!Object.is(cache.key, cacheKey)) { - requestVersionRef.current += 1 - resetCache(cacheKey) - setState(emptyState(initialData)) - } - if (!enabled) { - clear(cacheKey) - return - } - if (!force && cache.loadedAt && Date.now() - cache.loadedAt < staleMs) { - setState({data: cache.data, loaded: true, loading: false}) - return + const latestRef = React.useRef({ + cache, + cacheKey, + clear, + enabled, + initialData, + load, + onError, + resetCache, + staleMs, + }) + React.useEffect(() => { + latestRef.current = { + cache, + cacheKey, + clear, + enabled, + initialData, + load, + onError, + resetCache, + staleMs, } - const requestVersion = ++requestVersionRef.current - setState(prev => ({...prev, loading: true})) - let request: Promise | undefined - try { - if (cache.inFlight) { - const data = await cache.inFlight - if (requestVersion === requestVersionRef.current) { - setState({data, loaded: true, loading: false}) - } - return + }, [cache, cacheKey, clear, enabled, initialData, load, onError, resetCache, staleMs]) + + const loadResource = React.useCallback( + async (force: boolean) => { + const {cache, cacheKey, clear, enabled, initialData, load, onError, resetCache, staleMs} = + latestRef.current + if (!Object.is(cache.getKey(), cacheKey)) { + requestVersionRef.current += 1 + resetCache(cacheKey) + setState(emptyState(initialData)) } - const generation = cache.generation - request = load().then(data => { - if (cache.generation === generation) { - cache.data = data - cache.loadedAt = Date.now() - } - return data - }) - cache.inFlight = request - const data = await request - if (requestVersion === requestVersionRef.current) { - setState({data, loaded: true, loading: false}) + if (!enabled) { + clear(cacheKey) + return } - } catch (error) { - if (requestVersion !== requestVersionRef.current) { + const loadedAt = cache.getLoadedAt() + if (!force && loadedAt && Date.now() - loadedAt < staleMs) { + setState({data: cache.getData(), loaded: true, loading: false}) return } - onError?.(error) - setState(prev => ({...prev, loading: false})) - } finally { - if (request && cache.inFlight === request) { - cache.inFlight = undefined + const requestVersion = ++requestVersionRef.current + setState(prev => ({...prev, loading: true})) + let request: Promise | undefined + try { + const inFlight = cache.getInFlight() + if (inFlight) { + const data = await inFlight + if (requestVersion === requestVersionRef.current) { + setState({data, loaded: true, loading: false}) + } + return + } + const generation = cache.getGeneration() + request = load().then(data => { + cache.setDataLoaded(data, generation) + return data + }) + cache.setInFlight(request) + const data = await request + if (requestVersion === requestVersionRef.current) { + setState({data, loaded: true, loading: false}) + } + } catch (error) { + if (requestVersion !== requestVersionRef.current) { + return + } + onError?.(error) + setState(prev => ({...prev, loading: false})) + } finally { + if (request) { + cache.clearInFlight(request) + } } - } - }) + }, + [] + ) const reload = React.useCallback(async () => { await loadResource(true) - }, []) + }, [loadResource]) const loadIfStale = React.useCallback(async () => { await loadResource(false) - }, []) + }, [loadResource]) React.useEffect(() => { setState( - Object.is(cache.key, cacheKey) && cache.loadedAt - ? {data: cache.data, loaded: true, loading: false} + Object.is(cache.getKey(), cacheKey) && cache.getLoadedAt() + ? {data: cache.getData(), loaded: true, loading: false} : emptyState(initialData) ) - if (!Object.is(cache.key, cacheKey)) { + if (!Object.is(cache.getKey(), cacheKey)) { clear(cacheKey) } void loadIfStale() diff --git a/shared/teams/use-teams-list.tsx b/shared/teams/use-teams-list.tsx index 63e6bc27d4a8..a099e91fcc3c 100644 --- a/shared/teams/use-teams-list.tsx +++ b/shared/teams/use-teams-list.tsx @@ -44,10 +44,7 @@ const teamListToArray = (list: ReadonlyArray) => { } const invalidateCachedResource = (cache: CachedResourceCache, nextKey: K) => { - cache.generation += 1 - cache.inFlight = undefined - cache.key = nextKey - cache.loadedAt = 0 + cache.invalidate(nextKey) } export const invalidateLoadedTeams = () => { diff --git a/shared/util/use-debounce.tsx b/shared/util/use-debounce.tsx index 0d4755f92a5a..88f807a7db2d 100644 --- a/shared/util/use-debounce.tsx +++ b/shared/util/use-debounce.tsx @@ -4,6 +4,13 @@ import * as React from 'react' type AnyFunction = (...args: Array) => any type TimerID = ReturnType +type DebounceRuntime = { + lastArgs?: Parameters + lastCallTime?: number + lastResult?: ReturnType + timerID?: TimerID +} + export type DebouncedState = ((...args: Parameters) => ReturnType | undefined) & { cancel: () => void flush: () => ReturnType | undefined @@ -23,42 +30,43 @@ export function useDebouncedCallback( options?: DebounceOptions ): DebouncedState { const funcRef = React.useRef(func) - funcRef.current = func + React.useEffect(() => { + funcRef.current = func + }, [func]) + const runtimeRef = React.useRef>({}) const waitMs = normalizeWait(wait) const leading = options?.leading ?? false const trailing = options?.trailing ?? true const debounced = React.useMemo(() => { - let lastArgs: Parameters | undefined - let lastCallTime: number | undefined - let lastResult: ReturnType | undefined - let timerID: TimerID | undefined - const clearTimer = () => { - if (timerID !== undefined) { - clearTimeout(timerID) - timerID = undefined + const runtime = runtimeRef.current + if (runtime.timerID !== undefined) { + clearTimeout(runtime.timerID) + runtime.timerID = undefined } } const invoke = () => { - const args = lastArgs - lastArgs = undefined + const runtime = runtimeRef.current + const args = runtime.lastArgs + runtime.lastArgs = undefined if (!args) { - return lastResult + return runtime.lastResult } const result = funcRef.current(...args) - lastResult = result + runtime.lastResult = result return result } const remainingWait = (time: number) => { - const sinceLastCall = time - (lastCallTime ?? 0) + const sinceLastCall = time - (runtimeRef.current.lastCallTime ?? 0) return waitMs - sinceLastCall } const shouldInvoke = (time: number) => { + const {lastCallTime} = runtimeRef.current if (lastCallTime === undefined) { return true } @@ -68,11 +76,12 @@ export function useDebouncedCallback( const trailingEdge = () => { clearTimer() - if (trailing && lastArgs) { + const runtime = runtimeRef.current + if (trailing && runtime.lastArgs) { return invoke() } - lastArgs = undefined - return lastResult + runtime.lastArgs = undefined + return runtime.lastResult } const timerExpired = () => { @@ -81,44 +90,48 @@ export function useDebouncedCallback( trailingEdge() return } - timerID = setTimeout(timerExpired, remainingWait(time)) + runtimeRef.current.timerID = setTimeout(timerExpired, remainingWait(time)) } const leadingEdge = () => { - timerID = setTimeout(timerExpired, waitMs) - return leading ? invoke() : lastResult + const runtime = runtimeRef.current + runtime.timerID = setTimeout(timerExpired, waitMs) + return leading ? invoke() : runtime.lastResult } const next = ((...args: Parameters) => { const time = Date.now() const invokeNow = shouldInvoke(time) + const runtime = runtimeRef.current - lastArgs = args - lastCallTime = time + runtime.lastArgs = args + runtime.lastCallTime = time - if (invokeNow && timerID === undefined) { + if (invokeNow && runtime.timerID === undefined) { return leadingEdge() } clearTimer() - timerID = setTimeout(timerExpired, waitMs) - return lastResult + runtime.timerID = setTimeout(timerExpired, waitMs) + return runtime.lastResult }) as DebouncedState next.cancel = () => { clearTimer() - lastArgs = undefined - lastCallTime = undefined + const runtime = runtimeRef.current + runtime.lastArgs = undefined + runtime.lastCallTime = undefined } next.flush = () => { - if (timerID === undefined) { - return lastResult + const runtime = runtimeRef.current + if (runtime.timerID === undefined) { + return runtime.lastResult } return trailingEdge() } - next.isPending = () => timerID !== undefined + next.isPending = () => runtimeRef.current.timerID !== undefined return next }, [leading, trailing, waitMs]) @@ -134,82 +147,97 @@ export function useThrottledCallback( options?: DebounceOptions ): DebouncedState { const funcRef = React.useRef(func) - funcRef.current = func + React.useEffect(() => { + funcRef.current = func + }, [func]) + const runtimeRef = React.useRef<{ + lastArgs?: Parameters + lastInvokeTime?: number + lastResult?: ReturnType + timerID?: TimerID + }>({}) const waitMs = normalizeWait(wait) const leading = options?.leading ?? true const trailing = options?.trailing ?? true const throttled = React.useMemo(() => { - let lastArgs: Parameters | undefined - let lastInvokeTime: number | undefined - let lastResult: ReturnType | undefined - let timerID: TimerID | undefined - const clearTimer = () => { - if (timerID !== undefined) { - clearTimeout(timerID) - timerID = undefined + const runtime = runtimeRef.current + if (runtime.timerID !== undefined) { + clearTimeout(runtime.timerID) + runtime.timerID = undefined } } const invoke = (time: number, args: Parameters) => { - lastArgs = undefined - lastInvokeTime = time + const runtime = runtimeRef.current + runtime.lastArgs = undefined + runtime.lastInvokeTime = time const result = funcRef.current(...args) - lastResult = result + runtime.lastResult = result return result } const schedule = (time: number) => { - if (timerID !== undefined) { + const runtime = runtimeRef.current + if (runtime.timerID !== undefined) { return } const delay = - lastInvokeTime === undefined ? waitMs : Math.max(0, waitMs - (time - lastInvokeTime)) - timerID = setTimeout(() => { - timerID = undefined - if (trailing && lastArgs) { - invoke(Date.now(), lastArgs) + runtime.lastInvokeTime === undefined + ? waitMs + : Math.max(0, waitMs - (time - runtime.lastInvokeTime)) + runtime.timerID = setTimeout(() => { + const runtime = runtimeRef.current + runtime.timerID = undefined + if (trailing && runtime.lastArgs) { + invoke(Date.now(), runtime.lastArgs) } else { - lastArgs = undefined + runtime.lastArgs = undefined } }, delay) } const next = ((...args: Parameters) => { const time = Date.now() - lastArgs = args + const runtime = runtimeRef.current + runtime.lastArgs = args - if (leading && (lastInvokeTime === undefined || time - lastInvokeTime >= waitMs)) { + if ( + leading && + (runtime.lastInvokeTime === undefined || time - runtime.lastInvokeTime >= waitMs) + ) { const result = invoke(time, args) schedule(time) return result } schedule(time) - return lastResult + return runtime.lastResult }) as DebouncedState next.cancel = () => { clearTimer() - lastArgs = undefined - lastInvokeTime = undefined + const runtime = runtimeRef.current + runtime.lastArgs = undefined + runtime.lastInvokeTime = undefined } next.flush = () => { - if (timerID === undefined) { - return lastResult + const runtime = runtimeRef.current + if (runtime.timerID === undefined) { + return runtime.lastResult } clearTimer() - if (trailing && lastArgs) { - return invoke(Date.now(), lastArgs) + if (trailing && runtime.lastArgs) { + return invoke(Date.now(), runtime.lastArgs) } - lastArgs = undefined - return lastResult + runtime.lastArgs = undefined + return runtime.lastResult } - next.isPending = () => timerID !== undefined + next.isPending = () => runtimeRef.current.timerID !== undefined return next }, [leading, trailing, waitMs]) From 7be7ca382fd92643aa1d63f6b683670acb2641a5 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 24 Apr 2026 17:11:23 -0400 Subject: [PATCH 03/27] WIP --- .../conversation/info-panel/attachments.tsx | 20 +- skill/react-effect-lints/SKILL.md | 186 ++++++++++++++++++ 2 files changed, 194 insertions(+), 12 deletions(-) create mode 100644 skill/react-effect-lints/SKILL.md diff --git a/shared/chat/conversation/info-panel/attachments.tsx b/shared/chat/conversation/info-panel/attachments.tsx index e5ca28c007bb..b2f925c705ce 100644 --- a/shared/chat/conversation/info-panel/attachments.tsx +++ b/shared/chat/conversation/info-panel/attachments.tsx @@ -430,7 +430,6 @@ export const useAttachmentSections = ( useFlexWrap: boolean ): {sections: Array
} => { const [selectedAttachmentView, onSelectAttachmentView] = React.useState(T.RPCChat.GalleryItemTyp.media) - const [lastSAV, setLastSAV] = React.useState(selectedAttachmentView) const loadAttachmentView = ConvoState.useChatContext(s => s.dispatch.loadAttachmentView) const loadMessagesCentered = ConvoState.useChatContext(s => s.dispatch.loadMessagesCentered) const clearModals = C.Router2.clearModals @@ -448,17 +447,6 @@ export const useAttachmentSections = ( }, 1) }) - React.useEffect(() => { - if (lastSAV !== selectedAttachmentView) { - setLastSAV(selectedAttachmentView) - if (loadImmediately) { - setTimeout(() => { - loadAttachmentView(selectedAttachmentView) - }, 1) - } - } - }, [lastSAV, loadAttachmentView, loadImmediately, selectedAttachmentView]) - const attachmentView = ConvoState.useChatContext(s => s.attachmentViewMap) const attachmentInfo = attachmentView.get(selectedAttachmentView) const fromMsgID = attachmentInfo ? getFromMsgID(attachmentInfo) : undefined @@ -466,7 +454,15 @@ export const useAttachmentSections = ( const onLoadMore = fromMsgID ? () => loadAttachmentView(selectedAttachmentView, fromMsgID) : undefined const onAttachmentViewChange = (viewType: T.RPCChat.GalleryItemTyp) => { + if (viewType === selectedAttachmentView) { + return + } onSelectAttachmentView(viewType) + if (loadImmediately) { + setTimeout(() => { + loadAttachmentView(viewType) + }, 1) + } } const loadAttachments = () => { diff --git a/skill/react-effect-lints/SKILL.md b/skill/react-effect-lints/SKILL.md new file mode 100644 index 000000000000..3a136d159c13 --- /dev/null +++ b/skill/react-effect-lints/SKILL.md @@ -0,0 +1,186 @@ +--- +name: react-effect-lints +description: Use when fixing React hook lint failures in the Keybase client, especially `react-hooks/set-state-in-effect`, derived-state effects, prop-change reset effects, event logic hidden in effects, async stale-result effects, or requests that reference React's "You Might Not Need an Effect" guidance. +--- + +# React Effect Lints + +Use this skill to fix the cause of React effect lint errors, not to hide the diagnostic. +The default target is less state, fewer effects, and the same user-visible behavior. + +React's rule of thumb: + +- Effects synchronize React with external systems: subscriptions, timers, imperative APIs, DOM/native APIs, and async requests. +- Effects are usually wrong for transforming props/state for render, resetting state because props changed, or running logic caused by a user event. +- A fix that wraps `setState` in `setTimeout`, `Promise.resolve`, `queueMicrotask`, or a helper such as `deferEffectUpdate` is almost always a lint workaround, not a fix. + +Authoritative references: + +- React: `https://react.dev/learn/you-might-not-need-an-effect` +- React lint: `https://react.dev/reference/eslint-plugin-react-hooks/lints/set-state-in-effect` + +## Workflow + +1. Read the whole component and identify what the effect is trying to model. +2. Classify the effect before editing: + - Derived render data + - Initial state only + - Reset all state on identity change + - Adjust part of state on input change + - User-event consequence + - External synchronization or async request +3. Prefer the matching refactor pattern below. +4. Preserve existing guards, platform branches, waiting keys, route behavior, and stale async protection unless proven dead. +5. Remove now-unused imports, state, refs, helpers, styles, and type parameters. +6. In this repo, do not run `yarn`, `npm`, lint, or TypeScript unless `node_modules` exists and the user's machine guidance allows it. + +## Refactor Patterns + +### Derived Render Data + +Delete state and effects that only mirror values already available from props, store selectors, or other state. +Calculate the value during render. + +```tsx +// Avoid +const [visibleItems, setVisibleItems] = React.useState>([]) +React.useEffect(() => { + setVisibleItems(items.filter(item => item.enabled)) +}, [items]) + +// Prefer +const visibleItems = items.filter(item => item.enabled) +``` + +Use `React.useMemo` only when a calculation is demonstrably expensive or memo identity is required for compatibility. +This repo uses React Compiler, so do not add `useMemo` by default. + +### Initial State Only + +If a prop only seeds local state and later prop changes should not reset user edits, use a lazy initializer or direct initializer. +Delete effects that keep rewriting the local state from the prop. + +```tsx +const [draft, setDraft] = React.useState(() => initialDraft) +``` + +Confirm this is really "initial only"; if changing the prop should reset the form or modal, use the keyed reset pattern. + +### Reset All State On Identity Change + +When a route, username, conversation ID, team ID, or other identity means "this is a different instance", split the component and key the inner component. +This resets all descendant state before children render with stale values. + +```tsx +const Outer = (props: Props) => + +const Inner = (props: Props) => { + const [draft, setDraft] = React.useState('') + // ... +} +``` + +Use this for modals or screens whose local form state should restart when the entity changes. +Keep exported component names stable unless callers need a new export. + +### Adjust Part Of State On Input Change + +First try to store a stable ID and derive the selected object or validity during render. +This often removes the need to reset selection at all. + +```tsx +const [selectedID, setSelectedID] = React.useState() +const selected = items.find(item => item.id === selectedID) +``` + +If partial adjustment is unavoidable, React allows guarded state updates during render for the same component. +Use this sparingly, always with a previous-value guard that prevents loops, and never for side effects. + +```tsx +const [prevItems, setPrevItems] = React.useState(items) +const [selectedID, setSelectedID] = React.useState() +if (items !== prevItems) { + setPrevItems(items) + setSelectedID(undefined) +} +``` + +Do not update another component's state during render. +Move timers, navigation, RPCs, logging, and DOM/native work to events or effects. + +### User-Event Consequences + +If logic happens because a user clicked, submitted, selected, dismissed, or navigated, put that logic in the event handler or a function called by event handlers. +Do not infer the event later from a state flag in an effect. + +Good candidates: + +- Submit RPCs +- Notifications caused by a button press +- Navigation caused by a selected tab or menu item +- Clearing waiting state before starting a mutation + +Effects can still observe external completion of the event, such as waiting changing from true to false. +Track previous waiting state with a ref when needed. + +### External Synchronization And Async Requests + +Keep effects when they synchronize with an external system: + +- Timer setup and cleanup +- Subscriptions and listeners +- Imperative DOM/native APIs +- RPCs or fetches keyed by reactive inputs +- Updating external stores from a callback or subscription + +For async work, protect against stale results with a request ID or cleanup guard. +Do not start an effect with a synchronous reset just to avoid showing stale data. +Instead, tag loaded data with the input key and derive the visible value during render. + +```tsx +type Loaded = {key: string; value: Result} +const [loaded, setLoaded] = React.useState() +const visible = loaded?.key === requestKey ? loaded.value : undefined + +React.useEffect(() => { + let canceled = false + load(requestKey).then(value => { + if (!canceled) { + setLoaded({key: requestKey, value}) + } + }) + return () => { + canceled = true + } +}, [requestKey]) +``` + +Prefer request/version IDs over broad `isMounted` refs when rejecting stale async results. +If a real mount guard is required, set it true inside the effect body and false in cleanup so Strict Mode remounts do not leave it stuck false. + +### Timers And Delayed UI + +Timers are external synchronization, but state that is immediately derivable from current props should still be render-derived. +Common fixes: + +- Derive open/closed visibility from the current error or pending timer state. +- Keep cached text only when intentionally delaying removal for an animation or timeout. +- Set up and clear the timer in an effect, but avoid a synchronous "mirror prop into state" update in the effect body. + +## Keybase-Specific Checks + +- Plain `.tsx` files under `shared/` should use `Kb.*` components, not raw DOM, unless the file already has desktop-only DOM guarded by platform constraints. +- Components must not mutate Zustand stores directly with `useXState.setState` or `getState()` writes; route through dispatch actions. +- When reading multiple adjacent values from one store hook, prefer a consolidated selector with `C.useShallow(...)`. +- Keep `useEffectEvent` functions out of dependency arrays; depend on the real reactive values instead. +- Do not add lint disables. Fix the state shape, effect purpose, or dependencies. +- Do not add exported helpers unless another file needs them. + +## Common Non-Effect Lints Nearby + +Fix TypeScript cleanup errors while touching the same files: + +- Remove unused locals instead of assigning them to dummy values. +- If an export is missing, either restore the real implementation or stop exporting/importing it; do not export an undefined placeholder. +- For generic caches, preserve literal key types instead of widening them accidentally. +- If `T.*` is used as a runtime value, import `@/constants/types` as a value import, not `import type`. From 1b075199455fd494d04232bb25a9132827b41c53 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 24 Apr 2026 17:21:39 -0400 Subject: [PATCH 04/27] WIP --- plans/use-effects-lint-todo.md | 150 +++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 plans/use-effects-lint-todo.md diff --git a/plans/use-effects-lint-todo.md b/plans/use-effects-lint-todo.md new file mode 100644 index 000000000000..da47606fddee --- /dev/null +++ b/plans/use-effects-lint-todo.md @@ -0,0 +1,150 @@ +# React Effect Lint TODO + +Source log: `/Users/ChrisNojima/Downloads/temp.log`. + +Scope: only `react-hooks/set-state-in-effect` findings. Ignore `refs`, `immutability`, `purity`, `preserve-manual-memoization`, and TypeScript diagnostics unless they are directly touched by a set-state-in-effect fix. + +## Batch Rules + +- Before starting each batch, read `skill/react-effect-lints/SKILL.md` and use its workflow. +- For each lint, classify the effect first: derived render data, initial state only, identity reset, partial state adjustment, user-event consequence, or external sync/async request. +- Do not fix these by deferring the state update with `setTimeout`, `Promise.resolve`, `queueMicrotask`, `deferEffectUpdate`, or a similar wrapper. +- Preserve behavior, guards, platform branches, route params, waiting keys, and stale async protection. +- Remove unused state, refs, imports, helpers, and comments after each item. +- After each batch, update this checklist in the same change. +- On this machine, do not run `yarn`, `npm`, `yarn lint`, or `yarn tsc` because `node_modules` is unavailable. Use pure code review plus `git diff --check`. If working on a machine where repo node tooling is available and allowed, run the focused lint/type checks for the touched files. + +## Batch 1: Chat Info Panel + +- [x] `shared/chat/conversation/info-panel/attachments.tsx:453:7` - already handled with `skill/react-effect-lints`; moved attachment-view load into `onAttachmentViewChange` and removed `lastSAV`. +- [ ] `shared/chat/conversation/info-panel/index.tsx:55:5` +- [ ] `shared/chat/conversation/info-panel/members.tsx:56:7` + +## Batch 2: Chat Modals And Bot Install + +- [ ] `shared/chat/blocking/block-modal.tsx:244:7` +- [ ] `shared/chat/conversation/attachment-get-titles.tsx:122:5` +- [ ] `shared/chat/conversation/bot/install.tsx:68:5` +- [ ] `shared/chat/conversation/bot/install.tsx:242:5` + +## Batch 3: Chat Input And Inbox State + +- [ ] `shared/chat/conversation/input-area/normal/index.tsx:264:5` +- [ ] `shared/chat/conversation/input-area/normal/input.native.tsx:233:5` +- [ ] `shared/chat/inbox/use-inbox-state.tsx:45:5` +- [ ] `shared/chat/user-emoji.tsx:34:7` + +## Batch 4: Chat Message Wrappers And Timers + +- [ ] `shared/chat/conversation/list-area/index.desktop.tsx:662:9` +- [ ] `shared/chat/conversation/messages/wrapper/exploding-height-retainer/index.desktop.tsx:20:7` +- [ ] `shared/chat/conversation/messages/wrapper/exploding-height-retainer/index.native.tsx:135:7` +- [ ] `shared/chat/conversation/messages/wrapper/exploding-meta.tsx:82:7` +- [ ] `shared/chat/conversation/messages/wrapper/exploding-meta.tsx:105:9` +- [ ] `shared/chat/conversation/messages/wrapper/send-indicator.tsx:86:7` + +## Batch 5: Chat Conversation Container And Search + +- [ ] `shared/chat/conversation/normal/container.tsx:40:5` +- [ ] `shared/chat/conversation/normal/container.tsx:79:9` +- [ ] `shared/chat/conversation/search.tsx:254:5` +- [ ] `shared/chat/conversation/search.tsx:264:5` +- [ ] `shared/chat/conversation/team-hooks.tsx:290:10` +- [ ] `shared/chat/conversation/team-hooks.tsx:373:10` + +## Batch 6: Common Adapter Components + +- [ ] `shared/common-adapters/choice-list.native.tsx:16:5` +- [ ] `shared/common-adapters/copy-text.tsx:97:11` +- [ ] `shared/common-adapters/phone-input.tsx:198:7` +- [ ] `shared/common-adapters/phone-input.tsx:388:7` +- [ ] `shared/common-adapters/popup/floating-box/index.desktop.tsx:18:5` +- [ ] `shared/common-adapters/popup/floating-box/relative-floating-box.desktop.tsx:284:7` +- [ ] `shared/common-adapters/save-indicator.tsx:35:9` +- [ ] `shared/common-adapters/toast.native.tsx:33:7` +- [ ] `shared/common-adapters/zoomable-image.desktop.tsx:46:7` + +## Batch 7: Desktop And Remote Surfaces + +- [ ] `shared/desktop/remote/remote-component.desktop.tsx:41:5` +- [ ] `shared/menubar/remote-proxy.desktop.tsx:281:7` +- [ ] `shared/pinentry/remote-proxy.desktop.tsx:78:7` + +## Batch 8: Files, Devices, Git, Incoming Share, Wallets + +- [ ] `shared/devices/device-revoke.tsx:144:5` +- [ ] `shared/fs/browser/rows/editing.tsx:27:5` +- [ ] `shared/fs/common/hooks.tsx:884:7` +- [ ] `shared/fs/common/hooks.tsx:1040:7` +- [ ] `shared/fs/common/use-files-tab-upload-icon.tsx:63:7` +- [ ] `shared/fs/footer/upload.desktop.tsx:18:7` +- [ ] `shared/fs/footer/use-upload-countdown.tsx:72:11` +- [ ] `shared/git/index.tsx:141:5` +- [ ] `shared/incoming-share/index.tsx:309:5` +- [ ] `shared/wallets/really-remove-account.tsx:33:5` + +## Batch 9: Login, Profile, Provision + +- [ ] `shared/login/relogin/container.tsx:68:5` +- [ ] `shared/login/relogin/container.tsx:73:7` +- [ ] `shared/profile/add-to-team.tsx:181:5` +- [ ] `shared/profile/generic/proofs-list.tsx:680:5` +- [ ] `shared/profile/generic/proofs-list.tsx:684:5` +- [ ] `shared/profile/generic/proofs-list.tsx:761:5` +- [ ] `shared/profile/use-proof-suggestions.tsx:100:5` +- [ ] `shared/provision/code-page/container.tsx:71:5` + +## Batch 10: Settings And Signup + +- [ ] `shared/router-v2/account-switcher/index.tsx:142:7` +- [ ] `shared/settings/account/index.tsx:208:5` +- [ ] `shared/settings/account/index.tsx:215:5` +- [ ] `shared/settings/account/index.tsx:222:5` +- [ ] `shared/settings/account/index.tsx:231:7` +- [ ] `shared/settings/chat.tsx:199:7` +- [ ] `shared/settings/files/hooks.tsx:38:21` +- [ ] `shared/settings/proxy.tsx:123:9` +- [ ] `shared/signup/device-name.tsx:139:7` + +## Batch 11: Teams Entry Forms And Permissions + +- [ ] `shared/teams/add-members-wizard/confirm.tsx:49:5` +- [ ] `shared/teams/add-members-wizard/confirm.tsx:301:5` +- [ ] `shared/teams/channel/create-channels.tsx:21:5` +- [ ] `shared/teams/channel/header.tsx:15:5` +- [ ] `shared/teams/common/enable-contacts.tsx:16:5` +- [ ] `shared/teams/common/use-contacts.native.tsx:93:7` +- [ ] `shared/teams/common/use-contacts.native.tsx:123:7` +- [ ] `shared/teams/emojis/add-alias.tsx:47:26` + +## Batch 12: Teams Loading And Navigation + +- [ ] `shared/teams/external-team.tsx:22:5` +- [ ] `shared/teams/join-team/container.tsx:45:5` +- [ ] `shared/teams/join-team/join-from-invite.tsx:45:5` +- [ ] `shared/teams/new-team/wizard/new-team-info.tsx:65:7` +- [ ] `shared/teams/role-picker.tsx:270:5` +- [ ] `shared/teams/team/index.tsx:103:5` +- [ ] `shared/teams/team/member/index.new.tsx:109:5` +- [ ] `shared/teams/team/rows/index.tsx:294:5` +- [ ] `shared/teams/team/rows/index.tsx:365:7` + +## Batch 13: Teams Settings And Cached Resource + +- [ ] `shared/teams/team/settings-tab/default-channels.tsx:67:19` +- [ ] `shared/teams/team/settings-tab/index.tsx:439:5` +- [ ] `shared/teams/team/settings-tab/retention/index.tsx:63:7` +- [ ] `shared/teams/team/settings-tab/retention/index.tsx:105:11` +- [ ] `shared/teams/team/settings-tab/retention/index.tsx:118:9` +- [ ] `shared/teams/team/settings-tab/retention/index.tsx:475:10` +- [ ] `shared/teams/team/team-info.tsx:75:5` +- [ ] `shared/teams/team/team-info.tsx:79:5` +- [ ] `shared/teams/use-cached-resource.tsx:146:5` + +## Batch 14: Utilities And App Global Error + +- [ ] `shared/app/global-errors.tsx:76:5` +- [ ] `shared/util/featured-bots.tsx:41:7` +- [ ] `shared/util/featured-bots.tsx:91:7` +- [ ] `shared/util/phone-numbers/index.tsx:236:7` +- [ ] `shared/util/use-intersection-observer.desktop.tsx:44:5` From a661823bfd26ea72d0760b4162aea3f116044314 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 24 Apr 2026 17:26:05 -0400 Subject: [PATCH 05/27] WIP --- plans/use-effects-lint-todo.md | 5 ++-- shared/chat/conversation/info-panel/index.tsx | 7 +++--- .../chat/conversation/info-panel/members.tsx | 23 ++++++++++--------- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/plans/use-effects-lint-todo.md b/plans/use-effects-lint-todo.md index da47606fddee..58bd9922113f 100644 --- a/plans/use-effects-lint-todo.md +++ b/plans/use-effects-lint-todo.md @@ -9,6 +9,7 @@ Scope: only `react-hooks/set-state-in-effect` findings. Ignore `refs`, `immutabi - Before starting each batch, read `skill/react-effect-lints/SKILL.md` and use its workflow. - For each lint, classify the effect first: derived render data, initial state only, identity reset, partial state adjustment, user-event consequence, or external sync/async request. - Do not fix these by deferring the state update with `setTimeout`, `Promise.resolve`, `queueMicrotask`, `deferEffectUpdate`, or a similar wrapper. +- Do not trade a `react-hooks/set-state-in-effect` fix for another React lint violation. Avoid mutating refs during render, doing side effects during render, adding unguarded render-time `setState`, breaking hook dependency rules, or silencing React lint rules. - Preserve behavior, guards, platform branches, route params, waiting keys, and stale async protection. - Remove unused state, refs, imports, helpers, and comments after each item. - After each batch, update this checklist in the same change. @@ -17,8 +18,8 @@ Scope: only `react-hooks/set-state-in-effect` findings. Ignore `refs`, `immutabi ## Batch 1: Chat Info Panel - [x] `shared/chat/conversation/info-panel/attachments.tsx:453:7` - already handled with `skill/react-effect-lints`; moved attachment-view load into `onAttachmentViewChange` and removed `lastSAV`. -- [ ] `shared/chat/conversation/info-panel/index.tsx:55:5` -- [ ] `shared/chat/conversation/info-panel/members.tsx:56:7` +- [x] `shared/chat/conversation/info-panel/index.tsx:55:5` - moved the prop tab correction to a guarded render update so the prop-provided tab still wins without an effect. +- [x] `shared/chat/conversation/info-panel/members.tsx:56:7` - replaced last-team-name state with a ref that gates the refresh RPC. ## Batch 2: Chat Modals And Bot Install diff --git a/shared/chat/conversation/info-panel/index.tsx b/shared/chat/conversation/info-panel/index.tsx index de2d3e6cf694..874fbf5d175d 100644 --- a/shared/chat/conversation/info-panel/index.tsx +++ b/shared/chat/conversation/info-panel/index.tsx @@ -27,7 +27,7 @@ const InfoPanelConnector = (ownProps: Props) => { const teamname = meta.teamname const {role: yourRole} = useChatTeam(meta.teamID, teamname) - const [selectedTab, onSelectTab] = React.useState(initialTab ?? 'members') + const [selectedTab, onSelectTab] = React.useState(initialTab ?? 'members') const [lastSNO, setLastSNO] = React.useState(shouldNavigateOut) const showInfoPanel = ConvoState.useChatContext(s => s.dispatch.showInfoPanel) @@ -50,10 +50,9 @@ const InfoPanelConnector = (ownProps: Props) => { } } - React.useEffect(() => { - if (ownProps.tab === undefined || ownProps.tab === selectedTab) return + if (ownProps.tab !== undefined && ownProps.tab !== selectedTab) { onSelectTab(ownProps.tab) - }, [ownProps.tab, selectedTab]) + } const getTabs = (): Array> => { const showSettings = !isPreview || Teams.isAdmin(yourRole) || Teams.isOwner(yourRole) diff --git a/shared/chat/conversation/info-panel/members.tsx b/shared/chat/conversation/info-panel/members.tsx index 8913bd958146..2a7c9f829513 100644 --- a/shared/chat/conversation/info-panel/members.tsx +++ b/shared/chat/conversation/info-panel/members.tsx @@ -50,19 +50,20 @@ const MembersTab = (props: Props) => { const participants = ConvoState.useChatContext( C.useShallow(s => getBotsAndParticipants(s.meta, s.participants, teamMembers).participants) ) - const [lastTeamName, setLastTeamName] = React.useState('') + const lastTeamNameRef = React.useRef('') React.useEffect(() => { - if (lastTeamName !== teamname) { - setLastTeamName(teamname) - if (teamname) { - refreshParticipants( - [{convID: T.Chat.keyToConversationID(conversationIDKey)}], - () => {}, - () => {} - ) - } + if (lastTeamNameRef.current === teamname) { + return + } + lastTeamNameRef.current = teamname + if (teamname) { + refreshParticipants( + [{convID: T.Chat.keyToConversationID(conversationIDKey)}], + () => {}, + () => {} + ) } - }, [conversationIDKey, lastTeamName, refreshParticipants, teamname]) + }, [conversationIDKey, refreshParticipants, teamname]) const showSpinner = !participants.length const participantsItems = participants From 7ccc4004cdec40968e6ee8bfd2bc52b8a4ffd5ac Mon Sep 17 00:00:00 2001 From: chrisnojima-zoom <83838430+chrisnojima-zoom@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:32:53 -0400 Subject: [PATCH 06/27] module cleanup (#29175) * FS clean (#29176) --- AGENTS.md | 1 + plans/fs-cleanup.md | 179 -- plans/init-small-cleanup.md | 97 + plans/module-level-cleanup.md | 125 -- .../conversation/attachment-get-titles.tsx | 28 +- shared/chat/conversation/team-hooks.tsx | 159 +- shared/chat/send-to-chat/index.tsx | 3 - shared/constants/fs.tsx | 26 +- shared/constants/init/shared.tsx | 28 +- shared/constants/remote-actions.tsx | 4 - shared/constants/types/fs.tsx | 7 - .../renderer/remote-event-handler.desktop.tsx | 7 +- shared/fs/banner/conflict-banner.tsx | 31 +- shared/fs/banner/public-reminder.tsx | 4 +- shared/fs/banner/reset-banner.tsx | 15 +- .../container.tsx | 6 +- .../kext-permission-popup.tsx | 4 +- shared/fs/browser/destination-picker.tsx | 103 +- shared/fs/browser/edit-state.tsx | 301 +++ shared/fs/browser/index.tsx | 25 +- shared/fs/browser/offline.tsx | 5 +- shared/fs/browser/root.tsx | 4 +- shared/fs/browser/rows/common.tsx | 4 +- shared/fs/browser/rows/editing.tsx | 37 +- shared/fs/browser/rows/rows-container.tsx | 147 +- shared/fs/browser/rows/rows.tsx | 10 +- shared/fs/browser/rows/still.tsx | 16 +- shared/fs/browser/rows/tlf.tsx | 5 +- shared/fs/browser/rows/types.tsx | 6 +- shared/fs/browser/sort-state.tsx | 57 + shared/fs/common/error-state.tsx | 124 + shared/fs/common/errs-container.tsx | 17 +- shared/fs/common/folder-view-filter-icon.tsx | 4 +- shared/fs/common/folder-view-filter.tsx | 4 +- shared/fs/common/hooks.tsx | 1137 +++++++++- shared/fs/common/index.tsx | 2 + shared/fs/common/item-icon.tsx | 16 +- shared/fs/common/kbfs-path.tsx | 30 +- shared/fs/common/last-modified-line.tsx | 5 +- .../fs/common/open-in-system-file-manager.tsx | 4 +- .../common/path-item-action/choose-view.tsx | 5 +- .../path-item-action/confirm-delete.tsx | 20 +- shared/fs/common/path-item-action/confirm.tsx | 23 +- shared/fs/common/path-item-action/index.tsx | 15 +- .../path-item-action/menu-container.tsx | 106 +- shared/fs/common/path-item-action/types.d.ts | 6 + shared/fs/common/path-item-info.tsx | 21 +- .../fs/common/path-status-icon-container.tsx | 26 +- shared/fs/common/rpc-state.tsx | 324 +++ shared/fs/common/tlf-info-line-container.tsx | 4 +- shared/fs/common/upload-button.tsx | 9 +- .../fs/common/use-files-tab-upload-icon.tsx | 112 + .../common/use-non-folder-syncing-paths.tsx | 117 + shared/fs/common/use-open.tsx | 8 +- shared/fs/filepreview/bare-preview.tsx | 12 +- shared/fs/filepreview/default-view.tsx | 15 +- shared/fs/filepreview/view.tsx | 12 +- shared/fs/footer/download.tsx | 10 +- shared/fs/footer/upload-container.tsx | 14 +- shared/fs/index.tsx | 31 +- shared/fs/nav-header/actions.tsx | 18 +- shared/fs/nav-header/main-banner.tsx | 22 +- shared/fs/nav-header/mobile-header.tsx | 12 +- shared/fs/nav-header/title.tsx | 11 +- shared/fs/top-bar/loading.tsx | 18 +- shared/fs/top-bar/sort.tsx | 28 +- shared/fs/top-bar/sync-toggle.tsx | 41 +- shared/menubar/index.desktop.tsx | 14 +- shared/menubar/remote-proxy.desktop.tsx | 122 +- shared/router-v2/tab-bar.desktop.tsx | 9 +- shared/settings/advanced.tsx | 9 +- shared/settings/archive/modal.tsx | 6 +- shared/settings/files/hooks.tsx | 86 +- shared/settings/files/index.desktop.tsx | 13 +- shared/settings/files/index.native.tsx | 28 +- shared/settings/files/refresh-settings.tsx | 14 - shared/stores/fs-platform.d.ts | 1 - shared/stores/fs-platform.desktop.tsx | 20 +- shared/stores/fs-platform.native.tsx | 1 - shared/stores/fs.tsx | 1997 ++++------------- shared/stores/shell.tsx | 11 +- shared/stores/tests/fs.test.ts | 224 +- shared/util/fs-storeless-actions.tsx | 18 +- 83 files changed, 3570 insertions(+), 2830 deletions(-) delete mode 100644 plans/fs-cleanup.md create mode 100644 plans/init-small-cleanup.md delete mode 100644 plans/module-level-cleanup.md create mode 100644 shared/fs/browser/edit-state.tsx create mode 100644 shared/fs/browser/sort-state.tsx create mode 100644 shared/fs/common/error-state.tsx create mode 100644 shared/fs/common/rpc-state.tsx create mode 100644 shared/fs/common/use-files-tab-upload-icon.tsx create mode 100644 shared/fs/common/use-non-folder-syncing-paths.tsx delete mode 100644 shared/settings/files/refresh-settings.tsx diff --git a/AGENTS.md b/AGENTS.md index d84bc62c67e9..5d13a9cf791e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,7 @@ - This repo uses React Compiler. Assume React Compiler patterns are enabled when editing React code, and avoid adding `useMemo`/`useCallback` by default unless they are clearly needed for correctness or compatibility with existing code. - Functions returned from `React.useEffectEvent(...)` are special stable event functions, not normal callback dependencies. Do not include them in dependency arrays; instead, depend on the real reactive values around the effect/callback. +- Do not read or write `ref.current` during render. Access refs only from effects, event handlers, or other post-render code; if render needs to know whether async data is current, store a request key/version in state or derive it from props/state. - Treat React mount/unmount effects as Strict-Mode-safe. Do not assume a component only mounts once; route-driven async startup and cleanup logic must be idempotent and must not leave refs or guards stuck false after a dev remount. - Do not add `mountedRef`/`isMountedRef` guards just to suppress local React state updates after unmount; those updates are already a no-op in modern React. Only use a guard when you need to reject stale async results or protect a real side effect, and prefer request/version guards when they express the intent more directly. - If a mount guard is truly needed, set the ref to `true` inside the effect body and set it to `false` in cleanup. Never rely on `useRef(true)` alone across the component lifetime, because Strict Mode remounts can leave the guard stuck `false` and silently drop async results. diff --git a/plans/fs-cleanup.md b/plans/fs-cleanup.md deleted file mode 100644 index c79052880b29..000000000000 --- a/plans/fs-cleanup.md +++ /dev/null @@ -1,179 +0,0 @@ -# FS Store Cleanup - -Reference skill: `skill/zustand-store-pruning/SKILL.md` - -## Summary - -Shrink `shared/stores/fs.tsx` by moving path-owned view data, path action state, edit state, and service-backed convenience caches out of Zustand where mounted screens can load directly from the service. - -The end state is: - -- mounted FS screens load path, TLF, and metadata through feature hooks instead of reading a warmed global cache -- path action menus, inline rename/new-folder rows, and screen-local errors live with the owning route or component -- subscription and engine plumbing only stays global where it must support true background state -- the remaining Zustand surface is limited to data that genuinely needs app-wide lifetime - -Assumption for this plan: local service RPCs are cheap enough that we prefer reloading on mount/focus over preserving most frontend convenience caches. - -## Guiding Rules - -- Do not keep a store cache just to avoid a small number of local KBFS RPCs -- Prefer feature hooks such as `useFsPath(...)`, `useFsTlf(...)`, and `useFsChildren(...)` over raw store selectors -- Keep route-owned UI state out of the global store -- Prefer direct router calls and local `C.useRPC(...)` usage over FS-store wrapper actions when the result only affects the mounted screen -- Do not introduce module-level mutable state as a replacement for Zustand - - no module-level caches - - no module-level listener registries - - no module-level in-flight request maps - - no module-level `useSyncExternalStore(...)` stores backed by module variables - - if a route needs shared loaded data, use a feature-local provider instead -- Keep behavior intact while changing ownership of state -- Slice-by-slice migrations must preserve current functionality; if a mounted FS view previously updated live while visible, move that refresh/subscription behavior into the new hook/provider/listener in the same slice instead of deferring it - -## Chunk 1: Define Path / TLF Data Hooks - -- [ ] Introduce a small service-backed FS hook layer for mounted consumers - - `useFsPathItem(path)` - - `useFsFolderChildren(path, options?)` - - `useFsTlf(path)` - - `useFsPathMetadata(path)` - - `useFsPathInfo(path)` - - `useFsFileContext(path)` -- [ ] Make these hooks own reload-on-mount/focus behavior instead of relying on a globally warmed `pathItems` / `tlfs` cache -- [ ] Keep outputs narrow and purpose-built for FS consumers - - current path item - - children for the visible folder - - current TLF metadata / sync config - - file preview context - - path info / soft error for the current path -- [ ] Do not add a new hidden module-level cache; if a route needs shared data, use a feature-local provider - -### Target callers for Chunk 1 - -- [ ] `fs/browser/*` -- [ ] `fs/filepreview/*` -- [ ] `fs/nav-header/*` -- [ ] `fs/common/path-*` -- [ ] `fs/common/item-icon.tsx` -- [ ] `fs/common/upload-button.tsx` - -### Store fallout after Chunk 1 - -- `pathItems` reads from mounted FS views -- `tlfs` reads from mounted FS views -- `pathInfos` reads from mounted FS views -- `fileContext` reads from mounted FS views -- `softErrors` reads from mounted FS views - -## Chunk 2: Remove Path-Action and Inline-Edit UI State - -- [ ] Move `pathItemActionMenu` state into the path action menu flow -- [ ] Move `edits` state into the owning folder / row UI -- [ ] Replace store-owned rename/new-folder orchestration with local `C.useRPC(...)` calls or feature-local hooks -- [ ] Keep error, waiting, and temporary filename state local to the mounted editor or menu - -### Files likely to move together - -- [ ] `fs/common/path-item-action/*` -- [ ] `fs/browser/rows/editing.tsx` -- [ ] `fs/browser/rows/rows-container.tsx` -- [ ] `fs/common/path-item-action/confirm*.tsx` - -### Store fallout after Chunk 2 - -- `pathItemActionMenu` -- `edits` -- `startRename` -- `newFolderRow` -- `setPathItemActionMenuDownload` -- any edit-only error bookkeeping currently tied to `edits` - -## Chunk 3: Remove Route-Owned FS Screen State and Convenience Caches - -- [ ] Replace list and preview screens with route-owned loaders rather than shared FS cache reads -- [ ] Move per-path sort / filter / local view preferences out of the global store unless they must survive unrelated entry points -- [ ] Re-evaluate whether `pathUserSettings` should become route-local, persisted separately, or remain small global preference state -- [ ] Move screen-local redbars / errors out of the store where they only serve the current route -- [ ] Replace `loadAdditionalTlf`, `favoritesLoad`, `folderListLoad`, `loadPathMetadata`, and similar callers with feature hooks where possible - -### Screens and flows to convert - -- [ ] `fs/index.tsx` -- [ ] `fs/browser/index.tsx` -- [ ] `fs/browser/root.tsx` -- [ ] `fs/browser/rows/*` -- [ ] `fs/top-bar/*` -- [ ] `fs/nav-header/*` -- [ ] `fs/common/hooks.tsx` -- [ ] `fs/common/errs-container.tsx` - -### Store fallout after Chunk 3 - -- `pathUserSettings` if moved out -- `errors` if route-local -- more `pathItems` / `tlfs` / `pathInfos` / `fileContext` cache usage - -## Chunk 4: Re-evaluate Subscription and Notification Ownership - -- [ ] Stop treating `fs` as the default owner for mounted-screen refreshes -- [ ] Keep mounted path subscriptions near the owning hooks instead of centering all path refresh behavior in the store -- [ ] Re-evaluate store-owned handling for: - - `keybase.1.NotifyFS.FSSubscriptionNotifyPath` - - `keybase.1.NotifyFS.FSSubscriptionNotify` - - `keybase.1.NotifyFS.FSOverallSyncStatusChanged` - - `keybase.1.NotifyBadges.badgeState` -- [ ] Preserve only the parts that truly need background lifetime - - file-tab badge - - overall sync status banner state - - download status - - upload / journal status - - daemon online / rpc status - - settings / SFMI integration state - -### Store fallout after Chunk 4 - -- `onPathChange` -- path-refresh parts of `onSubscriptionNotify` -- mounted-view portions of `onEngineIncomingImpl` -- some `subscribePath` / `subscribeNonPath` ownership if moved into feature hooks - -## Chunk 5: Decide What Stays Global - -- [ ] Review what remains in `shared/stores/fs.tsx` -- [ ] Delete dead selectors, helpers, and tests -- [ ] Keep only state that still clearly needs app-wide lifetime - -### Likely candidates to keep global - -- `downloads` -- `uploads` -- `kbfsDaemonStatus` -- `overallSyncStatus` -- `settings` -- `sfmi` -- `badge` -- possibly `criticalUpdate` - -### Likely candidates to remove by the end - -- `pathItems` -- `pathInfos` -- `fileContext` -- `tlfs` -- `tlfUpdates` -- `edits` -- `pathItemActionMenu` -- maybe `pathUserSettings` -- maybe `errors` -- most mounted-view refresh logic in `dispatch` - -## Validation - -- [ ] FS browser still loads folders and metadata correctly on entry -- [ ] File preview still loads `fileContext` and download/share flows correctly -- [ ] Rename and new-folder flows still work without global edit state -- [ ] Path action menu still handles save/share/download/open flows correctly -- [ ] Favorites and TLF views still refresh correctly after mutation -- [ ] Download and upload banners still work globally across routes -- [ ] Sync status, disk-space warnings, daemon status, and SFMI banners still work -- [ ] No new module-level mutable cache is introduced as a replacement for Zustand diff --git a/plans/init-small-cleanup.md b/plans/init-small-cleanup.md new file mode 100644 index 000000000000..1c8f28e883d3 --- /dev/null +++ b/plans/init-small-cleanup.md @@ -0,0 +1,97 @@ +# Init Subscription Small Cleanup + +## Summary + +Clean up the app-wide Zustand side-effect wiring in `shared/constants/init/shared.tsx` without changing behavior. + +The end state is: + +- `initSharedSubscriptions` reads as a registry of named effects +- each subscription reacts to a narrow selected value instead of inspecting a whole store callback +- HMR cleanup behavior stays centralized and unchanged +- app-lifetime side effects remain outside React component lifetimes +- stores do not gain new cross-store imports + +This is intentionally a small cleanup plan. It does not move engine incoming routing, rewrite startup, or change store ownership. + +## Guiding Rules + +- Preserve current behavior exactly +- Keep app bootstrap and cross-store orchestration explicit in `constants/init` +- Use store subscriptions for daemon/login/navigation side effects that have app lifetime +- Prefer named handlers over inline multi-branch subscription bodies +- Prefer narrow subscription helpers over full-store `s` / `old` comparisons +- Keep `_sharedUnsubs` as the single shared HMR cleanup list +- Do not move these effects into mounted React components unless the effect is truly owned by a mounted screen +- Do not make stores import and drive each other directly +- Remove dead imports, styles, helpers, and comments created by the cleanup + +## Chunk 1: Add A Small Subscription Helper + +- [ ] Add a file-local helper near `initSharedSubscriptions` that subscribes to a selected value and invokes a handler only when that value changes +- [ ] Keep the helper local to `shared.tsx` for this pass unless another init file immediately needs it +- [ ] Use strict identity comparison by default, matching the current `s.field !== old.field` behavior +- [ ] Return the underlying unsubscribe so callers can keep using `_sharedUnsubs.push(...)` + +Example target shape: + +```ts +const subscribeValue = ( + store: {subscribe: (listener: (state: State, prevState: State) => void) => () => void}, + select: (state: State) => Value, + onChange: (value: Value, previous: Value) => void +) => + store.subscribe((state, previousState) => { + const value = select(state) + const previous = select(previousState) + if (value !== previous) { + onChange(value, previous) + } + }) +``` + +## Chunk 2: Extract Config Effects Into Named Handlers + +- [ ] Split the config subscription into separate named handlers for: + - load-on-start phase reaching `startupOrReloginButNotInARush` + - gregor reachability becoming `Reachable.yes` + - installer run count changes + - login state changes + - revoked trigger changes + - configured accounts changes +- [ ] Keep the existing startup behavior intact: follower info request, server config update, contact import setting load, and non-phone chat bootstrap refresh +- [ ] Keep login behavior intact: bootstrap status load, KBFS daemon status check, signup draft cleanup, configured account loading, and account refresh rules +- [ ] Keep configured account mirroring into the users store intact +- [ ] Move nested one-off functions out of the subscription body only when it makes the handler easier to scan + +## Chunk 3: Extract Daemon, Shell, Provision, And Router Effects + +- [ ] Convert the shell `active` subscription into a named `onUserActiveChanged` style handler +- [ ] Split daemon reactions into named handlers for: + - handshake version changes + - bootstrap status changes + - handshake state reaching `done` +- [ ] Keep bootstrap status fanout unchanged: current user bootstrap, default username, login state, HTTP server info, and user reacjis +- [ ] Convert the provision trigger subscription into a named handler while preserving the logout-before-provision behavior +- [ ] Convert the router nav-state subscription into a named route-change handler while preserving: + - team-building cancellation when leaving team-builder screens + - FS critical update clearing when leaving the FS tab + - FS `userIn` / `userOut` transitions + - team nav badge clearing + - conversation route-change forwarding + +## Chunk 4: Keep The Init Boundary Clear + +- [ ] Leave `_onEngineIncoming` behavior out of this cleanup unless a trivial local extraction is needed for readability +- [ ] Do not introduce a new global event bus or module-level mutable cache +- [ ] Do not add `subscribeWithSelector` middleware in this pass unless the local helper proves insufficient +- [ ] If a helper becomes useful in desktop or native init files too, move it to a small shared init utility in a separate follow-up + +## Validation + +- [ ] Review the diff to confirm subscription triggers are identical to the previous field comparisons +- [ ] Confirm `_sharedUnsubs` still receives every shared subscription unsubscribe +- [ ] Confirm HMR cleanup still unsubscribes before re-subscribing +- [ ] Confirm no stores gained new imports from other stores solely for side effects +- [ ] Confirm no React component lifetime now owns app-wide daemon/login/navigation behavior +- [ ] If node tooling is available on another machine, run `yarn lint` and `yarn tsc` from `shared/` diff --git a/plans/module-level-cleanup.md b/plans/module-level-cleanup.md deleted file mode 100644 index d3fc03c3a852..000000000000 --- a/plans/module-level-cleanup.md +++ /dev/null @@ -1,125 +0,0 @@ -# Module-Level Cleanup - -Reference skill: `skill/zustand-store-pruning/SKILL.md` - -## Summary - -Remove module-scope mutable state introduced during the Zustand cleanup series. - -For this repo cleanup, treat all module-level mutable coordination as out of bounds: - -- no module-level caches -- no module-level in-flight request maps -- no module-level listener registries -- no module-level popup handler singletons -- no `useSyncExternalStore(...)` snapshots backed by module variables - -If state truly needs app-wide lifetime, keep it in an explicit store and document why. If it only needs route lifetime, move it into a feature-local provider or the owning mounted component. - -## Current Targets - -### 1. Chat Team Hooks - -- [ ] `shared/chat/conversation/team-hooks.tsx` -- Current module-level mutable state to remove: - - `chosenChannelsStoreState` - - `chosenChannelsInFlight` - - `chosenChannelsLoadedAt` - - `chosenChannelsListeners` - - `chatTeamnameVersion` - - `chatTeamnameCache` - - `chatTeamnameRequests` - - `chatTeamnameVersions` - - `chatTeamHooksUsername` -- Current hidden-store behaviors: - - chosen-channels badge state is implemented as a module-level external store - - teamname lookups are cached and deduped in module scope - - per-user resets are coordinated through a module-scope username sentinel - -### 2. Pinentry Remote Popup Bridge - -- [x] `shared/pinentry/desktop-popup-handles.tsx` -- [x] integration in `shared/pinentry/remote-proxy.desktop.tsx` -- [x] integration in `shared/desktop/renderer/remote-event-handler.desktop.tsx` -- Current module-level mutable state to remove: - - `handlers` - - `resetListeners` -- Current hidden-store behaviors: - - remote window actions are routed through a singleton callback registry - - popup reset coordination is routed through a module listener set - -### 3. Tracker Remote Popup Bridge - -- [x] `shared/tracker/desktop-popup-handles.tsx` -- [x] integration in `shared/tracker/remote-proxy.desktop.tsx` -- [x] integration in `shared/desktop/renderer/remote-event-handler.desktop.tsx` -- Current module-level mutable state to remove: - - `handlers` -- Current hidden-store behaviors: - - remote window actions are routed through a singleton callback registry - -## Guiding Rules - -- Do not replace a Zustand store with a hidden module-scope store. -- Do not use module-level `Map`, `Set`, `let`, or listener collections for feature state, request dedupe, or cross-component coordination. -- If multiple mounted descendants on one route need shared loaded data, use a feature-local provider. -- If a remote window flow needs cross-file coordination, route it through an explicit owner with a documented lifetime, not a bare module singleton. -- Keep request-version guards local with `useRef(...)` inside the owning hook or component. -- When a state owner changes, keep its reset path equally explicit. Do not rely on a module resetter to clean up hidden state later. - -## Chunk 1: Remove Hidden Chat Team Stores - -- [ ] Split `team-hooks.tsx` into provider-owned state and pure helper functions -- [ ] Move chosen-channels badge state into a mounted owner - - likely a conversation/team feature provider - - no module-level `useSyncExternalStore(...)` snapshot -- [ ] Move teamname loading out of module cache state - - either provider-owned loaded state - - or direct per-consumer loads if duplication is acceptable -- [ ] Remove the module-level per-user reset sentinel - - user changes should reset provider/local state through React ownership, not `chatTeamHooksUsername` -- [ ] Delete all chosen-channels and teamname module variables after consumers are moved - -### Validation for Chunk 1 - -- [ ] chat info panel still renders permissions and team metadata correctly -- [ ] manage-channels badge still reloads and dismisses correctly -- [ ] teamname lookups still refresh on team rename / exit / delete -- [ ] no module-level mutable state remains in `chat/conversation/team-hooks.tsx` - -## Chunk 2: Remove Pinentry Singleton Routing - -- [x] Delete `shared/pinentry/desktop-popup-handles.tsx` -- [x] Rework remote action routing so pinentry submit/cancel reaches an explicit mounted owner -- [x] Keep popup state and callback ownership in one place -- [x] Remove module-level reset listener plumbing -- [x] Replace singleton-specific tests with owner-scoped behavior tests - -### Design constraint for Chunk 2 - -- The replacement must not use another module-level singleton such as a new handle map, listener set, or flow registry. -- If app-wide lifetime is genuinely required, use an explicit existing store/action seam and document why that lifetime is necessary. - -## Chunk 3: Remove Tracker Singleton Routing - -- [x] Delete `shared/tracker/desktop-popup-handles.tsx` -- [x] Rework remote action routing so tracker follow/ignore/close/load reaches an explicit mounted owner -- [x] Keep popup state and remote-action handling owned by the tracker popup feature -- [x] Replace singleton-specific tests with owner-scoped behavior tests - -### Design constraint for Chunk 3 - -- The replacement must not use another module-level singleton such as a new handle map, listener set, or flow registry. -- If app-wide lifetime is genuinely required, use an explicit existing store/action seam and document why that lifetime is necessary. - -## Chunk 4: Sweep Plans And Follow-Up Work - -- [x] Update the active cleanup plans when a module-level singleton is removed -- [x] Re-scan changed files for module-scope mutable state before landing follow-up cleanup work -- [ ] Reject any new cleanup patch that reintroduces hidden module-level state as a shortcut - -## Validation - -- [ ] `rg -n "^(let |const .* = new (Map|Set)\\(|const .*Listeners = new Set\\()"` -- [ ] No branch-introduced cleanup code relies on module-level mutable state for feature ownership -- [ ] Any remaining module-level mutable state is explicitly reviewed and documented as pre-existing or unrelated diff --git a/shared/chat/conversation/attachment-get-titles.tsx b/shared/chat/conversation/attachment-get-titles.tsx index bb7213ee928d..a5e28e1d12c5 100644 --- a/shared/chat/conversation/attachment-get-titles.tsx +++ b/shared/chat/conversation/attachment-get-titles.tsx @@ -1,4 +1,5 @@ import * as C from '@/constants' +import * as FS from '@/constants/fs' import * as Chat from '@/stores/chat' import * as ConvoState from '@/stores/convostate' import * as T from '@/constants/types' @@ -36,6 +37,8 @@ const pathToAttachmentType = (path: string) => { return 'file' } +const isKbfsPath = (path: string) => path.startsWith('/keybase/') + const Container = (ownProps: OwnProps) => { const {titles: _titles, tlfName, pathAndOutboxIDs} = ownProps const noDragDrop = ownProps.noDragDrop ?? false @@ -114,6 +117,29 @@ const Container = (ownProps: OwnProps) => { const inputRef = React.useRef(null) const {info, path} = pathAndInfos[index] ?? {} + const [kbfsPreviewURL, setKbfsPreviewURL] = React.useState(undefined) + React.useEffect(() => { + setKbfsPreviewURL(undefined) + if (info?.type !== 'image' || info.url || !path || !isKbfsPath(path)) { + return + } + let canceled = false + const f = async () => { + try { + const fileContext = await T.RPCGen.SimpleFSSimpleFSGetGUIFileContextRpcPromise({ + path: FS.pathToRPCPath(T.FS.stringToPath(path)).kbfs, + }) + if (!canceled) { + setKbfsPreviewURL(fileContext.url) + } + } catch {} + } + C.ignorePromise(f()) + return () => { + canceled = true + } + }, [info?.type, info?.url, path]) + const titleHint = 'Add a caption...' if (!info) return null @@ -121,7 +147,7 @@ const Container = (ownProps: OwnProps) => { switch (info.type) { case 'image': preview = path ? ( - + ) : null break case 'video': diff --git a/shared/chat/conversation/team-hooks.tsx b/shared/chat/conversation/team-hooks.tsx index 7bef36055f40..1fabc9519e4a 100644 --- a/shared/chat/conversation/team-hooks.tsx +++ b/shared/chat/conversation/team-hooks.tsx @@ -24,12 +24,6 @@ type ChatTeamMembersState = { members: ReadonlyMap } -type ChatTeamChannelsState = { - channels: ReadonlyMap - loading: boolean - teamname: string -} - type ChatTeamNamesState = { loading: boolean teamnames: ReadonlyMap @@ -43,10 +37,6 @@ type ChatTeamMembersStateInternal = ChatTeamMembersState & { loadedTeamID?: T.Teams.TeamID } -type ChatTeamChannelsStateInternal = ChatTeamChannelsState & { - loadedTeamID?: T.Teams.TeamID -} - type ChatManageChannelsBadgeState = { loading: boolean showBadge: boolean @@ -68,10 +58,6 @@ export type ChatTeamMembers = ChatTeamMembersState & { reload: () => Promise } -export type ChatTeamChannels = ChatTeamChannelsState & { - reload: () => Promise -} - export type ChatTeamNames = ChatTeamNamesState & { reload: () => Promise } @@ -81,10 +67,6 @@ export type ChatManageChannelsBadge = ChatManageChannelsBadgeState & { reload: () => Promise } -const emptyMembers = new Map() -const emptyChannels = new Map() -const emptyTeamnames = new Map() - const emptyChatTeamState: ChatTeamStateInternal = { allowPromote: false, description: '', @@ -95,30 +77,22 @@ const emptyChatTeamState: ChatTeamStateInternal = { yourOperations: Teams.initialCanUserPerform, } -const emptyChatTeamChannelsState: ChatTeamChannelsStateInternal = { - channels: emptyChannels, +const makeEmptyChatTeamMembersState = (): ChatTeamMembersStateInternal => ({ loadedTeamID: undefined, loading: false, - teamname: '', -} - -const emptyChatTeamMembersState: ChatTeamMembersStateInternal = { - loadedTeamID: undefined, - loading: false, - members: emptyMembers, -} + members: new Map(), +}) -const emptyChatTeamNamesState: ChatTeamNamesState = { +const makeEmptyChatTeamNamesState = (): ChatTeamNamesState => ({ loading: false, - teamnames: emptyTeamnames, -} + teamnames: new Map(), +}) -const emptyChosenChannelsTeamnames = new Set() -const emptyChosenChannelsStoreState: ChosenChannelsStoreState = { +const makeEmptyChosenChannelsStoreState = (): ChosenChannelsStoreState => ({ loaded: false, loading: false, - teamnames: emptyChosenChannelsTeamnames, -} + teamnames: new Set(), +}) const loadableTeamID = (teamID: T.Teams.TeamID) => teamID && teamID !== T.Teams.noTeamID && teamID !== T.Teams.newTeamWizardTeamID ? teamID : undefined @@ -267,14 +241,14 @@ const useChatTeamRaw = (teamID: T.Teams.TeamID, teamname?: string, enabled = tru const useChatTeamMembersRaw = (teamID: T.Teams.TeamID, enabled = true): ChatTeamMembers => { const validTeamID = loadableTeamID(teamID) - const [state, setState] = React.useState({ - ...emptyChatTeamMembersState, + const [state, setState] = React.useState(() => ({ + ...makeEmptyChatTeamMembersState(), loadedTeamID: validTeamID, - }) + })) const requestVersionRef = React.useRef(0) const clearState = React.useCallback(() => { requestVersionRef.current++ - setState({...emptyChatTeamMembersState, loadedTeamID: validTeamID}) + setState({...makeEmptyChatTeamMembersState(), loadedTeamID: validTeamID}) }, [validTeamID]) const reload = React.useCallback(async () => { @@ -309,7 +283,7 @@ const useChatTeamMembersRaw = (teamID: T.Teams.TeamID, enabled = true): ChatTeam const visibleState = enabled && state.loadedTeamID !== validTeamID - ? {...emptyChatTeamMembersState, loadedTeamID: validTeamID} + ? {...makeEmptyChatTeamMembersState(), loadedTeamID: validTeamID} : state React.useEffect(() => { @@ -339,98 +313,6 @@ const useChatTeamMembersRaw = (teamID: T.Teams.TeamID, enabled = true): ChatTeam return {...visibleState, reload} } -const useChatTeamChannelsRaw = ( - teamID: T.Teams.TeamID, - teamname?: string, - enabled = true -): ChatTeamChannels => { - const validTeamID = loadableTeamID(teamID) - const [state, setState] = React.useState(() => ({ - ...emptyChatTeamChannelsState, - loadedTeamID: validTeamID, - teamname: teamname ?? '', - })) - const requestVersionRef = React.useRef(0) - const clearState = React.useCallback(() => { - requestVersionRef.current++ - setState({...emptyChatTeamChannelsState, loadedTeamID: validTeamID, teamname: teamname ?? ''}) - }, [teamname, validTeamID]) - - const reload = React.useCallback(async () => { - if (!enabled || !validTeamID) { - clearState() - return - } - const requestVersion = ++requestVersionRef.current - setState(prev => ({...prev, loading: true, teamname: prev.teamname || teamname || ''})) - try { - const resolvedTeamname = - teamname || (await T.RPCGen.teamsGetAnnotatedTeamRpcPromise({teamID: validTeamID})).name - const {convs} = await T.RPCChat.localGetTLFConversationsLocalRpcPromise({ - membersType: T.RPCChat.ConversationMembersType.team, - tlfName: resolvedTeamname, - topicType: T.RPCChat.TopicType.chat, - }) - const channels = - convs?.reduce((res, inboxUIItem) => { - const conversationIDKey = T.Chat.stringToConversationIDKey(inboxUIItem.convID) - res.set(conversationIDKey, { - channelname: inboxUIItem.channel, - conversationIDKey, - description: inboxUIItem.headline, - }) - return res - }, new Map()) ?? emptyChannels - if (requestVersion !== requestVersionRef.current) { - return - } - setState({channels, loadedTeamID: validTeamID, loading: false, teamname: resolvedTeamname}) - } catch (error) { - if (requestVersion !== requestVersionRef.current) { - return - } - logger.warn(`Failed to load chat channels for ${validTeamID}`, error) - setState(prev => ({...prev, loading: false})) - } - }, [clearState, enabled, teamname, validTeamID]) - - const visibleState = - enabled && state.loadedTeamID !== validTeamID - ? {...emptyChatTeamChannelsState, loadedTeamID: validTeamID, teamname: teamname ?? ''} - : state - - React.useEffect(() => { - void reload() - }, [reload]) - C.Router2.useSafeFocusEffect( - React.useCallback(() => { - void reload() - }, [reload]) - ) - useEngineActionListener('keybase.1.NotifyTeam.teamMetadataUpdate', () => { - if (enabled) { - void reload() - } - }) - useEngineActionListener('keybase.1.NotifyTeam.teamChangedByID', action => { - if (enabled && action.payload.params.teamID === validTeamID) { - void reload() - } - }) - useEngineActionListener('keybase.1.NotifyTeam.teamDeleted', action => { - if (enabled && action.payload.params.teamID === validTeamID) { - clearState() - } - }) - useEngineActionListener('keybase.1.NotifyTeam.teamExit', action => { - if (enabled && action.payload.params.teamID === validTeamID) { - clearState() - } - }) - - return {...visibleState, reload} -} - const useChatTeamNamesRaw = (teamIDs: ReadonlyArray, enabled = true): ChatTeamNames => { const username = useCurrentUserState(s => s.username) const teamIDsKey = [ @@ -439,11 +321,11 @@ const useChatTeamNamesRaw = (teamIDs: ReadonlyArray, enabled = t .sort() .join(',') const validTeamIDs = React.useMemo(() => parseTeamIDsKey(teamIDsKey), [teamIDsKey]) - const [state, setState] = React.useState(emptyChatTeamNamesState) + const [state, setState] = React.useState(() => makeEmptyChatTeamNamesState()) const requestVersionRef = React.useRef(0) const clearState = React.useCallback(() => { requestVersionRef.current++ - setState(emptyChatTeamNamesState) + setState(makeEmptyChatTeamNamesState()) }, []) const reload = React.useCallback(async () => { @@ -568,9 +450,6 @@ export const useChatTeamMembers = (teamID: T.Teams.TeamID): ChatTeamMembers => { return useContextValue ? context.members : raw } -export const useChatTeamChannels = (teamID: T.Teams.TeamID, teamname?: string): ChatTeamChannels => - useChatTeamChannelsRaw(teamID, teamname) - export const useChatTeamNames = (teamIDs: ReadonlyArray): ChatTeamNames => useChatTeamNamesRaw(teamIDs) @@ -581,7 +460,7 @@ export const useChatManageChannelsBadge = ( const username = useCurrentUserState(s => s.username) const validTeamID = loadableTeamID(teamID) const [chosenChannelsState, setChosenChannelsState] = - React.useState(emptyChosenChannelsStoreState) + React.useState(() => makeEmptyChosenChannelsStoreState()) const chosenChannelsStateRef = React.useRef(chosenChannelsState) const chosenChannelsInFlightRef = React.useRef | undefined>(undefined) const chosenChannelsLoadedAtRef = React.useRef(0) @@ -623,7 +502,7 @@ export const useChatManageChannelsBadge = ( const reload = React.useCallback(async () => { if (!validTeamID || !teamname || !username) { - updateChosenChannelsState(emptyChosenChannelsStoreState) + updateChosenChannelsState(makeEmptyChosenChannelsStoreState()) return } if (chosenChannelsInFlightRef.current) { @@ -657,7 +536,7 @@ export const useChatManageChannelsBadge = ( const loadIfStale = React.useCallback( async (force = false) => { if (!validTeamID || !teamname || !username) { - updateChosenChannelsState(emptyChosenChannelsStoreState) + updateChosenChannelsState(makeEmptyChosenChannelsStoreState()) return } const isFresh = diff --git a/shared/chat/send-to-chat/index.tsx b/shared/chat/send-to-chat/index.tsx index fff79474ec7e..44062ee8a6de 100644 --- a/shared/chat/send-to-chat/index.tsx +++ b/shared/chat/send-to-chat/index.tsx @@ -7,7 +7,6 @@ import * as Kb from '@/common-adapters' import * as Kbfs from '@/fs/common' import ConversationList from './conversation-list/conversation-list' import ChooseConversation from './conversation-list/choose-conversation' -import {useFSState} from '@/stores/fs' import {useCurrentUserState} from '@/stores/current-user' type Props = { @@ -34,7 +33,6 @@ export const MobileSendToChat = (props: Props) => { const {isFromShareExtension, sendPaths, text} = props const navigateAppend = C.Router2.navigateAppend const clearModals = C.Router2.clearModals - const fileContext = useFSState(s => s.fileContext) const onSelect = (conversationIDKey: T.Chat.ConversationIDKey, tlfName: string) => { text && ConvoState.getConvoUIState(conversationIDKey).dispatch.injectIntoInput(text) if (sendPaths?.length) { @@ -44,7 +42,6 @@ export const MobileSendToChat = (props: Props) => { conversationIDKey, pathAndOutboxIDs: sendPaths.map(p => ({ path: Kb.Styles.normalizePath(p), - url: fileContext.get(p)?.url, })), selectConversationWithReason: isFromShareExtension ? 'extension' : 'files', tlfName, diff --git a/shared/constants/fs.tsx b/shared/constants/fs.tsx index 61202abaf5a3..653cecf6b0e0 100644 --- a/shared/constants/fs.tsx +++ b/shared/constants/fs.tsx @@ -150,17 +150,10 @@ export const emptyDownloadInfo = { startTime: 0, } satisfies T.FS.DownloadInfo -export const emptyPathItemActionMenu = { - downloadID: undefined, - downloadIntent: undefined, -} satisfies T.FS.PathItemActionMenu - export const emptySettings = { - isLoading: false, loaded: false, sfmiBannerDismissed: false, spaceAvailableNotificationThreshold: 0, - syncOnCellular: false, } satisfies T.FS.Settings export const emptyPathInfo = { @@ -168,11 +161,11 @@ export const emptyPathInfo = { platformAfterMountPath: '', } satisfies T.FS.PathInfo -export const emptyFileContext = { +export const emptyFileContext: T.FS.FileContext = { contentType: '', url: '', viewType: T.RPCGen.GUIViewType.default, -} satisfies T.FS.FileContext +} // Driver Status Constants export const driverStatusUnknown = { @@ -572,14 +565,15 @@ export const downloadIsOngoing = (dlState: T.FS.DownloadState) => export const getDownloadIntent = ( path: T.FS.Path, - downloads: T.FS.Downloads + downloadInfos: ReadonlyMap, + downloadStates: ReadonlyMap ): T.FS.DownloadIntent | undefined => { - const found = [...downloads.info].find(([_, info]) => info.path === path) + const found = [...downloadInfos].find(([_, info]) => info.path === path) if (!found) { return undefined } const [downloadID, info] = found - const dlState = downloads.state.get(downloadID) || emptyDownloadState + const dlState = downloadStates.get(downloadID) || emptyDownloadState if (!downloadIsOngoing(dlState)) { return undefined } @@ -715,14 +709,6 @@ export const getMainBannerType = ( } } -// Settings/Configuration Utilities -export const getPathUserSetting = ( - pathUserSettings: T.Immutable>, - path: T.Immutable -): T.FS.PathUserSetting => - pathUserSettings.get(path) || - (T.FS.getPathLevel(path) < 3 ? defaultTlfListPathUserSetting : defaultPathUserSetting) - export const showSortSetting = ( path: T.FS.Path, pathItem: T.FS.PathItem, diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index e8c240cd0269..cec0dffc38b9 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -18,7 +18,6 @@ declare global { var __hmr_TBstores: Map | undefined } import type * as UseChatStateType from '@/stores/chat' -import type * as UseFSStateType from '@/stores/fs' import type * as UseNotificationsStateType from '@/stores/notifications' import type * as UseUsersStateType from '@/stores/users' import {notifyEngineActionListeners} from '@/engine/action-listener' @@ -345,10 +344,10 @@ export const initSharedSubscriptions = () => { Util.getTab(prev) === Tabs.fsTab && next && Util.getTab(next) !== Tabs.fsTab && - useFSState.getState().criticalUpdate + useShellState.getState().fsCriticalUpdate ) { - const {dispatch} = useFSState.getState() - dispatch.setCriticalUpdate(false) + const {dispatch} = useShellState.getState() + dispatch.setFsCriticalUpdate(false) } const fsRrouteNames = ['fsRoot', 'barePreview'] const wasScreen = fsRrouteNames.includes(Util.getVisibleScreen(prev)?.name ?? '') @@ -397,9 +396,6 @@ export const _onEngineIncoming = (action: EngineGen.Actions) => { const {useNotifState} = require('@/stores/notifications') as typeof UseNotificationsStateType useNotifState.getState().dispatch.onEngineIncomingImpl(action) - const {useFSState} = require('@/stores/fs') as typeof UseFSStateType - useFSState.getState().dispatch.onEngineIncomingImpl(action) - const {useChatState} = require('@/stores/chat') as typeof UseChatStateType useChatState.getState().dispatch.onEngineIncomingImpl(action) } @@ -446,13 +442,25 @@ export const _onEngineIncoming = (action: EngineGen.Actions) => { } break case 'keybase.1.NotifyFS.FSOverallSyncStatusChanged': - case 'keybase.1.NotifyFS.FSSubscriptionNotifyPath': - case 'keybase.1.NotifyFS.FSSubscriptionNotify': { - const {useFSState} = require('@/stores/fs') as typeof UseFSStateType useFSState.getState().dispatch.onEngineIncomingImpl(action) } break + case 'keybase.1.NotifyFS.FSSubscriptionNotify': + { + switch (action.payload.params.topic) { + case T.RPCGen.SubscriptionTopic.journalStatus: + case T.RPCGen.SubscriptionTopic.onlineStatus: + case T.RPCGen.SubscriptionTopic.downloadStatus: + case T.RPCGen.SubscriptionTopic.uploadStatus: + case T.RPCGen.SubscriptionTopic.settings: { + useFSState.getState().dispatch.onEngineIncomingImpl(action) + break + } + default: + } + } + break case 'keybase.1.NotifyEmailAddress.emailAddressVerified': { const emailAddress = action.payload.params.emailAddress diff --git a/shared/constants/remote-actions.tsx b/shared/constants/remote-actions.tsx index ddba1551965f..0e7128d1b3fa 100644 --- a/shared/constants/remote-actions.tsx +++ b/shared/constants/remote-actions.tsx @@ -33,7 +33,6 @@ export const updateNow = 'remote:updateNow' export const updateWindowMaxState = 'remote:updateWindowMaxState' export const updateWindowShown = 'remote:updateWindowShown' export const updateWindowState = 'remote:updateWindowState' -export const userFileEditsLoad = 'remote:userFileEditsLoad' // Action Creators /** @@ -110,7 +109,6 @@ export const createUpdateWindowMaxState = (payload: {readonly max: boolean}) => ({payload, type: updateWindowMaxState}) as const export const createUpdateWindowShown = (payload: {readonly component: string}) => ({payload, type: updateWindowShown}) as const -export const createUserFileEditsLoad = (payload?: undefined) => ({payload, type: userFileEditsLoad}) as const // Action Payloads export type CloseUnlockFoldersPayload = ReturnType @@ -141,7 +139,6 @@ export type UpdateNowPayload = ReturnType export type UpdateWindowMaxStatePayload = ReturnType export type UpdateWindowShownPayload = ReturnType export type UpdateWindowStatePayload = ReturnType -export type UserFileEditsLoadPayload = ReturnType // All Actions // prettier-ignore @@ -174,5 +171,4 @@ export type Actions = | UpdateWindowMaxStatePayload | UpdateWindowShownPayload | UpdateWindowStatePayload - | UserFileEditsLoadPayload | {readonly payload: undefined; readonly type: 'common:resetStore'} diff --git a/shared/constants/types/fs.tsx b/shared/constants/types/fs.tsx index 8ba53078e1b8..b272094d71e5 100644 --- a/shared/constants/types/fs.tsx +++ b/shared/constants/types/fs.tsx @@ -336,7 +336,6 @@ export type DownloadInfo = Readonly<{ }> export type Downloads = Readonly<{ - info: ReadonlyMap regularDownloads: ReadonlyArray state: ReadonlyMap }> @@ -424,10 +423,6 @@ export enum PathItemActionMenuView { ConfirmSaveMedia = 'confirm-save-media', ConfirmSendToOtherApp = 'confirm-send-to-other-app', } -export type PathItemActionMenu = Readonly<{ - downloadID: string | undefined - downloadIntent: DownloadIntent | undefined -}> export enum DriverStatusType { Unknown = 'unknown', @@ -508,11 +503,9 @@ export type SoftErrors = Readonly<{ }> export type Settings = Readonly<{ - isLoading: boolean loaded: boolean sfmiBannerDismissed: boolean spaceAvailableNotificationThreshold: number - syncOnCellular: boolean }> export type PathInfo = Readonly<{ diff --git a/shared/desktop/renderer/remote-event-handler.desktop.tsx b/shared/desktop/renderer/remote-event-handler.desktop.tsx index 64f96f77307c..d76334d045e4 100644 --- a/shared/desktop/renderer/remote-event-handler.desktop.tsx +++ b/shared/desktop/renderer/remote-event-handler.desktop.tsx @@ -11,7 +11,6 @@ import {isPathSaltpackEncrypted, isPathSaltpackSigned} from '@/util/path' import type HiddenString from '@/util/hidden-string' import {useChatState} from '@/stores/chat' import {useConfigState} from '@/stores/config' -import {useFSState} from '@/stores/fs' import {useShellState} from '@/stores/shell' import {useUnlockFoldersState} from '@/unlock-folders/store' import logger from '@/logger' @@ -161,11 +160,7 @@ export const eventFromRemoteWindows = (action: RemoteGen.Actions) => { break } case RemoteGen.setCriticalUpdate: { - useFSState.getState().dispatch.setCriticalUpdate(action.payload.critical) - break - } - case RemoteGen.userFileEditsLoad: { - useFSState.getState().dispatch.userFileEditsLoad() + useShellState.getState().dispatch.setFsCriticalUpdate(action.payload.critical) break } case RemoteGen.openFilesFromWidget: { diff --git a/shared/fs/banner/conflict-banner.tsx b/shared/fs/banner/conflict-banner.tsx index 0a18615f8347..b4d9229fd9bb 100644 --- a/shared/fs/banner/conflict-banner.tsx +++ b/shared/fs/banner/conflict-banner.tsx @@ -1,8 +1,8 @@ import * as C from '@/constants' import * as Kb from '@/common-adapters' import * as T from '@/constants/types' +import * as Kbfs from '@/fs/common' import {openURL as openUrl} from '@/util/misc' -import {useFSState} from '@/stores/fs' import * as FS from '@/stores/fs' import {openPathInSystemFileManagerDesktop} from '@/util/fs-storeless-actions' @@ -12,12 +12,20 @@ type OwnProps = { const ConnectedBanner = (ownProps: OwnProps) => { const {path} = ownProps - const _tlf = useFSState(s => FS.getTlfFromPath(s.tlfs, path)) - const finishManualConflictResolution = useFSState(s => s.dispatch.finishManualConflictResolution) - const startManualConflictResolution = useFSState(s => s.dispatch.startManualConflictResolution) + const errorToActionOrThrow = Kbfs.useFsErrorActionOrThrow() + const _tlf = Kbfs.useFsTlf(path) const navigateAppend = C.Router2.navigateAppend const onFinishResolving = () => { - finishManualConflictResolution(path) + const f = async () => { + try { + await T.RPCGen.SimpleFSSimpleFSFinishResolvingConflictRpcPromise({ + path: FS.pathToRPCPath(path), + }) + } catch (error) { + errorToActionOrThrow(error, path) + } + } + C.ignorePromise(f()) } const onGoToSamePathInDifferentTlf = (tlfPath: T.FS.Path) => { navigateAppend({name: 'fsRoot', params: {path: FS.rebasePathToDifferentTlf(path, tlfPath)}}) @@ -26,11 +34,20 @@ const ConnectedBanner = (ownProps: OwnProps) => { openUrl('https://book.keybase.io/docs/files/details#conflict-resolution') } const onStartResolving = () => { - startManualConflictResolution(path) + const f = async () => { + try { + await T.RPCGen.SimpleFSSimpleFSClearConflictStateRpcPromise({ + path: FS.pathToRPCPath(path), + }) + } catch (error) { + errorToActionOrThrow(error, path) + } + } + C.ignorePromise(f()) } const openInSystemFileManager = (path: T.FS.Path) => { - openPathInSystemFileManagerDesktop(path) + openPathInSystemFileManagerDesktop(path, errorToActionOrThrow) } const conflictState = _tlf.conflictState diff --git a/shared/fs/banner/public-reminder.tsx b/shared/fs/banner/public-reminder.tsx index 43f4795b5a60..38ca1f27efa4 100644 --- a/shared/fs/banner/public-reminder.tsx +++ b/shared/fs/banner/public-reminder.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import * as Kb from '@/common-adapters' import * as T from '@/constants/types' import {navigateAppend} from '@/constants/router' -import {useFSState} from '@/stores/fs' +import {useFsPathItem} from '@/fs/common' import * as FS from '@/stores/fs' type Props = { @@ -19,7 +19,7 @@ const getTlfName = (parsedPath: T.FS.ParsedPath): string => { const PublicBanner = (props: Props) => { const {path} = props - const isWritable = useFSState(s => FS.getPathItem(s.pathItems, path).writable) + const isWritable = useFsPathItem(path).writable const lastPublicBannerClosedTlf = props.lastClosedTlf ?? '' const setLastPublicBannerClosedTlf = React.useCallback( (tlf: string) => diff --git a/shared/fs/banner/reset-banner.tsx b/shared/fs/banner/reset-banner.tsx index 7ce0d9cb160f..7f8268b0764d 100644 --- a/shared/fs/banner/reset-banner.tsx +++ b/shared/fs/banner/reset-banner.tsx @@ -3,7 +3,7 @@ import * as T from '@/constants/types' import {folderNameWithoutUsers} from '@/util/kbfs' import * as Kb from '@/common-adapters' import * as RowTypes from '@/fs/browser/rows/types' -import {useFSState} from '@/stores/fs' +import {useFsErrorActionOrThrow, useFsTlf} from '@/fs/common' import * as FS from '@/stores/fs' import {navToProfile} from '@/constants/router' @@ -11,8 +11,8 @@ type OwnProps = {path: T.FS.Path} const ConnectedBanner = (ownProps: OwnProps) => { const {path} = ownProps - const _tlf = useFSState(s => FS.getTlfFromPath(s.tlfs, path)) - const letResetUserBackIn = useFSState(s => s.dispatch.letResetUserBackIn) + const _tlf = useFsTlf(path) + const errorToActionOrThrow = useFsErrorActionOrThrow() const _onOpenWithoutResetUsers = (currPath: T.FS.Path, users: {[K in string]: boolean}) => { const pathElems = T.FS.getPathElements(currPath) if (pathElems.length < 3) return @@ -21,7 +21,14 @@ const ConnectedBanner = (ownProps: OwnProps) => { FS.navToPath(filteredPath) } const _onReAddToTeam = (id: T.RPCGen.TeamID, username: string) => { - letResetUserBackIn(id, username) + const f = async () => { + try { + await T.RPCGen.teamsTeamReAddMemberAfterResetRpcPromise({id, username}) + } catch (error) { + errorToActionOrThrow(error) + } + } + C.ignorePromise(f()) } const onOpenProfile = (username: string) => () => { diff --git a/shared/fs/banner/system-file-manager-integration-banner/container.tsx b/shared/fs/banner/system-file-manager-integration-banner/container.tsx index b277133fbc4a..658516542924 100644 --- a/shared/fs/banner/system-file-manager-integration-banner/container.tsx +++ b/shared/fs/banner/system-file-manager-integration-banner/container.tsx @@ -3,7 +3,7 @@ import * as C from '@/constants' import * as T from '@/constants/types' import * as Kb from '@/common-adapters' import * as Kbfs from '@/fs/common' -import {errorToActionOrThrow, useFSState} from '@/stores/fs' +import {useFSState} from '@/stores/fs' import * as FS from '@/stores/fs' import {ignorePromise} from '@/constants/utils' import {setSfmiBannerDismissedDesktop as setSfmiBannerDismissedInPlatform} from '@/stores/fs-platform' @@ -12,6 +12,7 @@ import {openLocalPathInSystemFileManagerDesktop} from '@/util/fs-storeless-actio type OwnProps = {alwaysShow?: boolean} const SFMIContainer = (op: OwnProps) => { + const errorToActionOrThrow = Kbfs.useFsErrorActionOrThrow() const {driverStatus, driverEnable, driverDisable, settings} = useFSState( C.useShallow(s => ({ driverDisable: s.dispatch.driverDisable, @@ -240,9 +241,10 @@ const DokanOutdated = (props: {driverStatus: T.FS.DriverStatus; onDisable: () => type JustEnabledProps = {onDismiss?: () => void} const JustEnabled = ({onDismiss}: JustEnabledProps) => { + const errorToActionOrThrow = Kbfs.useFsErrorActionOrThrow() const displayingMountDir = useFSState(s => s.sfmi.preferredMountDirs[0] ?? '') const open = displayingMountDir - ? () => openLocalPathInSystemFileManagerDesktop(displayingMountDir) + ? () => openLocalPathInSystemFileManagerDesktop(displayingMountDir, errorToActionOrThrow) : undefined return ( { + const errorToActionOrThrow = Kbfs.useFsErrorActionOrThrow() const driverStatus = useFSState(s => s.sfmi.driverStatus) const onCancel = C.Router2.navigateUp const openSecurityPrefs = () => { diff --git a/shared/fs/browser/destination-picker.tsx b/shared/fs/browser/destination-picker.tsx index f5e1d97a71f4..f0e4b902d778 100644 --- a/shared/fs/browser/destination-picker.tsx +++ b/shared/fs/browser/destination-picker.tsx @@ -6,9 +6,12 @@ import * as RowCommon from './rows/common' import * as T from '@/constants/types' import NavHeaderTitle from '@/fs/nav-header/title' import Root from './root' +import {FsBrowserEditProvider, useFsBrowserEdits} from './edit-state' +import {FsBrowserSortProvider} from './sort-state' import Rows from './rows/rows-container' -import {useFSState} from '@/stores/fs' import * as FS from '@/stores/fs' +import {useConfigState} from '@/stores/config' +import {makeUUID} from '@/util/uuid' type OwnProps = { parentPath: T.FS.Path @@ -21,28 +24,66 @@ const canBackUp = C.isMobile const ConnectedDestinationPicker = (ownProps: OwnProps) => { const {parentPath, source} = ownProps - const {isShare, isWritable, isCopyable, isMovable, moveOrCopy, newFolderRow} = useFSState( - C.useShallow(s => { - const pathItem = FS.getPathItem(s.pathItems, parentPath) - const writable = T.FS.getPathLevel(parentPath) > 2 && pathItem.writable - const isShareSource = source.type === T.FS.DestinationPickerSource.IncomingShare - const isMoveOrCopy = source.type === T.FS.DestinationPickerSource.MoveOrCopy - const copyable = - writable && (isShareSource || (isMoveOrCopy && parentPath !== T.FS.getPathParent(source.path))) - const movable = copyable && isMoveOrCopy && FS.pathsInSameTlf(source.path, parentPath) - return { - isCopyable: copyable, - isMovable: movable, - isShare: isShareSource, - isWritable: writable, - moveOrCopy: s.dispatch.moveOrCopy, - newFolderRow: s.dispatch.newFolderRow, - } - }) - ) + const parentPathItem = FsCommon.useFsPathMetadata(parentPath) + const browserEdits = useFsBrowserEdits() + const errorToActionOrThrow = FsCommon.useFsErrorActionOrThrow() + const isWritable = T.FS.getPathLevel(parentPath) > 2 && parentPathItem.writable + const isShare = source.type === T.FS.DestinationPickerSource.IncomingShare + const isMoveOrCopy = source.type === T.FS.DestinationPickerSource.MoveOrCopy + const isCopyable = + isWritable && (isShare || (isMoveOrCopy && parentPath !== T.FS.getPathParent(source.path))) + const isMovable = isCopyable && isMoveOrCopy && FS.pathsInSameTlf(source.path, parentPath) const nav = useSafeNavigation() const clearModals = C.Router2.clearModals + const moveOrCopy = (type: 'move' | 'copy') => { + const f = async () => { + const params = + source.type === T.FS.DestinationPickerSource.MoveOrCopy + ? [ + { + dest: FS.pathToRPCPath(T.FS.pathConcat(parentPath, T.FS.getPathName(source.path))), + opID: makeUUID(), + overwriteExistingFiles: false, + src: FS.pathToRPCPath(source.path), + }, + ] + : source.source + .map(item => ({originalPath: item.originalPath ?? '', scaledPath: item.scaledPath})) + .filter(({originalPath}) => !!originalPath) + .map(({originalPath, scaledPath}) => ({ + dest: FS.pathToRPCPath( + T.FS.pathConcat( + parentPath, + T.FS.getLocalPathName(originalPath) + // We use the local path name here since we only care about file name. + ) + ), + opID: makeUUID(), + overwriteExistingFiles: false, + src: { + PathType: T.RPCGen.PathType.local, + local: T.FS.getNormalizedLocalPath( + useConfigState.getState().incomingShareUseOriginal + ? originalPath + : scaledPath || originalPath + ), + } as T.RPCGen.Path, + })) + + try { + const rpc = + type === 'move' + ? T.RPCGen.SimpleFSSimpleFSMoveRpcPromise + : T.RPCGen.SimpleFSSimpleFSCopyRecursiveRpcPromise + await Promise.all(params.map(async param => rpc(param))) + await Promise.all(params.map(async ({opID}) => T.RPCGen.SimpleFSSimpleFSWaitRpcPromise({opID}))) + } catch (error) { + errorToActionOrThrow(error, parentPath) + } + } + C.ignorePromise(f()) + } const onBackUp = isShare || !canBackUp(parentPath) ? undefined @@ -54,24 +95,23 @@ const ConnectedDestinationPicker = (ownProps: OwnProps) => { const onCancel = isShare ? undefined : () => clearModals() const onCopyHere = isCopyable ? () => { - moveOrCopy(parentPath, source, 'copy') + moveOrCopy('copy') clearModals() nav.safeNavigateAppend({name: 'fsRoot', params: {path: parentPath}}) } : undefined const onMoveHere = isMovable ? () => { - moveOrCopy(parentPath, source, 'move') + moveOrCopy('move') clearModals() nav.safeNavigateAppend({name: 'fsRoot', params: {path: parentPath}}) } : undefined const onNewFolder = - isWritable && !isShare - ? () => newFolderRow(parentPath) + isWritable && !isShare && browserEdits?.newFolderRow + ? () => browserEdits.newFolderRow(parentPath) : undefined - FsCommon.useFsPathMetadata(parentPath) FsCommon.useFsTlfs() FsCommon.useFsOnlineStatus() @@ -85,6 +125,7 @@ const ConnectedDestinationPicker = (ownProps: OwnProps) => { )} + {!!onBackUp && ( { ) } -const Screen = (props: OwnProps) => +const Screen = (props: OwnProps) => ( + + + + + + + + + +) const NewFolder = (p: {onNewFolder?: () => void}) => { const {onNewFolder} = p diff --git a/shared/fs/browser/edit-state.tsx b/shared/fs/browser/edit-state.tsx new file mode 100644 index 000000000000..99fca1e5bdf1 --- /dev/null +++ b/shared/fs/browser/edit-state.tsx @@ -0,0 +1,301 @@ +import * as Constants from '@/constants/fs' +import * as S from '@/constants/strings' +import {ignorePromise} from '@/constants/utils' +import * as React from 'react' +import * as T from '@/constants/types' +import {RPCError} from '@/util/errors' +import {useFsErrorActionOrThrow} from '../common/error-state' +import {useFsLoadedPathItems} from '../common/hooks' +import {makeEditID, makeUUID} from '@/stores/fs' + +export type BrowserEditSession = Readonly<{ + commitEdit: () => void + discardEdit: () => void + edit: T.FS.Edit + editID: T.FS.EditID + isSubmitting: boolean + setEditName: (name: string) => void +}> + +type BrowserEditContextType = { + edits: ReadonlyMap + newFolderRow: (parentPath: T.FS.Path) => void + startRename: (path: T.FS.Path) => void +} + +const BrowserEditContext = React.createContext(null) + +type BrowserEditState = { + edits: ReadonlyMap + submitting: ReadonlySet +} + +const makeEmptyBrowserEditState = (): BrowserEditState => ({ + edits: new Map(), + submitting: new Set(), +}) + +let browserEditState = makeEmptyBrowserEditState() +let browserEditProviderCount = 0 +const browserEditStateListeners = new Set<() => void>() + +const subscribeBrowserEditState = (listener: () => void) => { + browserEditStateListeners.add(listener) + return () => { + browserEditStateListeners.delete(listener) + } +} + +const getBrowserEditStateSnapshot = () => browserEditState + +const setBrowserEditState = (updater: (prevState: BrowserEditState) => BrowserEditState) => { + const nextState = updater(browserEditState) + if (nextState === browserEditState) { + return + } + browserEditState = nextState + browserEditStateListeners.forEach(listener => listener()) +} + +const setBrowserEdits = ( + updater: (prevEdits: ReadonlyMap) => ReadonlyMap +) => { + setBrowserEditState(prevState => { + const edits = updater(prevState.edits) + return edits === prevState.edits ? prevState : {...prevState, edits} + }) +} + +const setBrowserSubmitting = ( + updater: (prevSubmitting: ReadonlySet) => ReadonlySet +) => { + setBrowserEditState(prevState => { + const submitting = updater(prevState.submitting) + return submitting === prevState.submitting ? prevState : {...prevState, submitting} + }) +} + +const resetBrowserEditState = () => { + browserEditState = makeEmptyBrowserEditState() + browserEditStateListeners.forEach(listener => listener()) +} + +const addOrReplaceEdit = ( + prevEdits: ReadonlyMap, + editID: T.FS.EditID, + edit: T.FS.Edit +) => { + const nextEdits = new Map(prevEdits) + nextEdits.set(editID, edit) + return nextEdits +} + +const deleteEdit = (prevEdits: ReadonlyMap, editID: T.FS.EditID) => { + if (!prevEdits.has(editID)) { + return prevEdits + } + const nextEdits = new Map(prevEdits) + nextEdits.delete(editID) + return nextEdits +} + +const addSubmitting = (prevSubmitting: ReadonlySet, editID: T.FS.EditID) => { + if (prevSubmitting.has(editID)) { + return prevSubmitting + } + const nextSubmitting = new Set(prevSubmitting) + nextSubmitting.add(editID) + return nextSubmitting +} + +const deleteSubmitting = (prevSubmitting: ReadonlySet, editID: T.FS.EditID) => { + if (!prevSubmitting.has(editID)) { + return prevSubmitting + } + const nextSubmitting = new Set(prevSubmitting) + nextSubmitting.delete(editID) + return nextSubmitting +} + +export const useFsBrowserEdits = () => React.useContext(BrowserEditContext) + +const getStaleRenameEditIDs = ( + edits: ReadonlyMap, + pathItems: T.FS.PathItems +): ReadonlySet => { + const stale = new Set() + edits.forEach((edit, editID) => { + if (edit.type !== T.FS.EditType.Rename) { + return + } + const parent = Constants.getPathItem(pathItems, edit.parentPath) + if (!(parent.type === T.FS.PathType.Folder && parent.children.has(edit.originalName))) { + stale.add(editID) + } + }) + return stale +} + +export const FsBrowserEditProvider = ({children}: {children: React.ReactNode}) => { + const errorToActionOrThrow = useFsErrorActionOrThrow() + const {edits, submitting} = React.useSyncExternalStore( + subscribeBrowserEditState, + getBrowserEditStateSnapshot, + getBrowserEditStateSnapshot + ) + const pathItems = useFsLoadedPathItems() + + React.useEffect(() => { + browserEditProviderCount++ + return () => { + browserEditProviderCount-- + if (!browserEditProviderCount) { + resetBrowserEditState() + } + } + }, []) + + React.useEffect(() => { + const staleEditIDs = getStaleRenameEditIDs(edits, pathItems) + if (!staleEditIDs.size) { + return + } + setBrowserEditState(prevState => { + const nextEdits = new Map(prevState.edits) + const nextSubmitting = new Set(prevState.submitting) + staleEditIDs.forEach(editID => { + nextEdits.delete(editID) + nextSubmitting.delete(editID) + }) + return { + edits: nextEdits, + submitting: nextSubmitting, + } + }) + }, [edits, pathItems]) + + const commitEdit = (editID: T.FS.EditID) => { + const edit = edits.get(editID) + if (!edit) { + return + } + setBrowserSubmitting(prevSubmitting => addSubmitting(prevSubmitting, editID)) + const f = async () => { + try { + switch (edit.type) { + case T.FS.EditType.NewFolder: + await T.RPCGen.SimpleFSSimpleFSOpenRpcPromise( + { + dest: Constants.pathToRPCPath(T.FS.pathConcat(edit.parentPath, edit.name)), + flags: T.RPCGen.OpenFlags.directory, + opID: makeUUID(), + }, + S.waitingKeyFSCommitEdit + ) + break + case T.FS.EditType.Rename: { + const opID = makeUUID() + await T.RPCGen.SimpleFSSimpleFSMoveRpcPromise({ + dest: Constants.pathToRPCPath(T.FS.pathConcat(edit.parentPath, edit.name)), + opID, + overwriteExistingFiles: false, + src: Constants.pathToRPCPath(T.FS.pathConcat(edit.parentPath, edit.originalName)), + }) + await T.RPCGen.SimpleFSSimpleFSWaitRpcPromise({opID}, S.waitingKeyFSCommitEdit) + break + } + } + setBrowserEdits(prevEdits => deleteEdit(prevEdits, editID)) + } catch (error) { + if ( + edit.type === T.FS.EditType.Rename && + error instanceof RPCError && + [T.RPCGen.StatusCode.scsimplefsdirnotempty, T.RPCGen.StatusCode.scsimplefsnameexists].includes( + error.code + ) + ) { + setBrowserEdits(prevEdits => + addOrReplaceEdit(prevEdits, editID, {...edit, error: error.desc || 'name exists'}) + ) + return + } + errorToActionOrThrow(error, edit.parentPath) + } finally { + setBrowserSubmitting(prevSubmitting => deleteSubmitting(prevSubmitting, editID)) + } + } + ignorePromise(f()) + } + + const discardEdit = (editID: T.FS.EditID) => { + setBrowserEdits(prevEdits => deleteEdit(prevEdits, editID)) + setBrowserSubmitting(prevSubmitting => deleteSubmitting(prevSubmitting, editID)) + } + + const setEditName = (editID: T.FS.EditID, name: string) => { + setBrowserEdits(prevEdits => { + const edit = prevEdits.get(editID) + if (!edit || edit.name === name) { + return prevEdits + } + return addOrReplaceEdit(prevEdits, editID, {...edit, error: undefined, name}) + }) + } + + const startRename = (path: T.FS.Path) => { + const parentPath = T.FS.getPathParent(path) + const originalName = T.FS.getPathName(path) + setBrowserEdits(prevEdits => + addOrReplaceEdit(prevEdits, makeEditID(), { + name: originalName, + originalName, + parentPath, + type: T.FS.EditType.Rename, + }) + ) + } + + const newFolderRow = (parentPath: T.FS.Path) => { + const parentPathItem = Constants.getPathItem(pathItems, parentPath) + if (parentPathItem.type !== T.FS.PathType.Folder) { + console.warn(`bad parentPath: ${parentPathItem.type}`) + return + } + + const existingNewFolderNames = new Set([...edits.values()].map(({name}) => name)) + + let newFolderName = 'New Folder' + let i = 2 + while (parentPathItem.children.has(newFolderName) || existingNewFolderNames.has(newFolderName)) { + newFolderName = `New Folder ${i}` + ++i + } + + setBrowserEdits(prevEdits => + addOrReplaceEdit(prevEdits, makeEditID(), { + ...Constants.emptyNewFolder, + name: newFolderName, + originalName: newFolderName, + parentPath, + }) + ) + } + + const sessions = new Map() + edits.forEach((edit, editID) => { + sessions.set(editID, { + commitEdit: () => commitEdit(editID), + discardEdit: () => discardEdit(editID), + edit, + editID, + isSubmitting: submitting.has(editID), + setEditName: (name: string) => setEditName(editID, name), + }) + }) + + return ( + + {children} + + ) +} diff --git a/shared/fs/browser/index.tsx b/shared/fs/browser/index.tsx index e496a0976893..c9002a8a4b1b 100644 --- a/shared/fs/browser/index.tsx +++ b/shared/fs/browser/index.tsx @@ -7,11 +7,14 @@ import ConflictBanner from '../banner/conflict-banner' import Footer from '../footer/footer' import OfflineFolder from './offline' import PublicReminder from '../banner/public-reminder' +import {FsBrowserEditProvider} from './edit-state' +import {FsBrowserSortProvider} from './sort-state' import Root from './root' import Rows from './rows/rows-container' +import {useFsErrorActionOrThrow, useFsPathItem, useFsTlf, useFsUpload} from '../common' import {asRows as resetBannerAsRows} from '../banner/reset-banner' import {useModalHeaderState} from '@/stores/modal-header' -import {errorToActionOrThrow, useFSState} from '@/stores/fs' +import {useFSState} from '@/stores/fs' import * as FS from '@/stores/fs' import {uploadFromDragAndDropDesktop as uploadFromDragAndDropInPlatform} from '@/stores/fs-platform' @@ -23,11 +26,12 @@ type OwnProps = { const Container = (ownProps: OwnProps) => { const {path} = ownProps const filter = useModalHeaderState(s => s.folderViewFilter) - const {_kbfsDaemonStatus, _pathItem, resetBannerType} = useFSState( + const _pathItem = useFsPathItem(path) + const tlf = useFsTlf(path) + const {_kbfsDaemonStatus, resetBannerType} = useFSState( C.useShallow(s => ({ _kbfsDaemonStatus: s.kbfsDaemonStatus, - _pathItem: FS.getPathItem(s.pathItems, path), - resetBannerType: FS.resetBannerType(s, path), + resetBannerType: FS.resetBannerTypeFromTlf(tlf), })) ) const props = { @@ -78,7 +82,8 @@ function DragAndDrop(p: { rejectReason?: string }) { const {children, path, rejectReason} = p - const upload = useFSState(s => s.dispatch.upload) + const errorToActionOrThrow = useFsErrorActionOrThrow() + const upload = useFsUpload() const onAttach = (localPaths: Array) => { const f = async () => { try { @@ -153,4 +158,12 @@ function BrowserContent(props: Props) { ) } -export default Container +const Screen = (props: OwnProps) => ( + + + + + +) + +export default Screen diff --git a/shared/fs/browser/offline.tsx b/shared/fs/browser/offline.tsx index 9ec9e81177e0..60e4c380c411 100644 --- a/shared/fs/browser/offline.tsx +++ b/shared/fs/browser/offline.tsx @@ -1,8 +1,7 @@ import * as Kb from '@/common-adapters' import * as T from '@/constants/types' import TopBar from '../top-bar' -import {useFSState} from '@/stores/fs' -import * as FS from '@/stores/fs' +import {useFsTlf} from '../common' type Props = { path: T.FS.Path @@ -43,7 +42,7 @@ type OwnProps = { const Container = (ownProps: OwnProps) => { const {path} = ownProps - const syncConfig = useFSState(s => FS.getTlfFromPath(s.tlfs, path).syncConfig) + const syncConfig = useFsTlf(path).syncConfig const props = { ...ownProps, syncEnabled: syncConfig.mode === T.FS.TlfSyncMode.Enabled, diff --git a/shared/fs/browser/root.tsx b/shared/fs/browser/root.tsx index a371d07b9e8a..60d6f0388182 100644 --- a/shared/fs/browser/root.tsx +++ b/shared/fs/browser/root.tsx @@ -2,9 +2,9 @@ import * as T from '@/constants/types' import * as Kb from '@/common-adapters' import TlfType from './rows/tlf-type' import Tlf from './rows/tlf' +import {useFsTlfs} from '../common' import SfmiBanner from '../banner/system-file-manager-integration-banner/container' import {WrapRow} from './rows/rows' -import {useFSState} from '@/stores/fs' import * as FS from '@/stores/fs' import {useCurrentUserState} from '@/stores/current-user' @@ -78,7 +78,7 @@ const useRecentTlfs = ( n: number, destinationPickerSource?: T.FS.MoveOrCopySource | T.FS.IncomingShareSource ): Array => { - const tlfs = useFSState(s => s.tlfs) + const tlfs = useFsTlfs() const username = useCurrentUserState(s => s.username) const privateTopN = useTopNTlfs(T.FS.TlfType.Private, tlfs.private, n) const publicTopN = useTopNTlfs(T.FS.TlfType.Public, tlfs.public, n) diff --git a/shared/fs/browser/rows/common.tsx b/shared/fs/browser/rows/common.tsx index 192956fd6f83..4090a598b35c 100644 --- a/shared/fs/browser/rows/common.tsx +++ b/shared/fs/browser/rows/common.tsx @@ -22,9 +22,11 @@ export const StillCommon = ( ) => ( } + statusIcon={} icon={ ({ - commitEdit: s.dispatch.commitEdit, - discardEdit: s.dispatch.discardEdit, - edit: s.edits.get(editID) || FS.emptyNewFolder, - setEditName: s.dispatch.setEditName, - })) - ) +function Editing({editSession}: Props) { + const {commitEdit, discardEdit, edit} = editSession const [filename, setFilename] = React.useState(edit.name) + const setEditName = React.useEffectEvent((nextName: string) => { + editSession.setEditName(nextName) + }) const onCancel = () => { - discardEdit(editID) + discardEdit() } const onSubmit = () => { - commitEdit(editID) + commitEdit() } React.useEffect(() => { - setEditName(editID, filename) - }, [editID, filename, setEditName]) + setEditName(filename) + }, [filename, editSession.editID]) + React.useEffect(() => { + setFilename(edit.name) + }, [edit.name, editSession.editID]) const onKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Escape') onCancel() } @@ -70,12 +67,12 @@ function Editing({editID}: Props) { )} - } const getStillRows = ( - pathItems: T.Immutable>, - parentPath: T.Immutable, - names: ReadonlySet + childItems: ReadonlyArray>, + childPaths: ReadonlyArray> ): Array => - [...names].reduce>((items, name) => { - const item = FS.getPathItem(pathItems, T.FS.pathConcat(parentPath, name)) - const path = T.FS.pathConcat(parentPath, item.name) + childItems.reduce>((items, item, index) => { + const path = childPaths[index] + if (!path) { + return items + } return [ ...items, { - key: `still:${name}`, + key: `still:${item.name}`, lastModifiedTimestamp: item.lastModifiedTimestamp, name: item.name, path, @@ -49,53 +52,55 @@ const folderPlaceholderRows = _getPlaceholderRows(T.FS.PathType.Folder) const _makeInTlfRows = ( parentPath: T.Immutable, - edits: T.Immutable>, + editSessions: ReadonlyMap, stillRows: T.Immutable> ) => { - const relevantEdits = [...edits].filter(([_, edit]) => edit.parentPath === parentPath) + const relevantEdits = [...editSessions.values()].filter(({edit}) => edit.parentPath === parentPath) const newFolderRows: Array = relevantEdits - .filter(([_, edit]) => edit.type === T.FS.EditType.NewFolder) - .map(([editID, edit]) => ({ - editID, - editType: edit.type, - key: `edit:${T.FS.editIDToString(editID)}`, - name: edit.name, + .filter(({edit}) => edit.type === T.FS.EditType.NewFolder) + .map(editSession => ({ + editSession, + key: `edit:${T.FS.editIDToString(editSession.editID)}`, + name: editSession.edit.name, // fields for sortable rowType: RowTypes.RowType.NewFolder, type: T.FS.PathType.Folder, })) const renameEdits = new Map( relevantEdits - .filter(([_, edit]) => edit.type === T.FS.EditType.Rename) - .map(([editID, edit]) => [edit.originalName, editID]) + .filter(({edit}) => edit.type === T.FS.EditType.Rename) + .map(editSession => [editSession.edit.originalName, editSession] as const) ) return newFolderRows.concat( stillRows.map(row => renameEdits.has(row.name) ? { ...row, - editID: renameEdits.get(row.name), + editSession: renameEdits.get(row.name), } : row ) ) } -const getInTlfItemsFromStateProps = ( - stateProps: StateProps, - path: T.FS.Path +const getInTlfItems = ( + pathItem: T.FS.PathItem, + childItems: ReadonlyArray, + childPaths: ReadonlyArray, + sortSetting: T.FS.SortSetting, + path: T.FS.Path, + editSessions: ReadonlyMap ): Array => { - const _pathItem = FS.getPathItem(stateProps._pathItems, path) - if (_pathItem.type !== T.FS.PathType.Folder) { + if (pathItem.type !== T.FS.PathType.Folder) { return filePlaceholderRows } - if (_pathItem.progress === T.FS.ProgressType.Pending) { + if (pathItem.progress === T.FS.ProgressType.Pending) { return filePlaceholderRows } - const stillRows = getStillRows(stateProps._pathItems, path, _pathItem.children) - return sortRowItems(_makeInTlfRows(path, stateProps._edits, stillRows), stateProps._sortSetting, '') + const stillRows = getStillRows(childItems, childPaths) + return sortRowItems(_makeInTlfRows(path, editSessions, stillRows), sortSetting, '') } const getTlfRowsFromTlfs = ( @@ -117,48 +122,58 @@ const getTlfRowsFromTlfs = ( type: T.FS.PathType.Folder, })) -type StateProps = { - _edits: T.FS.Edits - _pathItems: T.FS.PathItems - _sortSetting: T.FS.SortSetting - _tlfs: T.FS.Tlfs - _username: string -} - -const getTlfItemsFromStateProps = ( - stateProps: StateProps, +const getTlfItems = ( + tlfs: T.FS.Tlfs, + sortSetting: T.FS.SortSetting, + username: string, path: T.FS.Path, inDestinationPicker?: boolean ): Array => { - if (stateProps._tlfs.private.size === 0) { + if (tlfs.private.size === 0) { // /keybase/private/ is always favorited. If it's not there it must be // unintialized. return folderPlaceholderRows } - const {tlfList, tlfType} = FS.getTlfListAndTypeFromPath(stateProps._tlfs, path) + const {tlfList, tlfType} = FS.getTlfListAndTypeFromPath(tlfs, path) return sortRowItems( - getTlfRowsFromTlfs(tlfList, tlfType, stateProps._username, inDestinationPicker), - stateProps._sortSetting, - (T.FS.pathIsNonTeamTLFList(path) && stateProps._username) || '' + getTlfRowsFromTlfs(tlfList, tlfType, username, inDestinationPicker), + sortSetting, + (T.FS.pathIsNonTeamTLFList(path) && username) || '' ) } -const getNormalRowItemsFromStateProps = ( - stateProps: StateProps, - path: T.FS.Path, +const getNormalRowItems = ({ + childItems, + childPaths, + editSessions, + path, + pathItem, + sortSetting, + tlfs, + username, + inDestinationPicker, +}: { + childItems: ReadonlyArray + childPaths: ReadonlyArray + editSessions: ReadonlyMap inDestinationPicker?: boolean -): Array => { + path: T.FS.Path + pathItem: T.FS.PathItem + sortSetting: T.FS.SortSetting + tlfs: T.FS.Tlfs + username: string +}): Array => { const level = T.FS.getPathLevel(path) switch (level) { case 0: case 1: return [] // should never happen case 2: - return getTlfItemsFromStateProps(stateProps, path, inDestinationPicker) + return getTlfItems(tlfs, sortSetting, username, path, inDestinationPicker) default: - return getInTlfItemsFromStateProps(stateProps, path) + return getInTlfItems(pathItem, childItems, childPaths, sortSetting, path, editSessions) } } @@ -171,27 +186,25 @@ const filterRowItems = (rows: Array, filter?: string) => : rows const Container = (o: OwnProps) => { - const {_edits, _pathItems, _sortSetting, _tlfs} = useFSState( - C.useShallow(s => { - const _edits = s.edits - const _pathItems = s.pathItems - const _sortSetting = FS.getPathUserSetting(s.pathUserSettings, o.path).sort - const _tlfs = s.tlfs - return {_edits, _pathItems, _sortSetting, _tlfs} - }) - ) + const {childItems, childPaths, pathItem} = useFsFolderChildItems(o.path, {initialLoadRecursive: true}) + const tlfs = useFsTlfs() + const {sortSetting} = useFsBrowserSort(o.path) const _username = useCurrentUserState(s => s.username) - - const s = { - _edits, - _pathItems, - _sortSetting, - _tlfs, - _username, - } + const browserEdits = useFsBrowserEdits() + const editSessions: ReadonlyMap = browserEdits?.edits ?? new Map() const inDestinationPicker = !!o.destinationPickerSource - const normalRowItems = getNormalRowItemsFromStateProps(s, o.path, inDestinationPicker) + const normalRowItems = getNormalRowItems({ + childItems, + childPaths, + editSessions, + inDestinationPicker, + path: o.path, + pathItem, + sortSetting, + tlfs, + username: _username, + }) const filteredRowItems = filterRowItems(normalRowItems, o.filter) const props = { destinationPickerSource: o.destinationPickerSource, diff --git a/shared/fs/browser/rows/rows.tsx b/shared/fs/browser/rows/rows.tsx index 9e4e6b9993b2..29b66bb0eaca 100644 --- a/shared/fs/browser/rows/rows.tsx +++ b/shared/fs/browser/rows/rows.tsx @@ -8,7 +8,7 @@ import Tlf from './tlf' import Still from './still' import Editing from './editing' import {normalRowHeight} from './common' -import {useFsChildren, UploadButton} from '@/fs/common' +import {UploadButton} from '@/fs/common' export type Props = { destinationPickerSource?: T.FS.MoveOrCopySource | T.FS.IncomingShareSource @@ -57,8 +57,8 @@ function Rows(props: Props & {listKey: string}) { case RowTypes.RowType.Still: return ( - {item.editID ? ( - + {item.editSession ? ( + ) : ( )} @@ -67,7 +67,7 @@ function Rows(props: Props & {listKey: string}) { case RowTypes.RowType.NewFolder: return ( - + ) case RowTypes.RowType.Empty: @@ -139,8 +139,6 @@ function Rows(props: Props & {listKey: string}) { } const RowsWithAutoLoad = (props: Props) => { - useFsChildren(props.path, /* recursive */ true) // need recursive for the EMPTY tag - // List caches offsets. So have the key derive from layouts so that we // trigger a re-render when layout changes. Also encode items length into // this, otherwise we'd get taller-than content rows when going into a diff --git a/shared/fs/browser/rows/still.tsx b/shared/fs/browser/rows/still.tsx index cda0c331cc64..642643816310 100644 --- a/shared/fs/browser/rows/still.tsx +++ b/shared/fs/browser/rows/still.tsx @@ -1,9 +1,8 @@ -import * as C from '@/constants' import * as T from '@/constants/types' import {useOpen} from '@/fs/common/use-open' import {rowStyles, StillCommon} from './common' import * as Kb from '@/common-adapters' -import {LastModifiedLine, Filename} from '@/fs/common' +import {LastModifiedLine, Filename, useFsDismissUpload, useFsDownloadIntent, useFsPathItem} from '@/fs/common' import {useFSState} from '@/stores/fs' import * as FS from '@/stores/fs' @@ -27,21 +26,16 @@ const getDownloadingText = (intent: T.FS.DownloadIntent) => { const StillContainer = (p: OwnProps) => { const {destinationPickerSource, path} = p - const {_downloads, _pathItem, _uploads, dismissUpload} = useFSState( - C.useShallow(s => ({ - _downloads: s.downloads, - _pathItem: FS.getPathItem(s.pathItems, path), - _uploads: s.uploads, - dismissUpload: s.dispatch.dismissUpload, - })) - ) + const _pathItem = useFsPathItem(path, {loadOnMount: false, subscribe: false}) + const dismissUpload = useFsDismissUpload() + const _uploads = useFSState(s => s.uploads) const writingToJournalUploadState = _uploads.writingToJournal.get(path) const onOpen = useOpen({destinationPickerSource, path}) const dismissUploadError = writingToJournalUploadState?.error ? () => dismissUpload(writingToJournalUploadState.uploadID) : undefined - const intentIfDownloading = FS.getDownloadIntent(path, _downloads) + const intentIfDownloading = useFsDownloadIntent(path) const isEmpty = _pathItem.type === T.FS.PathType.Folder && _pathItem.progress === T.FS.ProgressType.Loaded && diff --git a/shared/fs/browser/rows/tlf.tsx b/shared/fs/browser/rows/tlf.tsx index 8a50324078c7..610232c18c5b 100644 --- a/shared/fs/browser/rows/tlf.tsx +++ b/shared/fs/browser/rows/tlf.tsx @@ -2,8 +2,7 @@ import * as T from '@/constants/types' import {useOpen} from '@/fs/common/use-open' import {rowStyles, StillCommon} from './common' import * as Kb from '@/common-adapters' -import {useFsPathMetadata, TlfInfoLine, Filename} from '@/fs/common' -import {useFSState} from '@/stores/fs' +import {useFsPathMetadata, useFsTlf, TlfInfoLine, Filename} from '@/fs/common' import * as FS from '@/stores/fs' import {useCurrentUserState} from '@/stores/current-user' @@ -23,9 +22,9 @@ const FsPathMetadataLoader = ({path}: {path: T.FS.Path}) => { const TLFContainer = (p: OwnProps) => { const {tlfType, name, mixedMode, destinationPickerSource, disabled} = p - const tlf = useFSState(s => FS.getTlfFromTlfs(s.tlfs, tlfType, name)) const username = useCurrentUserState(s => s.username) const path = FS.tlfTypeAndNameToPath(tlfType, name) + const tlf = useFsTlf(path, {loadOnMount: false}) const _usernames = FS.getUsernamesFromTlfName(name).filter(name => name !== username) const onOpen = useOpen({destinationPickerSource, path}) const loadPathMetadata = tlf.syncConfig.mode !== T.FS.TlfSyncMode.Disabled diff --git a/shared/fs/browser/rows/types.tsx b/shared/fs/browser/rows/types.tsx index 533b78d04c79..889ded67ff0d 100644 --- a/shared/fs/browser/rows/types.tsx +++ b/shared/fs/browser/rows/types.tsx @@ -1,5 +1,6 @@ import type * as T from '@/constants/types' import type * as React from 'react' +import type {BrowserEditSession} from '../edit-state' export enum RowType { TlfType, @@ -30,7 +31,7 @@ export type TlfRowItem = { } export type StillRowItem = { - editID?: T.FS.EditID // empty if not being renamed + editSession?: BrowserEditSession // empty if not being renamed key: string lastModifiedTimestamp: number name: string @@ -40,8 +41,7 @@ export type StillRowItem = { } export type NewFolderRowItem = { - editType: T.FS.EditType - editID: T.FS.EditID + editSession: BrowserEditSession key: string name: string rowType: RowType.NewFolder diff --git a/shared/fs/browser/sort-state.tsx b/shared/fs/browser/sort-state.tsx new file mode 100644 index 000000000000..eab7783e5401 --- /dev/null +++ b/shared/fs/browser/sort-state.tsx @@ -0,0 +1,57 @@ +import * as React from 'react' +import * as Constants from '@/constants/fs' +import * as T from '@/constants/types' +import {useCurrentUserState} from '@/stores/current-user' + +type BrowserSortContextType = { + setSortSetting: (path: T.FS.Path, sortSetting: T.FS.SortSetting) => void + sortSettings: ReadonlyMap +} + +const BrowserSortContext = React.createContext(null) + +const getDefaultSortSetting = (path: T.FS.Path) => + T.FS.getPathLevel(path) < 3 + ? Constants.defaultTlfListPathUserSetting.sort + : Constants.defaultPathUserSetting.sort + +export const useFsBrowserSort = (path: T.FS.Path) => { + const context = React.useContext(BrowserSortContext) + return { + setSortSetting: context?.setSortSetting ?? (() => {}), + sortSetting: context?.sortSettings.get(path) ?? getDefaultSortSetting(path), + } +} + +export const FsBrowserSortProvider = ({children}: {children: React.ReactNode}) => { + const username = useCurrentUserState(s => s.username) + const usernameRef = React.useRef(username) + const [sortSettings, setSortSettings] = React.useState>( + () => new Map() + ) + + React.useEffect(() => { + if (usernameRef.current === username) { + return + } + usernameRef.current = username + setSortSettings(new Map()) + }, [username]) + + const setSortSetting = (path: T.FS.Path, sortSetting: T.FS.SortSetting) => { + setSortSettings(prevSortSettings => { + if (prevSortSettings.get(path) === sortSetting) { + return prevSortSettings + } + const nextSortSettings = new Map(prevSortSettings) + nextSortSettings.set(path, sortSetting) + return nextSortSettings + }) + } + + return ( + + {children} + + ) +} diff --git a/shared/fs/common/error-state.tsx b/shared/fs/common/error-state.tsx new file mode 100644 index 000000000000..6660438c280a --- /dev/null +++ b/shared/fs/common/error-state.tsx @@ -0,0 +1,124 @@ +import * as React from 'react' +import type * as T from '@/constants/types' +import {errorToActionOrThrow, errorToActionOrThrowWithHandlers, useFSState} from '@/stores/fs' +import {useConfigState} from '@/stores/config' + +const noopSoftError = () => {} +const noopDismissRedbar = (_index: number) => {} +const emptyErrors: ReadonlyArray = [] + +const redbarToGlobalError = (error: string) => { + useConfigState.getState().dispatch.setGlobalError(new Error(error)) +} + +const makeEmptySoftErrors = (): T.FS.SoftErrors => ({ + pathErrors: new Map(), + tlfErrors: new Map(), +}) + +type FsErrorContextType = { + dismissRedbar: (index: number) => void + errorToActionOrThrow: (error: unknown, path?: T.FS.Path) => void + errors: ReadonlyArray + redbar: (error: string) => void + setPathSoftError: (path: T.FS.Path, softError?: T.FS.SoftError) => void + setTlfSoftError: (path: T.FS.Path, softError?: T.FS.SoftError) => void + softErrors: T.FS.SoftErrors +} + +const FsErrorContext = React.createContext(null) + +export const FsErrorProvider = ({children}: {children: React.ReactNode}) => { + const [errors, setErrors] = React.useState>([]) + const [softErrors, setSoftErrors] = React.useState(makeEmptySoftErrors) + + const dismissRedbar = (index: number) => { + setErrors(prevErrors => [...prevErrors.slice(0, index), ...prevErrors.slice(index + 1)]) + } + + const redbar = (error: string) => { + setErrors(prevErrors => [...prevErrors, error]) + } + + const setPathSoftError = (path: T.FS.Path, softError?: T.FS.SoftError) => { + setSoftErrors(prevSoftErrors => { + const pathErrors = new Map(prevSoftErrors.pathErrors) + if (softError) { + pathErrors.set(path, softError) + } else { + pathErrors.delete(path) + } + return {...prevSoftErrors, pathErrors} + }) + } + + const setTlfSoftError = (path: T.FS.Path, softError?: T.FS.SoftError) => { + setSoftErrors(prevSoftErrors => { + const tlfErrors = new Map(prevSoftErrors.tlfErrors) + if (softError) { + tlfErrors.set(path, softError) + } else { + tlfErrors.delete(path) + } + return {...prevSoftErrors, tlfErrors} + }) + } + + const handleError = (error: unknown, path?: T.FS.Path) => { + const {checkKbfsDaemonRpcStatus} = useFSState.getState().dispatch + errorToActionOrThrowWithHandlers( + {checkKbfsDaemonRpcStatus, redbar, setPathSoftError, setTlfSoftError}, + error, + path + ) + } + + return ( + + {children} + + ) +} + +export const useFsErrors = () => { + const routeErrors = React.useContext(FsErrorContext) + return routeErrors?.errors ?? emptyErrors +} + +export const useFsRedbarActions = () => { + const routeErrors = React.useContext(FsErrorContext) + return routeErrors + ? { + dismissRedbar: routeErrors.dismissRedbar, + redbar: routeErrors.redbar, + } + : { + dismissRedbar: noopDismissRedbar, + redbar: redbarToGlobalError, + } +} + +export const useFsErrorActionOrThrow = () => { + const routeErrors = React.useContext(FsErrorContext) + return routeErrors?.errorToActionOrThrow ?? errorToActionOrThrow +} + +export const useFsSoftErrors = () => React.useContext(FsErrorContext)?.softErrors + +export const useFsSoftErrorActions = () => { + const routeErrors = React.useContext(FsErrorContext) + return { + setPathSoftError: routeErrors?.setPathSoftError ?? noopSoftError, + setTlfSoftError: routeErrors?.setTlfSoftError ?? noopSoftError, + } +} diff --git a/shared/fs/common/errs-container.tsx b/shared/fs/common/errs-container.tsx index ae768869412a..6212ada7a02b 100644 --- a/shared/fs/common/errs-container.tsx +++ b/shared/fs/common/errs-container.tsx @@ -1,18 +1,13 @@ import * as React from 'react' import * as Kb from '@/common-adapters' -import * as C from '@/constants' -import {useFSState} from '@/stores/fs' +import {useFsErrors, useFsRedbarActions} from './error-state' const ErrsContainer = () => { - const {_errors, _dismiss} = useFSState( - C.useShallow(s => ({ - _dismiss: s.dispatch.dismissRedbar, - _errors: s.errors, - })) - ) + const errors = useFsErrors() + const {dismissRedbar} = useFsRedbarActions() const props = { - errs: _errors.map((err, i) => ({ - dismiss: () => _dismiss(i), + errs: errors.map((err, i) => ({ + dismiss: () => dismissRedbar(i), msg: err, })), } @@ -22,7 +17,7 @@ const ErrsContainer = () => { {props.errs.map((errProps, index) => ( - {props.errs.length > 1 && index !== props.errs.length && } + {props.errs.length > 1 && index !== props.errs.length - 1 && } ))} diff --git a/shared/fs/common/folder-view-filter-icon.tsx b/shared/fs/common/folder-view-filter-icon.tsx index 135aff8989f2..c9b92187a856 100644 --- a/shared/fs/common/folder-view-filter-icon.tsx +++ b/shared/fs/common/folder-view-filter-icon.tsx @@ -1,7 +1,7 @@ import * as T from '@/constants/types' import * as Kb from '@/common-adapters' import type * as Styles from '@/styles' -import {useFSState} from '@/stores/fs' +import {useFsPathItem} from './hooks' import * as FS from '@/stores/fs' type Props = { @@ -20,7 +20,7 @@ type OwnProps = Omit const Container = (ownProps: OwnProps) => { const {path} = ownProps - const pathItem = useFSState(s => FS.getPathItem(s.pathItems, path)) + const pathItem = useFsPathItem(path) const props = { ...ownProps, pathItem, diff --git a/shared/fs/common/folder-view-filter.tsx b/shared/fs/common/folder-view-filter.tsx index 8eb53a8a3329..2d31146ceab7 100644 --- a/shared/fs/common/folder-view-filter.tsx +++ b/shared/fs/common/folder-view-filter.tsx @@ -1,6 +1,6 @@ import * as T from '@/constants/types' import * as Kb from '@/common-adapters' -import {useFSState} from '@/stores/fs' +import {useFsPathItem} from './hooks' import * as FS from '@/stores/fs' type Props = { @@ -12,7 +12,7 @@ type Props = { } const FolderViewFilter = (props: Props) => { - const pathItem = useFSState(s => FS.getPathItem(s.pathItems, props.path)) + const pathItem = useFsPathItem(props.path) return FS.isFolder(props.path, pathItem) && T.FS.getPathLevel(props.path) > 1 ? ( T.FS.getPathLevel(path) > 2 || FS.hasSpecialFileElement(path) -const useFsPathSubscriptionEffect = (path: T.FS.Path, topic: T.RPCGen.PathSubscriptionTopic) => { - const {subscribePath, unsubscribe} = useFSState( - C.useShallow(s => ({ - subscribePath: s.dispatch.subscribePath, - unsubscribe: s.dispatch.unsubscribe, - })) +const emptyPathItems = new Map() + +const makeEmptyTlfs = (): T.FS.Tlfs => ({ + additionalTlfs: new Map(), + loaded: false, + private: new Map(), + public: new Map(), + team: new Map(), +}) + +const emptyTlfs = makeEmptyTlfs() + +type FsSubscription = { + count: number + subscribed: boolean + subscriptionID: string + unsubscribeTimer?: ReturnType +} + +type FsSubscriptionManager = { + subscriptions: Map +} + +type FsDataContextType = { + downloadInfos: ReadonlyMap + loadAdditionalTlf: (tlfPath: T.FS.Path) => void + loadDownloadInfo: (downloadID: string) => void + loadFolderChildren: (path: T.FS.Path, initialLoadRecursive: boolean) => void + loadPathMetadata: (path: T.FS.Path) => void + loadTlfs: () => void + pathItems: T.FS.PathItems + recordDownloadStarted: (downloadID: string, path: T.FS.Path, type: DownloadStartType) => void + subscriptionManager: FsSubscriptionManager + tlfs: T.FS.Tlfs +} + +const FsDataContext = React.createContext(null) + +type DownloadStartType = 'download' | 'share' | 'saveMedia' + +const downloadIntentFromStartType = (type: DownloadStartType): T.FS.DownloadIntent | undefined => + type === 'share' + ? T.FS.DownloadIntent.Share + : type === 'saveMedia' + ? T.FS.DownloadIntent.CameraRoll + : undefined + +type FsSharedData = { + downloadInfos: ReadonlyMap + pathItems: T.FS.PathItems + tlfs: T.FS.Tlfs +} + +const makeEmptyFsSharedData = (): FsSharedData => ({ + downloadInfos: new Map(), + pathItems: new Map(), + tlfs: makeEmptyTlfs(), +}) + +let fsSharedData = makeEmptyFsSharedData() +const fsSharedDataListeners = new Set<() => void>() +const sharedSubscriptionManager: FsSubscriptionManager = {subscriptions: new Map()} +const seenDownloadIDs = new Set() +const loadingDownloadInfos = new Set() +const loadingPathMetadata = new Set() +const loadingFolderChildren = new Set() +const loadingAdditionalTlfs = new Set() +let fsSharedDataUsername = '' +let loadTlfsInProgress = false + +const unsubscribeFsSubscription = (subscriptionID: string) => { + C.ignorePromise( + T.RPCGen.SimpleFSSimpleFSUnsubscribeRpcPromise({ + clientID: FS.clientID, + identifyBehavior: T.RPCGen.TLFIdentifyBehavior.fsGui, + subscriptionID, + }).catch(() => {}) ) - React.useEffect(() => { - if (T.FS.getPathLevel(path) < 3) { - return () => {} +} + +const resetFsSubscriptionManager = (manager: FsSubscriptionManager) => { + manager.subscriptions.forEach(subscription => { + if (subscription.unsubscribeTimer) { + clearTimeout(subscription.unsubscribeTimer) + delete subscription.unsubscribeTimer + } + if (subscription.subscribed) { + unsubscribeFsSubscription(subscription.subscriptionID) } + }) + manager.subscriptions.clear() +} - const subscriptionID = FS.makeUUID() - subscribePath(subscriptionID, path, topic) - return () => unsubscribe(subscriptionID) - }, [subscribePath, unsubscribe, path, topic]) +const subscribeFsSharedData = (listener: () => void) => { + fsSharedDataListeners.add(listener) + return () => { + fsSharedDataListeners.delete(listener) + } } -const useFsNonPathSubscriptionEffect = (topic: T.RPCGen.SubscriptionTopic) => { - const {subscribeNonPath, unsubscribe} = useFSState( - C.useShallow(s => ({ - subscribeNonPath: s.dispatch.subscribeNonPath, - unsubscribe: s.dispatch.unsubscribe, - })) +const getFsSharedDataSnapshot = () => fsSharedData + +const setFsSharedData = (updater: (prevData: FsSharedData) => FsSharedData) => { + const nextData = updater(fsSharedData) + if (nextData === fsSharedData) { + return + } + fsSharedData = nextData + fsSharedDataListeners.forEach(listener => listener()) +} + +const setDownloadInfos = ( + updater: ( + prevDownloadInfos: ReadonlyMap + ) => ReadonlyMap +) => { + setFsSharedData(prevData => { + const downloadInfos = updater(prevData.downloadInfos) + return downloadInfos === prevData.downloadInfos ? prevData : {...prevData, downloadInfos} + }) +} + +const setPathItems = (updater: (prevPathItems: T.FS.PathItems) => T.FS.PathItems) => { + setFsSharedData(prevData => { + const pathItems = updater(prevData.pathItems) + return pathItems === prevData.pathItems ? prevData : {...prevData, pathItems} + }) +} + +const setTlfs = (updater: (prevTlfs: T.FS.Tlfs) => T.FS.Tlfs) => { + setFsSharedData(prevData => { + const tlfs = updater(prevData.tlfs) + return tlfs === prevData.tlfs ? prevData : {...prevData, tlfs} + }) +} + +const resetFsSharedData = () => { + fsSharedData = makeEmptyFsSharedData() + resetFsSubscriptionManager(sharedSubscriptionManager) + seenDownloadIDs.clear() + loadingDownloadInfos.clear() + loadingPathMetadata.clear() + loadingFolderChildren.clear() + loadingAdditionalTlfs.clear() + loadTlfsInProgress = false + fsSharedDataListeners.forEach(listener => listener()) +} + +export const FsDataProvider = ({children}: {children: React.ReactNode}) => { + const username = useCurrentUserState(s => s.username) + const errorToActionOrThrow = useFsErrorActionOrThrow() + const {setPathSoftError, setTlfSoftError} = useFsSoftErrorActions() + const activeDownloadIDs = useFSState(C.useShallow(s => [...s.downloads.state.keys()])) + const {downloadInfos, pathItems, tlfs} = React.useSyncExternalStore( + subscribeFsSharedData, + getFsSharedDataSnapshot, + getFsSharedDataSnapshot ) + const subscriptionManager = sharedSubscriptionManager + + React.useLayoutEffect(() => { + if (!fsSharedDataUsername) { + fsSharedDataUsername = username + return + } + if (fsSharedDataUsername === username) { + return + } + fsSharedDataUsername = username + resetFsSharedData() + }, [username]) + + const loadDownloadInfo = (downloadID: string) => { + if (loadingDownloadInfos.has(downloadID)) { + return + } + loadingDownloadInfos.add(downloadID) + const f = async () => { + try { + const res = await T.RPCGen.SimpleFSSimpleFSGetDownloadInfoRpcPromise({ + downloadID, + }) + setDownloadInfos(prevDownloadInfos => { + const old = prevDownloadInfos.get(downloadID) + const nextDownloadInfos = new Map(prevDownloadInfos) + nextDownloadInfos.set(downloadID, { + filename: res.filename, + intent: old?.intent, + isRegularDownload: res.isRegularDownload, + path: T.FS.stringToPath('/keybase' + res.path.path), + startTime: res.startTime, + }) + return nextDownloadInfos + }) + } catch (error) { + errorToActionOrThrow(error) + } finally { + loadingDownloadInfos.delete(downloadID) + } + } + C.ignorePromise(f()) + } + + const recordDownloadStarted = (downloadID: string, path: T.FS.Path, type: DownloadStartType) => { + const downloadIntent = downloadIntentFromStartType(type) + setDownloadInfos(prevDownloadInfos => { + const old = prevDownloadInfos.get(downloadID) + const nextDownloadInfos = new Map(prevDownloadInfos) + nextDownloadInfos.set(downloadID, { + filename: old?.filename ?? T.FS.getPathName(path), + intent: downloadIntent ?? old?.intent, + isRegularDownload: type === 'download', + path, + startTime: old?.startTime ?? 0, + }) + return nextDownloadInfos + }) + } + + const loadMissingDownloadInfo = React.useEffectEvent((downloadID: string) => { + if (!downloadInfos.has(downloadID)) { + loadDownloadInfo(downloadID) + } + }) React.useEffect(() => { + const activeDownloadIDSet = new Set(activeDownloadIDs) + setDownloadInfos(prevDownloadInfos => { + let nextDownloadInfos: Map | undefined + prevDownloadInfos.forEach((_, downloadID) => { + if (activeDownloadIDSet.has(downloadID) || !seenDownloadIDs.has(downloadID)) { + return + } + nextDownloadInfos ??= new Map(prevDownloadInfos) + nextDownloadInfos.delete(downloadID) + seenDownloadIDs.delete(downloadID) + }) + return nextDownloadInfos ?? prevDownloadInfos + }) + activeDownloadIDs.forEach(downloadID => { + seenDownloadIDs.add(downloadID) + }) + activeDownloadIDs.forEach(loadMissingDownloadInfo) + }, [activeDownloadIDs]) + + const loadPathMetadata = (path: T.FS.Path) => { + if (loadingPathMetadata.has(path)) { + return + } + loadingPathMetadata.add(path) + const f = async () => { + try { + const dirent = await T.RPCGen.SimpleFSSimpleFSStatRpcPromise({ + path: FS.pathToRPCPath(path), + refreshSubscription: false, + }) + const pathItem = makeEntry(dirent) + setPathItems(prevPathItems => { + const nextPathItems = new Map(prevPathItems) + const oldPathItem = FS.getPathItem(prevPathItems, path) + nextPathItems.set(path, updatePathItem(oldPathItem, pathItem)) + return nextPathItems + }) + setPathSoftError(path) + const tlfPath = FS.getTlfPath(path) + tlfPath && setTlfSoftError(tlfPath) + } catch (error) { + errorToActionOrThrow(error, path) + } finally { + loadingPathMetadata.delete(path) + } + } + C.ignorePromise(f()) + } + + const loadFolderChildren = (rootPath: T.FS.Path, initialLoadRecursive: boolean) => { + const loadKey = `${rootPath}:${initialLoadRecursive ? 'recursive' : 'shallow'}` + if (loadingFolderChildren.has(loadKey)) { + return + } + loadingFolderChildren.add(loadKey) + const f = async () => { + try { + const opID = FS.makeUUID() + if (initialLoadRecursive) { + await T.RPCGen.SimpleFSSimpleFSListRecursiveToDepthRpcPromise({ + depth: 1, + filter: T.RPCGen.ListFilter.filterSystemHidden, + opID, + path: FS.pathToRPCPath(rootPath), + refreshSubscription: false, + }) + } else { + await T.RPCGen.SimpleFSSimpleFSListRpcPromise({ + filter: T.RPCGen.ListFilter.filterSystemHidden, + opID, + path: FS.pathToRPCPath(rootPath), + refreshSubscription: false, + }) + } + + await T.RPCGen.SimpleFSSimpleFSWaitRpcPromise({opID}) + const result = await T.RPCGen.SimpleFSSimpleFSReadListRpcPromise({opID}) + const entries = result.entries || [] + + setPathItems(prevPathItems => { + const nextPathItems = new Map(prevPathItems) + const loadedPathItems = makePathItemsFromDirents({ + entries, + isRecursive: initialLoadRecursive, + rootPath, + rootPathItem: FS.getPathItem(prevPathItems, rootPath), + }) + loadedPathItems.forEach((pathItemFromAction, path) => { + const oldPathItem = FS.getPathItem(nextPathItems, path) + const nextPathItem = updatePathItem(oldPathItem, pathItemFromAction) + if (oldPathItem.type === T.FS.PathType.Folder) { + oldPathItem.children.forEach(name => { + if (nextPathItem.type !== T.FS.PathType.Folder || !nextPathItem.children.has(name)) { + nextPathItems.delete(T.FS.pathConcat(path, name)) + } + }) + } + nextPathItems.set(path, nextPathItem) + }) + return nextPathItems + }) + } catch (error) { + errorToActionOrThrow(error, rootPath) + } finally { + loadingFolderChildren.delete(loadKey) + } + } + C.ignorePromise(f()) + } + + const loadTlfs = () => { + if (loadTlfsInProgress) { + return + } + loadTlfsInProgress = true + const f = async () => { + try { + const results = await T.RPCGen.SimpleFSSimpleFSListFavoritesRpcPromise() + setTlfs(prevTlfs => { + const nextTlfs = favoritesResultToTlfs(results, username, prevTlfs.additionalTlfs) + return isEqual(nextTlfs, prevTlfs) ? prevTlfs : nextTlfs + }) + } catch (error) { + errorToActionOrThrow(error) + } finally { + loadTlfsInProgress = false + } + } + C.ignorePromise(f()) + } + + const loadAdditionalTlf = (tlfPath: T.FS.Path) => { + if (loadingAdditionalTlfs.has(tlfPath)) { + return + } + loadingAdditionalTlfs.add(tlfPath) + const f = async () => { + if (T.FS.getPathLevel(tlfPath) !== 3) { + logger.warn('loadAdditionalTlf called on non-TLF path') + loadingAdditionalTlfs.delete(tlfPath) + return + } + try { + const result = await T.RPCGen.SimpleFSSimpleFSGetFolderRpcPromise({ + path: FS.pathToRPCPath(tlfPath).kbfs, + }) + const next = folderToTlf({ + folder: result.folder, + isFavorite: result.isFavorite, + isIgnored: result.isIgnored, + isNew: result.isNew, + username, + }) + if (!next) { + return + } + setTlfs(prevTlfs => { + const additionalTlfs = new Map(prevTlfs.additionalTlfs) + additionalTlfs.set(tlfPath, next.tlf) + return { + ...prevTlfs, + additionalTlfs, + } + }) + } catch (error) { + if (error instanceof RPCError && error.code === T.RPCGen.StatusCode.scteamcontactsettingsblock) { + const fields = error.fields as undefined | Array<{key?: string; value?: string}> + const users = fields?.filter(elem => elem.key === 'usernames') + const usernames = users?.map(elem => elem.value ?? '') ?? [] + C.Router2.navigateUp() + C.Router2.navigateAppend({ + name: 'contactRestricted', + params: {source: 'newFolder', usernames}, + }) + } + errorToActionOrThrow(error, tlfPath) + } finally { + loadingAdditionalTlfs.delete(tlfPath) + } + } + C.ignorePromise(f()) + } + + return ( + + {children} + + ) +} + +const useFsLoadOnMountAndFocus = ({ + enabled = true, + load, + reloadKey, +}: { + enabled?: boolean + load: () => void + reloadKey?: unknown +}) => { + const connected = useFSState(s => s.kbfsDaemonStatus.rpcStatus === T.FS.KbfsDaemonRpcStatus.Connected) + const lastLoadRef = React.useRef<{reloadKey?: unknown; time: number}>({time: 0}) + const loadOnMountAndFocus = React.useEffectEvent(() => { + if (!connected || !enabled) { + return + } + const now = Date.now() + const lastLoad = lastLoadRef.current + if (Object.is(lastLoad.reloadKey, reloadKey) && now - lastLoad.time < 250) { + return + } + lastLoadRef.current = {reloadKey, time: now} + load() + }) + const [stableLoadOnMountAndFocus] = React.useState(() => () => { + loadOnMountAndFocus() + }) + React.useEffect(() => { + connected && enabled && loadOnMountAndFocus() + }, [connected, enabled, reloadKey]) + C.Router2.useSafeFocusEffect(stableLoadOnMountAndFocus) +} + +const subscriptionUnsubscribeDelayMs = 1000 + +const cancelScheduledSubscriptionUnsubscribe = (subscription: FsSubscription) => { + if (!subscription.unsubscribeTimer) { + return + } + clearTimeout(subscription.unsubscribeTimer) + delete subscription.unsubscribeTimer +} + +const scheduleFsSubscriptionUnsubscribeIfUnused = ( + manager: FsSubscriptionManager, + subscriptionKey: string, + subscription: FsSubscription +) => { + if (subscription.count > 0 || subscription.unsubscribeTimer) { + return + } + if (!subscription.subscribed) { + return + } + subscription.unsubscribeTimer = setTimeout(() => { + const currentSubscription = manager.subscriptions.get(subscriptionKey) + if ( + currentSubscription !== subscription || + currentSubscription.count > 0 || + !currentSubscription.subscribed + ) { + return + } + manager.subscriptions.delete(subscriptionKey) + unsubscribeFsSubscription(subscription.subscriptionID) + }, subscriptionUnsubscribeDelayMs) +} + +const releaseFsSubscription = ( + manager: FsSubscriptionManager, + subscriptionKey: string, + subscription: FsSubscription +) => { + subscription.count-- + scheduleFsSubscriptionUnsubscribeIfUnused(manager, subscriptionKey, subscription) +} + +const useFsSubscriptionEffect = ({ + enabled = true, + errorPath, + subscribe, + subscriptionKey, +}: { + enabled?: boolean + errorPath?: T.FS.Path + subscribe: (subscriptionID: string) => Promise + subscriptionKey: string +}) => { + const connected = useFSState(s => s.kbfsDaemonStatus.rpcStatus === T.FS.KbfsDaemonRpcStatus.Connected) + const routeData = React.useContext(FsDataContext) + const username = useCurrentUserState(s => s.username) + const subscriptionManager = routeData?.subscriptionManager + const errorToActionOrThrow = useFsErrorActionOrThrow() + const onError = React.useEffectEvent((error: unknown) => { + errorToActionOrThrow(error, errorPath) + }) + const subscribeEvent = React.useEffectEvent(subscribe) + React.useEffect(() => { + if (!connected || !enabled) { + return + } + + const manager = subscriptionManager + if (manager) { + const existingSubscription = manager.subscriptions.get(subscriptionKey) + if (existingSubscription) { + cancelScheduledSubscriptionUnsubscribe(existingSubscription) + existingSubscription.count++ + return () => { + const currentSubscription = manager.subscriptions.get(subscriptionKey) + if (currentSubscription !== existingSubscription) { + return + } + releaseFsSubscription(manager, subscriptionKey, currentSubscription) + } + } + } + const subscriptionID = FS.makeUUID() - subscribeNonPath(subscriptionID, topic) + const subscription = {count: 1, subscribed: false, subscriptionID} + manager?.subscriptions.set(subscriptionKey, subscription) + const removeSubscription = () => { + if (manager?.subscriptions.get(subscriptionKey) === subscription) { + manager.subscriptions.delete(subscriptionKey) + } + } + const f = async () => { + try { + const subscribed = await subscribeEvent(subscriptionID) + if (subscribed === false) { + removeSubscription() + return + } + subscription.subscribed = true + if (!manager) { + if (subscription.count === 0) { + unsubscribeFsSubscription(subscriptionID) + } + return + } + if (manager.subscriptions.get(subscriptionKey) !== subscription) { + unsubscribeFsSubscription(subscriptionID) + return + } + scheduleFsSubscriptionUnsubscribeIfUnused(manager, subscriptionKey, subscription) + } catch (error) { + removeSubscription() + onError(error) + } + } + C.ignorePromise(f()) return () => { - unsubscribe(subscriptionID) + if (!manager) { + subscription.count-- + if (subscription.subscribed) { + unsubscribeFsSubscription(subscriptionID) + } + return + } + const currentSubscription = manager.subscriptions.get(subscriptionKey) + if (currentSubscription !== subscription) { + return + } + releaseFsSubscription(manager, subscriptionKey, currentSubscription) } - }, [subscribeNonPath, unsubscribe, topic]) + }, [connected, enabled, errorPath, subscriptionKey, subscriptionManager, username]) } -export const useFsPathMetadata = (path: T.FS.Path) => { - useFsPathSubscriptionEffect(path, T.RPCGen.PathSubscriptionTopic.stat) - React.useEffect(() => { - isPathItem(path) && useFSState.getState().dispatch.loadPathMetadata(path) - }, [path]) +const useFsPathSubscriptionEffect = ( + path: T.FS.Path, + topic: T.RPCGen.PathSubscriptionTopic, + enabled = true +) => { + const pathString = T.FS.pathToString(path) + useFsSubscriptionEffect({ + enabled: enabled && T.FS.getPathLevel(path) >= 3, + errorPath: path, + subscribe: async subscriptionID => { + try { + await T.RPCGen.SimpleFSSimpleFSSubscribePathRpcPromise({ + clientID: FS.clientID, + deduplicateIntervalSecond: 1, + identifyBehavior: T.RPCGen.TLFIdentifyBehavior.fsGui, + kbfsPath: pathString, + subscriptionID, + topic, + }) + return true + } catch (error) { + if (!(error instanceof RPCError)) { + throw error + } + if (error.code !== T.RPCGen.StatusCode.scteamcontactsettingsblock) { + throw error + } + return false + } + }, + subscriptionKey: `path:${pathString}:${topic}`, + }) +} + +type FsPathItemOptions = { + loadOnMount?: boolean + subscribe?: boolean +} + +const useFsNonPathSubscriptionEffect = (topic: T.RPCGen.SubscriptionTopic, enabled = true) => { + useFsSubscriptionEffect({ + enabled, + subscribe: async subscriptionID => { + await T.RPCGen.SimpleFSSimpleFSSubscribeNonPathRpcPromise({ + clientID: FS.clientID, + deduplicateIntervalSecond: 1, + identifyBehavior: T.RPCGen.TLFIdentifyBehavior.fsGui, + subscriptionID, + topic, + }) + }, + subscriptionKey: `nonpath:${topic}`, + }) +} + +const useLoadedPathItems = () => { + const routeData = React.useContext(FsDataContext) + return routeData?.pathItems ?? emptyPathItems } -export const useFsChildren = (path: T.FS.Path, initialLoadRecursive?: boolean) => { +const useLoadedTlfs = () => { + const routeData = React.useContext(FsDataContext) + return routeData?.tlfs ?? emptyTlfs +} + +export const useFsLoadedPathItems = () => useLoadedPathItems() + +export const useFsReloadTlfs = () => { + const routeData = React.useContext(FsDataContext) + return () => { + routeData?.loadTlfs() + } +} + +export const useFsRefreshTlf = (path: T.FS.Path) => { + const routeData = React.useContext(FsDataContext) + const tlfs = useLoadedTlfs() + const tlfPath = FS.getTlfPath(path) + return () => { + if (!routeData || !tlfPath) { + return + } + if (FS.getTlfFromPathInFavoritesOnly(tlfs, tlfPath) !== FS.unknownTlf) { + routeData.loadTlfs() + return + } + routeData.loadAdditionalTlf(tlfPath) + } +} + +export const useFsPathItem = (path: T.FS.Path, options?: FsPathItemOptions) => { + const routeData = React.useContext(FsDataContext) + const pathItems = useLoadedPathItems() + const shouldSubscribe = options?.subscribe ?? (options?.loadOnMount !== false) + useFsPathSubscriptionEffect(path, T.RPCGen.PathSubscriptionTopic.stat, shouldSubscribe) + const pathItem = FS.getPathItem(pathItems, path) + const loadPathMetadata = routeData?.loadPathMetadata + const shouldLoad = !!loadPathMetadata && isPathItem(path) && options?.loadOnMount !== false + useEngineActionListener( + 'keybase.1.NotifyFS.FSSubscriptionNotifyPath', + action => { + const {clientID, path: updatedPath, topics} = action.payload.params + if ( + loadPathMetadata && + clientID === FS.clientID && + updatedPath === T.FS.pathToString(path) && + topics?.includes(T.RPCGen.PathSubscriptionTopic.stat) + ) { + loadPathMetadata(path) + } + }, + shouldLoad + ) + useFsLoadOnMountAndFocus({ + enabled: shouldLoad, + load: () => { + loadPathMetadata?.(path) + }, + reloadKey: path, + }) + return pathItem +} + +export const useFsPathMetadata = (path: T.FS.Path, options?: FsPathItemOptions) => + useFsPathItem(path, options) + +export const useFsFolderChildren = ( + path: T.FS.Path, + options?: { + initialLoadRecursive?: boolean + } +) => { + const routeData = React.useContext(FsDataContext) + const pathItems = useLoadedPathItems() useFsPathSubscriptionEffect(path, T.RPCGen.PathSubscriptionTopic.children) - const folderListLoad = useFSState(s => s.dispatch.folderListLoad) - React.useEffect(() => { - isPathItem(path) && folderListLoad(path, initialLoadRecursive || false) - }, [folderListLoad, path, initialLoadRecursive]) + const pathItem = FS.getPathItem(pathItems, path) + const loadFolderChildren = routeData?.loadFolderChildren + const initialLoadRecursive = !!options?.initialLoadRecursive + const shouldLoad = !!loadFolderChildren && isPathItem(path) + useEngineActionListener( + 'keybase.1.NotifyFS.FSSubscriptionNotifyPath', + action => { + const {clientID, path: updatedPath, topics} = action.payload.params + if ( + loadFolderChildren && + clientID === FS.clientID && + updatedPath === T.FS.pathToString(path) && + topics?.includes(T.RPCGen.PathSubscriptionTopic.children) + ) { + loadFolderChildren(path, initialLoadRecursive) + } + }, + shouldLoad + ) + useFsLoadOnMountAndFocus({ + enabled: shouldLoad, + load: () => { + loadFolderChildren?.(path, initialLoadRecursive) + }, + reloadKey: `${path}:${initialLoadRecursive ? 'recursive' : 'shallow'}`, + }) + return pathItem +} + +export const useFsChildren = (path: T.FS.Path, initialLoadRecursive?: boolean) => + useFsFolderChildren(path, {initialLoadRecursive}) + +export const useFsFolderChildItems = ( + path: T.FS.Path, + options?: { + initialLoadRecursive?: boolean + } +) => { + const pathItem = useFsFolderChildren(path, options) + const pathItems = useLoadedPathItems() + const childPaths = + pathItem.type === T.FS.PathType.Folder + ? [...pathItem.children].map(name => T.FS.pathConcat(path, name)) + : [] + const childItems = childPaths.map(childPath => FS.getPathItem(pathItems, childPath)) + return {childItems, childPaths, pathItem} } export const useFsTlfs = () => { - useFsNonPathSubscriptionEffect(T.RPCGen.SubscriptionTopic.favorites) - const favoritesLoad = useFSState(s => s.dispatch.favoritesLoad) - React.useEffect(() => { - favoritesLoad() - }, [favoritesLoad]) + const routeData = React.useContext(FsDataContext) + const loadTlfs = routeData?.loadTlfs + useFsNonPathSubscriptionEffect(T.RPCGen.SubscriptionTopic.favorites, !!loadTlfs) + const tlfs = useLoadedTlfs() + useEngineActionListener( + 'keybase.1.NotifyFS.FSSubscriptionNotify', + action => { + const {clientID, topic} = action.payload.params + if (clientID === FS.clientID && topic === T.RPCGen.SubscriptionTopic.favorites) { + loadTlfs?.() + } + }, + !!loadTlfs + ) + useFsLoadOnMountAndFocus({ + enabled: !!loadTlfs, + load: () => { + loadTlfs?.() + }, + }) + return tlfs } -export const useFsTlf = (path: T.FS.Path) => { +export const useFsTlf = (path: T.FS.Path, options?: {loadOnMount?: boolean}) => { + const routeData = React.useContext(FsDataContext) const tlfPath = FS.getTlfPath(path) - const {tlfs, loadAdditionalTlf} = useFSState( - C.useShallow(s => ({ - loadAdditionalTlf: s.dispatch.loadAdditionalTlf, - tlfs: s.tlfs, - })) - ) + const tlfs = useFsTlfs() + const tlf = FS.getTlfFromPath(tlfs, path) + const loadAdditionalTlf = routeData?.loadAdditionalTlf const active = - // If we don't have a TLF path, we are not inside a TLF yet. So no need - // to load. + !!loadAdditionalTlf && !!tlfPath && - // If favorites are not loaded, don't load anything yet -- what we need - // might be available from favorites. tlfs.loaded && - // If TLF is part of favorites list, we already have notifications to - // cover the refresh, so no need to load here. (To be clear, - // notifications don't cover syncConfig, but we already load when user - // toggles change.) - FS.getTlfFromPathInFavoritesOnly(tlfs, tlfPath) === FS.unknownTlf - // We need to load TLFs. We don't have notifications for this rpc yet, so - // just poll on a 10s interval. + FS.getTlfFromPathInFavoritesOnly(tlfs, tlfPath) === FS.unknownTlf && + options?.loadOnMount !== false + const loadCurrentTlf = React.useEffectEvent(() => { + active && loadAdditionalTlf(tlfPath) + }) + const [stableLoadCurrentTlf] = React.useState(() => () => { + loadCurrentTlf() + }) Kb.useInterval( () => { - loadAdditionalTlf(tlfPath) + loadCurrentTlf() }, active ? 10000 : undefined ) - // useInterval doesn't trigger at beginning, so call in an effect here. React.useEffect(() => { - active && loadAdditionalTlf(tlfPath) - }, [active, loadAdditionalTlf, tlfPath]) + loadCurrentTlf() + }, [active, loadAdditionalTlf, tlfPath, tlfs.loaded]) + C.Router2.useSafeFocusEffect(stableLoadCurrentTlf) + return tlf } export const useFsOnlineStatus = () => { useFsNonPathSubscriptionEffect(T.RPCGen.SubscriptionTopic.onlineStatus) - const getOnlineStatus = useFSState(s => s.dispatch.getOnlineStatus) - React.useEffect(() => { - getOnlineStatus() - }, [getOnlineStatus]) + const getOnlineStatus = useFSState.getState().dispatch.getOnlineStatus + useFsLoadOnMountAndFocus({ + load: getOnlineStatus, + }) } -export const useFsPathInfo = (path: T.FS.Path, knownPathInfo: T.FS.PathInfo): T.FS.PathInfo => { - const pathInfo = useFSState(s => s.pathInfos.get(path) || FS.emptyPathInfo) +export const useFsPathInfo = (path: T.FS.Path, knownPathInfo = FS.emptyPathInfo): T.FS.PathInfo => { const alreadyKnown = knownPathInfo !== FS.emptyPathInfo + const pathInfoVersionRef = React.useRef(0) + const [pathInfoState, setPathInfoState] = React.useState<{ + path: T.FS.Path + pathInfo: T.FS.PathInfo + }>(() => ({path, pathInfo: alreadyKnown ? knownPathInfo : FS.emptyPathInfo})) React.useEffect(() => { if (alreadyKnown) { - useFSState.getState().dispatch.loadedPathInfo(path, knownPathInfo) - } else if (pathInfo === FS.emptyPathInfo) { - // We only need to load if it's empty. This never changes once we have - // it. - useFSState.getState().dispatch.loadPathInfo(path) + pathInfoVersionRef.current += 1 + setPathInfoState({path, pathInfo: knownPathInfo}) } - }, [path, alreadyKnown, knownPathInfo, pathInfo]) - return alreadyKnown ? knownPathInfo : pathInfo + }, [alreadyKnown, knownPathInfo, path]) + useFsLoadOnMountAndFocus({ + enabled: !alreadyKnown, + load: () => { + const version = ++pathInfoVersionRef.current + const requestPath = path + const f = async () => { + const nextPathInfo = await T.RPCGen.kbfsMountGetKBFSPathInfoRpcPromise({ + standardPath: T.FS.pathToString(requestPath), + }) + if (pathInfoVersionRef.current !== version) { + return + } + setPathInfoState({ + path: requestPath, + pathInfo: { + deeplinkPath: nextPathInfo.deeplinkPath, + platformAfterMountPath: nextPathInfo.platformAfterMountPath, + }, + }) + } + C.ignorePromise(f()) + }, + reloadKey: path, + }) + return alreadyKnown + ? knownPathInfo + : pathInfoState.path === path + ? pathInfoState.pathInfo + : FS.emptyPathInfo } export const useFsSoftError = (path: T.FS.Path): T.FS.SoftError | undefined => { - const softErrors = useFSState(s => s.softErrors) - return FS.getSoftError(softErrors, path) + const softErrors = useFsSoftErrors() + return softErrors ? FS.getSoftError(softErrors, path) : undefined } export const useFsDownloadInfo = (downloadID: string): T.FS.DownloadInfo => { - const {info, loadDownloadInfo} = useFSState( - C.useShallow(s => ({ - info: s.downloads.info.get(downloadID) || FS.emptyDownloadInfo, - loadDownloadInfo: s.dispatch.loadDownloadInfo, - })) - ) - React.useEffect(() => { - // This never changes, so simply just load it once. - downloadID && loadDownloadInfo(downloadID) - }, [downloadID, loadDownloadInfo]) + const routeData = React.useContext(FsDataContext) + const info = routeData?.downloadInfos.get(downloadID) || FS.emptyDownloadInfo + useFsLoadOnMountAndFocus({ + enabled: !!downloadID && !!routeData, + load: () => routeData?.loadDownloadInfo(downloadID), + reloadKey: downloadID, + }) return info } +export const useFsDownloadIntent = (path: T.FS.Path): T.FS.DownloadIntent | undefined => { + const routeData = React.useContext(FsDataContext) + const downloadStates = useFSState(s => s.downloads.state) + return routeData ? FS.getDownloadIntent(path, routeData.downloadInfos, downloadStates) : undefined +} + export const useFsDownloadStatus = () => { useFsNonPathSubscriptionEffect(T.RPCGen.SubscriptionTopic.downloadStatus) const {loadDownloadStatus} = useFSState( @@ -152,53 +944,153 @@ export const useFsDownloadStatus = () => { loadDownloadStatus: s.dispatch.loadDownloadStatus, })) ) - React.useEffect(() => { - loadDownloadStatus() - }, [loadDownloadStatus]) + useFsLoadOnMountAndFocus({ + load: loadDownloadStatus, + }) } -export const useFsFileContext = (path: T.FS.Path) => { - const {pathItem, loadFileContext} = useFSState( - C.useShallow(s => ({ - loadFileContext: s.dispatch.loadFileContext, - pathItem: FS.getPathItem(s.pathItems, path), - })) - ) +export const useFsDownload = () => { + const routeData = React.useContext(FsDataContext) + return ( + path: T.FS.Path, + type: DownloadStartType, + onStarted?: (downloadID: string, downloadIntent?: T.FS.DownloadIntent) => void + ) => { + const f = async () => { + await requestPermissionsToWrite() + const downloadID = await T.RPCGen.SimpleFSSimpleFSStartDownloadRpcPromise({ + isRegularDownload: type === 'download', + path: FS.pathToRPCPath(path).kbfs, + }) + const downloadIntent = downloadIntentFromStartType(type) + routeData?.recordDownloadStarted(downloadID, path, type) + onStarted?.(downloadID, downloadIntent) + } + C.ignorePromise(f()) + } +} + +export const useFsCancelDownload = () => { + return (downloadID: string) => { + const f = async () => { + await T.RPCGen.SimpleFSSimpleFSCancelDownloadRpcPromise({downloadID}) + } + C.ignorePromise(f()) + } +} + +export const useFsDismissDownload = () => { + return (downloadID: string) => { + const f = async () => { + await T.RPCGen.SimpleFSSimpleFSDismissDownloadRpcPromise({downloadID}) + } + C.ignorePromise(f()) + } +} + +export const useFsUpload = () => { + const errorToActionOrThrow = useFsErrorActionOrThrow() + return (parentPath: T.FS.Path, localPath: string) => { + const f = async () => { + try { + await T.RPCGen.SimpleFSSimpleFSStartUploadRpcPromise({ + sourceLocalPath: T.FS.getNormalizedLocalPath(localPath), + targetParentPath: FS.pathToRPCPath(parentPath).kbfs, + }) + } catch (error) { + errorToActionOrThrow(error, parentPath) + } + } + C.ignorePromise(f()) + } +} + +export const useFsDismissUpload = () => { + return (uploadID: string) => { + const f = async () => { + try { + await T.RPCGen.SimpleFSSimpleFSDismissUploadRpcPromise({uploadID}) + } catch {} + } + C.ignorePromise(f()) + } +} + +export const useFsFileContext = ( + path: T.FS.Path +): { + fileContext: T.FS.FileContext + onUrlError: React.Dispatch> + pathItem: T.FS.PathItem +} => { + const pathItem = useFsPathItem(path) + const errorToActionOrThrow = useFsErrorActionOrThrow() + const fileContextVersionRef = React.useRef(0) const [urlError, setUrlError] = React.useState('') + const reloadKey = `${path}:${pathItem.type}:${pathItem.lastModifiedTimestamp}:${urlError}` + const [fileContextState, setFileContextState] = React.useState<{ + fileContext: T.FS.FileContext + reloadKey: string + }>(() => ({fileContext: FS.emptyFileContext, reloadKey})) + const fileContext = + fileContextState.reloadKey === reloadKey ? fileContextState.fileContext : FS.emptyFileContext React.useEffect(() => { - urlError && logger.info(`urlError: ${urlError}`) - pathItem.type === T.FS.PathType.File && loadFileContext(path) - }, [ - loadFileContext, - path, - // Intentionally depend on pathItem instead of only pathItem.type so we - // load when timestamp changes. + if (pathItem.type !== T.FS.PathType.File) { + fileContextVersionRef.current += 1 + setFileContextState({fileContext: FS.emptyFileContext, reloadKey}) + } + }, [pathItem.type, reloadKey]) + useFsLoadOnMountAndFocus({ + enabled: pathItem.type === T.FS.PathType.File, + load: () => { + const version = ++fileContextVersionRef.current + const requestReloadKey = reloadKey + const f = async () => { + try { + urlError && logger.info(`urlError: ${urlError}`) + const res = await T.RPCGen.SimpleFSSimpleFSGetGUIFileContextRpcPromise({ + path: FS.pathToRPCPath(path).kbfs, + }) + if (fileContextVersionRef.current !== version) { + return + } + setFileContextState({ + fileContext: { + contentType: res.contentType, + url: res.url, + viewType: res.viewType, + }, + reloadKey: requestReloadKey, + }) + } catch (err) { + if (fileContextVersionRef.current !== version) { + return + } + errorToActionOrThrow(err) + } + } + C.ignorePromise(f()) + }, + reloadKey, + }) + return { + fileContext, + onUrlError: setUrlError, pathItem, - // When url error happens it's possible that the URL of the item has - // changed due to HTTP server restarting. So reload in case of that. - urlError, - ]) - return setUrlError + } } export const useFsWatchDownloadForMobile = C.isMobile ? (downloadID: string, downloadIntent?: T.FS.DownloadIntent): boolean => { const dlInfo = useFsDownloadInfo(downloadID) - useFsFileContext(dlInfo.path) - - const {dismissDownload, dlState, redbar} = useFSState( - C.useShallow(s => ({ - dismissDownload: s.dispatch.dismissDownload, - dlState: s.downloads.state.get(downloadID) || FS.emptyDownloadState, - redbar: s.dispatch.redbar, - })) - ) + const {fileContext} = useFsFileContext(dlInfo.path) + const {redbar} = useFsRedbarActions() + const errorToActionOrThrow = useFsErrorActionOrThrow() + const dismissDownload = useFsDismissDownload() + + const dlState = useFSState(s => s.downloads.state.get(downloadID) || FS.emptyDownloadState) const finished = dlState !== FS.emptyDownloadState && !FS.downloadIsOngoing(dlState) - const {mimeType} = useFSState( - C.useShallow(s => ({ - mimeType: (s.fileContext.get(dlInfo.path) || FS.emptyFileContext).contentType, - })) - ) + const mimeType = fileContext.contentType const [justDoneWithIntent, setJustDoneWithIntent] = React.useState(false) const handledIntentKeyRef = React.useRef('') @@ -242,11 +1134,12 @@ export const useFsWatchDownloadForMobile = C.isMobile dismissDownload, dlInfo, dlState, - redbar, + errorToActionOrThrow, finished, mimeType, downloadID, downloadIntent, + redbar, ]) return justDoneWithIntent } diff --git a/shared/fs/common/index.tsx b/shared/fs/common/index.tsx index 1b289f9a7e5a..5138f5d6c0ea 100644 --- a/shared/fs/common/index.tsx +++ b/shared/fs/common/index.tsx @@ -13,5 +13,7 @@ export {default as UploadIcon} from './upload-icon' export {default as SystemFileManagerIntegrationPopup} from './sfmi-popup' export {default as PathInfo} from './path-info' export {default as PathItemInfo} from './path-item-info' +export {useFilesTabUploadIcon} from './use-files-tab-upload-icon' +export * from './error-state' export * from './hooks' diff --git a/shared/fs/common/item-icon.tsx b/shared/fs/common/item-icon.tsx index b77d97b68103..c858e14b5109 100644 --- a/shared/fs/common/item-icon.tsx +++ b/shared/fs/common/item-icon.tsx @@ -1,7 +1,7 @@ import * as T from '@/constants/types' import * as Kb from '@/common-adapters' import type {IconType} from '@/common-adapters/icon' -import {useFSState} from '@/stores/fs' +import {useFsDownloadIntent, useFsPathItem, useFsTlfs} from './hooks' import * as FS from '@/stores/fs' export type Size = 96 | 48 | 32 | 16 @@ -74,7 +74,8 @@ const getTlfTypeIcon = (size: Size, tlfType: T.FS.TlfType) => { } const TlfTypeIcon = (props: TlfTypeIconProps) => { - const tlfList = useFSState(s => FS.getTlfListFromType(s.tlfs, props.tlfType)) + const tlfs = useFsTlfs() + const tlfList = FS.getTlfListFromType(tlfs, props.tlfType) const badgeCount = FS.computeBadgeNumberForTlfList(tlfList) const badgeStyle = badgeStyles[getIconSizeString(props.size)] return ( @@ -122,16 +123,17 @@ const TlfIcon = (props: TlfIconProps) => ( type InTlfItemIconProps = { badgeOverride?: Kb.IconType + loadOnMount?: boolean path: T.FS.Path size: Size style?: Kb.Styles.StylesCrossPlatform + subscribe?: boolean tlfTypeForFolderIconOverride?: T.FS.TlfType } const InTlfIcon = (props: InTlfItemIconProps) => { - const downloads = useFSState(s => s.downloads) - const downloadIntent = FS.getDownloadIntent(props.path, downloads) - const pathItem = useFSState(s => FS.getPathItem(s.pathItems, props.path)) + const downloadIntent = useFsDownloadIntent(props.path) + const pathItem = useFsPathItem(props.path, {loadOnMount: props.loadOnMount, subscribe: props.subscribe}) const badgeStyle = badgeStyles[getIconSizeString(props.size)] const badgeIcon = props.badgeOverride || (downloadIntent && 'icon-addon-file-downloading') return ( @@ -159,10 +161,12 @@ const InTlfIcon = (props: InTlfItemIconProps) => { export type ItemIconProps = { badgeOverride?: IconType + loadOnMount?: boolean mixedMode?: boolean path: T.FS.Path size: Size style?: Kb.Styles.StylesCrossPlatform + subscribe?: boolean } const ItemIcon = (props: ItemIconProps) => { @@ -196,9 +200,11 @@ const ItemIcon = (props: ItemIconProps) => { return ( { const KbfsPathPopup = (props: PopupProps) => { const openInFilesTab = useOpenInFilesTab(props.standardPath) const header = ( - - - - - + + + + + + + + + ) return ( diff --git a/shared/fs/common/last-modified-line.tsx b/shared/fs/common/last-modified-line.tsx index 9ee8d46d195f..d0b2804f7ac3 100644 --- a/shared/fs/common/last-modified-line.tsx +++ b/shared/fs/common/last-modified-line.tsx @@ -1,7 +1,7 @@ import * as Kb from '@/common-adapters' import type * as T from '@/constants/types' import {formatTimeForFS} from '@/util/timestamp' -import {useFSState} from '@/stores/fs' +import {useFsPathItem} from './hooks' import * as FS from '@/stores/fs' export type OwnProps = { @@ -24,7 +24,8 @@ const Username = ({mode, lastWriter}: {mode: OwnProps['mode']; lastWriter: strin const Container = (ownProps: OwnProps) => { const {path, mode} = ownProps - const _pathItem = useFSState(s => FS.getPathItem(s.pathItems, path)) + const loadPathItem = mode !== 'row' + const _pathItem = useFsPathItem(path, {loadOnMount: loadPathItem, subscribe: loadPathItem}) const lastModifiedTimestamp = _pathItem === FS.unknownPathItem ? undefined : _pathItem.lastModifiedTimestamp const lastWriter = _pathItem === FS.unknownPathItem ? undefined : _pathItem.lastWriter diff --git a/shared/fs/common/open-in-system-file-manager.tsx b/shared/fs/common/open-in-system-file-manager.tsx index 0a699067647c..50822446fb41 100644 --- a/shared/fs/common/open-in-system-file-manager.tsx +++ b/shared/fs/common/open-in-system-file-manager.tsx @@ -1,6 +1,7 @@ import * as Kb from '@/common-adapters' import * as T from '@/constants/types' import * as C from '@/constants' +import {useFsErrorActionOrThrow} from './error-state' import SystemFileManagerIntegrationPopup from './sfmi-popup' import {useFSState} from '@/stores/fs' import {openPathInSystemFileManagerDesktop} from '@/util/fs-storeless-actions' @@ -8,7 +9,8 @@ import {openPathInSystemFileManagerDesktop} from '@/util/fs-storeless-actions' type Props = {path: T.FS.Path} function OpenInSystemFileManager({path}: Props) { - const openInSystemFileManager = () => openPathInSystemFileManagerDesktop(path) + const errorToActionOrThrow = useFsErrorActionOrThrow() + const openInSystemFileManager = () => openPathInSystemFileManagerDesktop(path, errorToActionOrThrow) return ( void diff --git a/shared/fs/common/path-item-action/confirm-delete.tsx b/shared/fs/common/path-item-action/confirm-delete.tsx index f9f1e7091b37..a6a8d1838fc4 100644 --- a/shared/fs/common/path-item-action/confirm-delete.tsx +++ b/shared/fs/common/path-item-action/confirm-delete.tsx @@ -1,8 +1,9 @@ import * as Kb from '@/common-adapters' import * as T from '@/constants/types' import * as C from '@/constants' -import {useFSState} from '@/stores/fs' import * as FS from '@/stores/fs' +import {makeUUID} from '@/util/uuid' +import {useFsErrorActionOrThrow} from '../error-state' export type Props = { onBack: () => void @@ -30,12 +31,25 @@ type OwnProps = { const Container = (ownProps: OwnProps) => { const {path, mode} = ownProps - const deleteFile = useFSState(s => s.dispatch.deleteFile) + const errorToActionOrThrow = useFsErrorActionOrThrow() const navigateUp = C.Router2.navigateUp const onBack = navigateUp const onDelete = () => { if (path !== FS.defaultPath) { - deleteFile(path) + const f = async () => { + const opID = makeUUID() + try { + await T.RPCGen.SimpleFSSimpleFSRemoveRpcPromise({ + opID, + path: FS.pathToRPCPath(path), + recursive: true, + }) + await T.RPCGen.SimpleFSSimpleFSWaitRpcPromise({opID}) + } catch (error) { + errorToActionOrThrow(error, path) + } + } + C.ignorePromise(f()) } // If this is a screen menu, then we're deleting the folder we're in, // and we need to navigate up twice. diff --git a/shared/fs/common/path-item-action/confirm.tsx b/shared/fs/common/path-item-action/confirm.tsx index 0e146897a057..6d6ed360a0bf 100644 --- a/shared/fs/common/path-item-action/confirm.tsx +++ b/shared/fs/common/path-item-action/confirm.tsx @@ -1,12 +1,12 @@ -import * as C from '@/constants' import * as T from '@/constants/types' -import type {FloatingMenuProps} from './types' +import type {FloatingMenuProps, OnDownloadStarted} from './types' import * as Kb from '@/common-adapters' -import {useFSState} from '@/stores/fs' +import {useFsDownload, useFsPathItem} from '../hooks' import * as FS from '@/stores/fs' type OwnProps = { floatingMenuProps: FloatingMenuProps + onDownloadStarted: OnDownloadStarted previousView: T.FS.PathItemActionMenuView path: T.FS.Path setView: (view: T.FS.PathItemActionMenuView) => void @@ -14,16 +14,15 @@ type OwnProps = { } const Container = (ownProps: OwnProps) => { - const {path, floatingMenuProps, previousView, setView, view} = ownProps - const {download, size} = useFSState( - C.useShallow(s => { - const size = FS.getPathItem(s.pathItems, path).size - const download = s.dispatch.download - return {download, size} - }) - ) + const {onDownloadStarted, path, floatingMenuProps, previousView, setView, view} = ownProps + const size = useFsPathItem(path).size + const download = useFsDownload() const confirm = () => { - download(path, view === T.FS.PathItemActionMenuView.ConfirmSaveMedia ? 'saveMedia' : 'share') + download( + path, + view === T.FS.PathItemActionMenuView.ConfirmSaveMedia ? 'saveMedia' : 'share', + onDownloadStarted + ) setView(previousView) } const action = diff --git a/shared/fs/common/path-item-action/index.tsx b/shared/fs/common/path-item-action/index.tsx index 6a8c5e19afde..98d6fae467f8 100644 --- a/shared/fs/common/path-item-action/index.tsx +++ b/shared/fs/common/path-item-action/index.tsx @@ -3,7 +3,6 @@ import type * as T from '@/constants/types' import * as Kb from '@/common-adapters' import ChooseView from './choose-view' import type {SizeType} from '@/common-adapters/icon' -import {useFSState} from '@/stores/fs' import * as FS from '@/stores/fs' export type ClickableProps = { @@ -57,14 +56,20 @@ function IconClickable(props: ICProps) { const PathItemAction = (props: Props) => { const {initView, path, mode} = props - const setPathItemActionMenuDownload = useFSState(s => s.dispatch.setPathItemActionMenuDownload) const [previousView, setPreviousView] = React.useState(initView) const [view, setViewState] = React.useState(initView) + const [downloadState, setDownloadState] = React.useState<{ + downloadID?: string + downloadIntent?: T.FS.DownloadIntent + }>({}) const setView = (nextView: T.FS.PathItemActionMenuView) => { setPreviousView(view) setViewState(nextView) } + const onDownloadStarted = (downloadID: string, downloadIntent?: T.FS.DownloadIntent) => { + setDownloadState({downloadID, downloadIntent}) + } const makePopup = (p: Kb.Popup2Parms) => { const {attachTo, hidePopup} = p @@ -73,11 +78,14 @@ const PathItemAction = (props: Props) => { hidePopup() setPreviousView(initView) setViewState(initView) - setPathItemActionMenuDownload() + setDownloadState({}) } return ( { const onClick = () => { setPreviousView(initView) setViewState(initView) + setDownloadState({}) showPopup() } diff --git a/shared/fs/common/path-item-action/menu-container.tsx b/shared/fs/common/path-item-action/menu-container.tsx index 4ba74f2fe058..5ab36c0c37e6 100644 --- a/shared/fs/common/path-item-action/menu-container.tsx +++ b/shared/fs/common/path-item-action/menu-container.tsx @@ -1,22 +1,34 @@ import * as C from '@/constants' import * as Kb from '@/common-adapters' -import * as Kbfs from '@/fs/common/hooks' import * as React from 'react' import * as T from '@/constants/types' import * as Util from '@/util/kbfs' import Header from './header' -import type {FloatingMenuProps} from './types' +import type {FloatingMenuProps, OnDownloadStarted} from './types' +import {useFsBrowserEdits} from '@/fs/browser/edit-state' import {getRootLayout, getShareLayout} from './layout' +import {useFsErrorActionOrThrow} from '../error-state' +import { + useFsCancelDownload, + useFsDismissDownload, + useFsDownload, + useFsFileContext, + useFsReloadTlfs, + useFsWatchDownloadForMobile, +} from '../hooks' import {useFSState} from '@/stores/fs' import * as FS from '@/stores/fs' import {useCurrentUserState} from '@/stores/current-user' import {openPathInSystemFileManagerDesktop} from '@/util/fs-storeless-actions' type OwnProps = { + downloadID?: string + downloadIntent?: T.FS.DownloadIntent floatingMenuProps: FloatingMenuProps previousView: T.FS.PathItemActionMenuView path: T.FS.Path mode: 'row' | 'screen' + onDownloadStarted: OnDownloadStarted setView: (view: T.FS.PathItemActionMenuView) => void view: T.FS.PathItemActionMenuView } @@ -24,38 +36,39 @@ type OwnProps = { const needConfirm = (pathItem: T.FS.PathItem) => pathItem.type === T.FS.PathType.File && pathItem.size > 50 * 1024 * 1024 +const folderRPCFromPath = (path: T.FS.Path): T.RPCGen.FolderHandle | undefined => { + const pathElems = T.FS.getPathElements(path) + if (!pathElems.length) { + return undefined + } + const visibility = T.FS.getVisibilityFromElems(pathElems) + if (visibility === undefined) { + return undefined + } + const name = T.FS.getPathNameFromElems(pathElems) + if (!name) { + return undefined + } + return { + created: false, + folderType: T.FS.getRPCFolderTypeFromVisibility(visibility), + name, + } +} + const Container = (op: OwnProps) => { - const {path, mode, floatingMenuProps, setView, view} = op + const {downloadID, downloadIntent, path, mode, floatingMenuProps, onDownloadStarted, setView, view} = op const {hide, containerStyle, attachTo, visible} = floatingMenuProps - Kbfs.useFsFileContext(path) - const data = useFSState( - C.useShallow(s => { - const pathItem = FS.getPathItem(s.pathItems, path) - const pathItemActionMenu = s.pathItemActionMenu - const fileContext = s.fileContext.get(path) || FS.emptyFileContext - const {cancelDownload, download, newFolderRow, startRename} = s.dispatch - const {favoriteIgnore, dismissDownload} = s.dispatch - const sfmiEnabled = s.sfmi.driverStatus.type === T.FS.DriverStatusType.Enabled - return { - cancelDownload, - dismissDownload, - download, - favoriteIgnore, - fileContext, - newFolderRow, - pathItem, - pathItemActionMenu, - sfmiEnabled, - startRename, - } - }) - ) - - const {pathItem, pathItemActionMenu, fileContext, cancelDownload} = data - const {download, newFolderRow} = data - const {sfmiEnabled, favoriteIgnore, dismissDownload, startRename} = data - - const {downloadID, downloadIntent} = pathItemActionMenu + const {fileContext, pathItem} = useFsFileContext(path) + const errorToActionOrThrow = useFsErrorActionOrThrow() + const reloadTlfs = useFsReloadTlfs() + const browserEdits = useFsBrowserEdits() + const cancelDownload = useFsCancelDownload() + const dismissDownload = useFsDismissDownload() + const download = useFsDownload() + const sfmiEnabled = useFSState(s => s.sfmi.driverStatus.type === T.FS.DriverStatusType.Enabled) + const newFolderRow = browserEdits?.newFolderRow + const startRename = browserEdits?.startRename const username = useCurrentUserState(s => s.username) const getLayout = view === T.FS.PathItemActionMenuView.Share ? getShareLayout : getRootLayout const layout = getLayout(mode, path, pathItem, fileContext, username) @@ -75,7 +88,7 @@ const Container = (op: OwnProps) => { cancel() } const hideAndCancelAfter = (f: () => void) => hideAfter(cancelAfter(f)) - const itemNewFolder = layout.newFolder + const itemNewFolder = layout.newFolder && newFolderRow ? ([ { icon: 'iconfont-folder-new', @@ -109,7 +122,7 @@ const Container = (op: OwnProps) => { { icon: 'iconfont-finder', onClick: hideAndCancelAfter(() => { - openPathInSystemFileManagerDesktop(path) + openPathInSystemFileManagerDesktop(path, errorToActionOrThrow) }), title: 'Show in ' + C.fileUIName, }, @@ -137,7 +150,7 @@ const Container = (op: OwnProps) => { cancel() } : () => { - download(path, 'saveMedia') + download(path, 'saveMedia', onDownloadStarted) cancel() } return [{icon: 'iconfont-download-2', onClick, title: 'Save'}] as const @@ -189,7 +202,7 @@ const Container = (op: OwnProps) => { if (conf) { setView(T.FS.PathItemActionMenuView.ConfirmSendToOtherApp) } else { - download(path, 'share') + download(path, 'share', onDownloadStarted) } }) return [{icon: 'iconfont-share', onClick, title: 'Send to another app'}] as const @@ -227,11 +240,26 @@ const Container = (op: OwnProps) => { : [] const ignoreNeedsToWait = C.Waiting.useAnyWaiting([C.waitingKeyFSFolderList, C.waitingKeyFSStat]) + const ignoreFolder = () => { + const folder = folderRPCFromPath(path) + if (!folder) { + return + } + const f = async () => { + try { + await T.RPCGen.favoriteFavoriteIgnoreRpcPromise({folder}) + reloadTlfs() + } catch (error) { + errorToActionOrThrow(error, path) + } + } + C.ignorePromise(f()) + } const ignoreTlf = layout.ignoreTlf ? ignoreNeedsToWait ? ('disabled' as const) : cancelAfter(() => { - favoriteIgnore(path) + ignoreFolder() }) : undefined const itemIgnore = ignoreTlf @@ -248,7 +276,7 @@ const Container = (op: OwnProps) => { ] as const) : [] - const itemRename = layout.rename + const itemRename = layout.rename && startRename ? ([ { icon: 'iconfont-edit', @@ -309,7 +337,7 @@ const Container = (op: OwnProps) => { ...itemDelete, ] - const justDoneWithIntent = Kbfs.useFsWatchDownloadForMobile(downloadID || '', downloadIntent) + const justDoneWithIntent = useFsWatchDownloadForMobile(downloadID || '', downloadIntent) React.useEffect(() => { justDoneWithIntent && hide() }, [justDoneWithIntent, hide]) diff --git a/shared/fs/common/path-item-action/types.d.ts b/shared/fs/common/path-item-action/types.d.ts index 6309eab9c4c1..59c62f39f297 100644 --- a/shared/fs/common/path-item-action/types.d.ts +++ b/shared/fs/common/path-item-action/types.d.ts @@ -1,5 +1,6 @@ import type * as React from 'react' import type * as Kb from '@/common-adapters' +import type * as T from '@/constants/types' export type FloatingMenuProps = { containerStyle?: Kb.Styles.StylesCrossPlatform @@ -7,3 +8,8 @@ export type FloatingMenuProps = { visible: boolean attachTo?: React.RefObject } + +export type OnDownloadStarted = ( + downloadID: string, + downloadIntent?: T.FS.DownloadIntent +) => void diff --git a/shared/fs/common/path-item-info.tsx b/shared/fs/common/path-item-info.tsx index 114460d00ee7..ea1b01c34058 100644 --- a/shared/fs/common/path-item-info.tsx +++ b/shared/fs/common/path-item-info.tsx @@ -5,8 +5,7 @@ import TlfInfoLine from './tlf-info-line-container' import ItemIcon from './item-icon' import CommaSeparatedName from './comma-separated-name' import {pluralize} from '@/util/string' -import {useFsChildren, useFsPathMetadata, useFsOnlineStatus, useFsSoftError} from './hooks' -import {useFSState} from '@/stores/fs' +import {useFsFolderChildItems, useFsOnlineStatus, useFsPathItem, useFsSoftError} from './hooks' import * as FS from '@/stores/fs' type Props = { @@ -15,14 +14,12 @@ type Props = { } const getNumberOfFilesAndFolders = ( - pathItems: T.FS.PathItems, - path: T.FS.Path + pathItem: T.FS.PathItem, + childItems: ReadonlyArray ): {folders: number; files: number; loaded: boolean} => { - const pathItem = FS.getPathItem(pathItems, path) return pathItem.type === T.FS.PathType.Folder - ? [...pathItem.children].reduce( - ({folders, files, loaded}, p) => { - const item = FS.getPathItem(pathItems, T.FS.pathConcat(path, p)) + ? childItems.reduce( + ({folders, files, loaded}, item) => { const isFolder = item.type === T.FS.PathType.Folder const isFile = item.type !== T.FS.PathType.Folder && item !== FS.unknownPathItem return { @@ -37,9 +34,8 @@ const getNumberOfFilesAndFolders = ( } const FilesAndFoldersCount = (props: Props) => { - useFsChildren(props.path) - const pathItems = useFSState(s => s.pathItems) - const {files, folders, loaded} = getNumberOfFilesAndFolders(pathItems, props.path) + const {childItems, pathItem} = useFsFolderChildItems(props.path) + const {files, folders, loaded} = getNumberOfFilesAndFolders(pathItem, childItems) return loaded ? ( {folders ? `${folders} ${pluralize('Folder')}${files ? ', ' : ''}` : undefined} @@ -78,8 +74,7 @@ const SoftErrorBanner = ({path}: {path: T.FS.Path}) => { const PathItemInfo = (props: Props) => { useFsOnlineStatus() // when used in chat, we don't have this from Files tab - useFsPathMetadata(props.path) - const pathItem = useFSState(s => FS.getPathItem(s.pathItems, props.path)) + const pathItem = useFsPathItem(props.path) const name = ( { - const {_kbfsDaemonStatus, _pathItem, _tlf, _uploads} = useFSState( + const _pathItem = useFsPathItem(ownProps.path, { + loadOnMount: ownProps.loadOnMount, + subscribe: ownProps.subscribe, + }) + const _tlf = useFsTlf(ownProps.path, {loadOnMount: ownProps.loadOnMount}) + const {_kbfsDaemonStatus, _uploads} = useFSState( C.useShallow(s => { const _kbfsDaemonStatus = s.kbfsDaemonStatus - const _pathItem = FS.getPathItem(s.pathItems, ownProps.path) - const _tlf = FS.getTlfFromPath(s.tlfs, ownProps.path) const _uploads = s.uploads.syncingPaths - return {_kbfsDaemonStatus, _pathItem, _tlf, _uploads} + return {_kbfsDaemonStatus, _uploads} }) ) const props = { @@ -38,10 +44,11 @@ type OwnPropsTlfType = { } const PathStatusIconTlfType = (ownProps: OwnPropsTlfType) => { + const tlfs = useFsTlfs() const {_kbfsDaemonStatus, _tlfList, _uploads} = useFSState( C.useShallow(s => { const _kbfsDaemonStatus = s.kbfsDaemonStatus - const _tlfList = ownProps.tlfType ? FS.getTlfListFromType(s.tlfs, ownProps.tlfType) : new Map() + const _tlfList = ownProps.tlfType ? FS.getTlfListFromType(tlfs, ownProps.tlfType) : new Map() const _uploads = s.uploads return {_kbfsDaemonStatus, _tlfList, _uploads} }) @@ -57,13 +64,20 @@ const PathStatusIconTlfType = (ownProps: OwnPropsTlfType) => { } type OwnProps = { + loadOnMount?: boolean path: T.FS.Path showTooltipOnPressMobile?: boolean + subscribe?: boolean } const PathStatusIconConnected = (props: OwnProps) => T.FS.getPathLevel(props.path) > 2 ? ( - + ) : ( ) diff --git a/shared/fs/common/rpc-state.tsx b/shared/fs/common/rpc-state.tsx new file mode 100644 index 000000000000..46bb8b300288 --- /dev/null +++ b/shared/fs/common/rpc-state.tsx @@ -0,0 +1,324 @@ +import * as FS from '@/constants/fs' +import * as T from '@/constants/types' +import {tlfToPreferredOrder} from '@/util/kbfs' + +const tlfSyncEnabled = { + mode: T.FS.TlfSyncMode.Enabled, +} satisfies T.FS.TlfSyncEnabled + +const tlfSyncDisabled = { + mode: T.FS.TlfSyncMode.Disabled, +} satisfies T.FS.TlfSyncDisabled + +const makeTlfSyncPartial = ({ + enabledPaths, +}: { + enabledPaths?: T.FS.TlfSyncPartial['enabledPaths'] +}): T.FS.TlfSyncPartial => ({ + enabledPaths: [...(enabledPaths || [])], + mode: T.FS.TlfSyncMode.Partial, +}) + +const makeConflictStateNormalView = ({ + localViewTlfPaths, + resolvingConflict, + stuckInConflict, +}: Partial): T.FS.ConflictStateNormalView => ({ + localViewTlfPaths: [...(localViewTlfPaths || [])], + resolvingConflict: resolvingConflict || false, + stuckInConflict: stuckInConflict || false, + type: T.FS.ConflictStateType.NormalView, +}) + +const tlfNormalViewWithNoConflict = makeConflictStateNormalView({}) + +const makeConflictStateManualResolvingLocalView = ({ + normalViewTlfPath, +}: Partial): T.FS.ConflictStateManualResolvingLocalView => ({ + normalViewTlfPath: normalViewTlfPath || FS.defaultPath, + type: T.FS.ConflictStateType.ManualResolvingLocalView, +}) + +const makeTlf = (p: Partial): T.FS.Tlf => { + const {conflictState, isFavorite, isIgnored, isNew, name, resetParticipants, syncConfig, teamId, tlfMtime} = + p + return { + conflictState: conflictState || tlfNormalViewWithNoConflict, + isFavorite: isFavorite || false, + isIgnored: isIgnored || false, + isNew: isNew || false, + name: name || '', + resetParticipants: [...(resetParticipants || [])], + syncConfig: syncConfig || tlfSyncDisabled, + teamId: teamId || '', + tlfMtime: tlfMtime || 0, + } +} + +const rpcFolderTypeToTlfType = (rpcFolderType: T.RPCGen.FolderType) => { + switch (rpcFolderType) { + case T.RPCGen.FolderType.private: + return T.FS.TlfType.Private + case T.RPCGen.FolderType.public: + return T.FS.TlfType.Public + case T.RPCGen.FolderType.team: + return T.FS.TlfType.Team + default: + return null + } +} + +const rpcPathToPath = (rpcPath: T.RPCGen.KBFSPath) => T.FS.pathConcat(FS.defaultPath, rpcPath.path) + +const rpcConflictStateToConflictState = (rpcConflictState?: T.RPCGen.ConflictState): T.FS.ConflictState => { + if (rpcConflictState) { + if (rpcConflictState.conflictStateType === T.RPCGen.ConflictStateType.normalview) { + const nv = rpcConflictState.normalview + return makeConflictStateNormalView({ + localViewTlfPaths: (nv.localViews || []).reduce>((arr, p) => { + p.PathType === T.RPCGen.PathType.kbfs && arr.push(rpcPathToPath(p.kbfs)) + return arr + }, []), + resolvingConflict: nv.resolvingConflict, + stuckInConflict: nv.stuckInConflict, + }) + } + const nv = rpcConflictState.manualresolvinglocalview.normalView + return makeConflictStateManualResolvingLocalView({ + normalViewTlfPath: nv.PathType === T.RPCGen.PathType.kbfs ? rpcPathToPath(nv.kbfs) : FS.defaultPath, + }) + } + return tlfNormalViewWithNoConflict +} + +const getSyncConfigFromRPC = ( + tlfName: string, + tlfType: T.FS.TlfType, + config?: T.RPCGen.FolderSyncConfig +): T.FS.TlfSyncConfig => { + if (!config) { + return tlfSyncDisabled + } + switch (config.mode) { + case T.RPCGen.FolderSyncMode.disabled: + return tlfSyncDisabled + case T.RPCGen.FolderSyncMode.enabled: + return tlfSyncEnabled + case T.RPCGen.FolderSyncMode.partial: + return makeTlfSyncPartial({ + enabledPaths: config.paths + ? config.paths.map(str => T.FS.getPathFromRelative(tlfName, tlfType, str)) + : [], + }) + default: + return tlfSyncDisabled + } +} + +export const folderToTlf = ({ + folder, + isFavorite, + isIgnored, + isNew, + username, +}: { + folder: T.RPCGen.Folder + isFavorite: boolean + isIgnored: boolean + isNew: boolean + username: string +}): {tlf: T.FS.Tlf; tlfName: string; tlfType: T.FS.TlfType} | undefined => { + const tlfType = rpcFolderTypeToTlfType(folder.folderType) + if (!tlfType) { + return undefined + } + const tlfName = + tlfType === T.FS.TlfType.Private || tlfType === T.FS.TlfType.Public + ? tlfToPreferredOrder(folder.name, username) + : folder.name + return { + tlf: makeTlf({ + conflictState: rpcConflictStateToConflictState(folder.conflictState || undefined), + isFavorite, + isIgnored, + isNew, + name: tlfName, + resetParticipants: (folder.reset_members || []).map(({username}) => username), + syncConfig: getSyncConfigFromRPC(tlfName, tlfType, folder.syncConfig || undefined), + teamId: folder.team_id || '', + tlfMtime: folder.mtime || 0, + }), + tlfName, + tlfType, + } +} + +export const favoritesResultToTlfs = ( + results: T.RPCGen.FavoritesResult, + username: string, + additionalTlfs?: ReadonlyMap +): T.FS.Tlfs => { + const payload = { + private: new Map(), + public: new Map(), + team: new Map(), + } as const + const folders = [ + ...(results.favoriteFolders + ? [{folders: results.favoriteFolders, isFavorite: true, isIgnored: false, isNew: false}] + : []), + ...(results.ignoredFolders + ? [{folders: results.ignoredFolders, isFavorite: false, isIgnored: true, isNew: false}] + : []), + ...(results.newFolders ? [{folders: results.newFolders, isFavorite: true, isIgnored: false, isNew: true}] : []), + ] + + folders.forEach(({folders, isFavorite, isIgnored, isNew}) => + folders.forEach(folder => { + const next = folderToTlf({folder, isFavorite, isIgnored, isNew, username}) + if (!next) { + return + } + payload[next.tlfType].set(next.tlfName, next.tlf) + }) + ) + + return { + additionalTlfs: new Map(additionalTlfs), + loaded: true, + private: payload.private, + public: payload.public, + team: payload.team, + } +} + +const direntToMetadata = (d: T.RPCGen.Dirent) => ({ + lastModifiedTimestamp: d.time, + lastWriter: d.lastWriterUnverified.username, + name: d.name.split('/').pop() ?? '', + prefetchStatus: (() => { + switch (d.prefetchStatus) { + case T.RPCGen.PrefetchStatus.notStarted: + return FS.prefetchNotStarted + case T.RPCGen.PrefetchStatus.inProgress: + return { + bytesFetched: d.prefetchProgress.bytesFetched, + bytesTotal: d.prefetchProgress.bytesTotal, + endEstimate: d.prefetchProgress.endEstimate, + startTime: d.prefetchProgress.start, + state: T.FS.PrefetchState.InProgress, + } satisfies T.FS.PrefetchInProgress + case T.RPCGen.PrefetchStatus.complete: + return FS.prefetchComplete + default: + return FS.prefetchNotStarted + } + })(), + size: d.size, + writable: d.writable, +}) + +export const makeEntry = (d: T.RPCGen.Dirent, children?: Set): T.FS.PathItem => { + switch (d.direntType) { + case T.RPCGen.DirentType.dir: + return { + ...FS.emptyFolder, + ...direntToMetadata(d), + children: new Set(children || []), + progress: children ? T.FS.ProgressType.Loaded : T.FS.ProgressType.Pending, + } + case T.RPCGen.DirentType.sym: + return { + ...FS.emptySymlink, + ...direntToMetadata(d), + } + case T.RPCGen.DirentType.file: + case T.RPCGen.DirentType.exec: + return { + ...FS.emptyFile, + ...direntToMetadata(d), + } + default: + return FS.unknownPathItem + } +} + +export const updatePathItem = ( + oldPathItem: T.Immutable, + newPathItemFromAction: T.Immutable +): T.Immutable => { + if ( + oldPathItem.type === T.FS.PathType.Folder && + newPathItemFromAction.type === T.FS.PathType.Folder && + oldPathItem.progress === T.FS.ProgressType.Loaded && + newPathItemFromAction.progress === T.FS.ProgressType.Pending + ) { + return { + ...newPathItemFromAction, + children: oldPathItem.children, + progress: T.FS.ProgressType.Loaded, + } + } + return newPathItemFromAction +} + +export const makePathItemsFromDirents = ({ + entries, + isRecursive, + rootPath, + rootPathItem, +}: { + entries: ReadonlyArray + isRecursive: boolean + rootPath: T.FS.Path + rootPathItem: T.FS.PathItem +}) => { + const childMap = entries.reduce((m, d) => { + const [parent, child] = d.name.split('/') + if (child) { + const fullParent = T.FS.pathConcat(rootPath, parent ?? '') + let children = m.get(fullParent) + if (!children) { + children = new Set() + m.set(fullParent, children) + } + children.add(child) + } else { + let children = m.get(rootPath) + if (!children) { + children = new Set() + m.set(rootPath, children) + } + children.add(d.name) + } + return m + }, new Map>()) + + const direntToPathAndPathItem = (d: T.RPCGen.Dirent) => { + const path = T.FS.pathConcat(rootPath, d.name) + const entry = makeEntry(d, childMap.get(path)) + if (entry.type === T.FS.PathType.Folder && isRecursive && !d.name.includes('/')) { + return [ + path, + { + ...entry, + progress: T.FS.ProgressType.Loaded, + }, + ] as const + } + return [path, entry] as const + } + + const rootFolder: T.FS.FolderPathItem = { + ...(rootPathItem.type === T.FS.PathType.Folder + ? rootPathItem + : {...FS.emptyFolder, name: T.FS.getPathName(rootPath)}), + children: new Set(childMap.get(rootPath)), + progress: T.FS.ProgressType.Loaded, + } + + return new Map([ + ...(T.FS.getPathLevel(rootPath) > 2 ? [[rootPath, rootFolder] as const] : []), + ...entries.map(direntToPathAndPathItem), + ]) +} diff --git a/shared/fs/common/tlf-info-line-container.tsx b/shared/fs/common/tlf-info-line-container.tsx index 09803b935e53..35530a585b95 100644 --- a/shared/fs/common/tlf-info-line-container.tsx +++ b/shared/fs/common/tlf-info-line-container.tsx @@ -1,6 +1,6 @@ import * as T from '@/constants/types' import TlfInfoLine from './tlf-info-line' -import {useFSState} from '@/stores/fs' +import {useFsTlf} from './hooks' import * as FS from '@/stores/fs' import {useCurrentUserState} from '@/stores/current-user' @@ -11,7 +11,7 @@ export type OwnProps = { } const Container = (ownProps: OwnProps) => { - const _tlf = useFSState(s => FS.getTlfFromPath(s.tlfs, ownProps.path)) + const _tlf = useFsTlf(ownProps.path) const _username = useCurrentUserState(s => s.username) const resetParticipants = _tlf === FS.unknownTlf ? undefined : _tlf.resetParticipants const props = { diff --git a/shared/fs/common/upload-button.tsx b/shared/fs/common/upload-button.tsx index c782a9e0ddf6..23010a90e967 100644 --- a/shared/fs/common/upload-button.tsx +++ b/shared/fs/common/upload-button.tsx @@ -2,13 +2,13 @@ import * as T from '@/constants/types' import * as C from '@/constants' import * as Kb from '@/common-adapters' import type * as Styles from '@/styles' -import {errorToActionOrThrow, useFSState} from '@/stores/fs' -import * as FS from '@/stores/fs' import { pickAndUploadMobile as pickAndUploadInPlatform, pickDocumentsMobile as pickDocumentsInPlatform, selectFilesToUploadDesktop as selectFilesToUploadInPlatform, } from '@/stores/fs-platform' +import {useFsErrorActionOrThrow} from './error-state' +import {useFsPathItem, useFsUpload} from './hooks' type OwnProps = { path: T.FS.Path @@ -72,8 +72,9 @@ const UploadButton = (props: UploadButtonProps) => { } const Container = (ownProps: OwnProps) => { - const _pathItem = useFSState(s => FS.getPathItem(s.pathItems, ownProps.path)) - const upload = useFSState(s => s.dispatch.upload) + const _pathItem = useFsPathItem(ownProps.path) + const errorToActionOrThrow = useFsErrorActionOrThrow() + const upload = useFsUpload() const _openAndUploadBoth = () => { const f = async () => { try { diff --git a/shared/fs/common/use-files-tab-upload-icon.tsx b/shared/fs/common/use-files-tab-upload-icon.tsx new file mode 100644 index 000000000000..680df898d465 --- /dev/null +++ b/shared/fs/common/use-files-tab-upload-icon.tsx @@ -0,0 +1,112 @@ +import * as C from '@/constants' +import * as React from 'react' +import * as T from '@/constants/types' +import {useEngineActionListener} from '@/engine/action-listener' +import * as FS from '@/stores/fs' +import {useFsErrorActionOrThrow} from './error-state' + +const filesTabBadgeToUploadIcon = (badge: T.RPCGen.FilesTabBadge): T.FS.UploadIcon | undefined => { + switch (badge) { + case T.RPCGen.FilesTabBadge.awaitingUpload: + return T.FS.UploadIcon.AwaitingToUpload + case T.RPCGen.FilesTabBadge.uploadingStuck: + return T.FS.UploadIcon.UploadingStuck + case T.RPCGen.FilesTabBadge.uploading: + return T.FS.UploadIcon.Uploading + case T.RPCGen.FilesTabBadge.none: + return undefined + } +} + +export const useFilesTabUploadIcon = () => { + const connected = FS.useFSState(s => s.kbfsDaemonStatus.rpcStatus === T.FS.KbfsDaemonRpcStatus.Connected) + const connectedRef = React.useRef(connected) + const generationRef = React.useRef(0) + const errorToActionOrThrow = useFsErrorActionOrThrow() + const [uploadIcon, setUploadIcon] = React.useState(undefined) + React.useLayoutEffect(() => { + connectedRef.current = connected + }, [connected]) + const onError = React.useEffectEvent((error: unknown) => { + errorToActionOrThrow(error) + }) + const loadUploadIcon = React.useEffectEvent(() => { + if (!connectedRef.current) { + return + } + const generation = ++generationRef.current + const f = async () => { + try { + const badge = await T.RPCGen.SimpleFSSimpleFSGetFilesTabBadgeRpcPromise() + if (generation === generationRef.current && connectedRef.current) { + setUploadIcon(filesTabBadgeToUploadIcon(badge)) + } + } catch { + // Retry once; see HOTPOT-1226. + try { + const badge = await T.RPCGen.SimpleFSSimpleFSGetFilesTabBadgeRpcPromise() + if (generation === generationRef.current && connectedRef.current) { + setUploadIcon(filesTabBadgeToUploadIcon(badge)) + } + } catch {} + } + } + C.ignorePromise(f()) + }) + const [stableLoadUploadIcon] = React.useState(() => () => { + loadUploadIcon() + }) + + React.useEffect(() => { + if (!connected) { + generationRef.current++ + setUploadIcon(undefined) + return + } + loadUploadIcon() + }, [connected]) + C.Router2.useSafeFocusEffect(stableLoadUploadIcon) + + React.useEffect(() => { + if (!connected) { + return + } + const subscriptionID = FS.makeUUID() + const f = async () => { + try { + await T.RPCGen.SimpleFSSimpleFSSubscribeNonPathRpcPromise({ + clientID: FS.clientID, + deduplicateIntervalSecond: 1, + identifyBehavior: T.RPCGen.TLFIdentifyBehavior.fsGui, + subscriptionID, + topic: T.RPCGen.SubscriptionTopic.filesTabBadge, + }) + } catch (error) { + onError(error) + } + } + C.ignorePromise(f()) + return () => { + C.ignorePromise( + T.RPCGen.SimpleFSSimpleFSUnsubscribeRpcPromise({ + clientID: FS.clientID, + identifyBehavior: T.RPCGen.TLFIdentifyBehavior.fsGui, + subscriptionID, + }).catch(() => {}) + ) + } + }, [connected]) + + useEngineActionListener( + 'keybase.1.NotifyFS.FSSubscriptionNotify', + action => { + const {clientID, topic} = action.payload.params + if (clientID === FS.clientID && topic === T.RPCGen.SubscriptionTopic.filesTabBadge) { + loadUploadIcon() + } + }, + connected + ) + + return uploadIcon +} diff --git a/shared/fs/common/use-non-folder-syncing-paths.tsx b/shared/fs/common/use-non-folder-syncing-paths.tsx new file mode 100644 index 000000000000..786258dbf776 --- /dev/null +++ b/shared/fs/common/use-non-folder-syncing-paths.tsx @@ -0,0 +1,117 @@ +import * as C from '@/constants' +import * as React from 'react' +import * as T from '@/constants/types' +import * as FS from '@/stores/fs' +import {useFsLoadedPathItems} from './hooks' + +const statBatchSize = 10 +const statBatchDelayMs = 250 + +const statPathType = async (path: T.FS.Path): Promise => { + try { + const dirent = await T.RPCGen.SimpleFSSimpleFSStatRpcPromise({ + path: FS.pathToRPCPath(path), + refreshSubscription: false, + }) + return dirent.direntType === T.RPCGen.DirentType.dir ? T.FS.PathType.Folder : T.FS.PathType.File + } catch { + return T.FS.PathType.Unknown + } +} + +export const useNonFolderSyncingPaths = (syncingPaths: ReadonlySet) => { + const loadedPathItems = useFsLoadedPathItems() + const [pathTypes, _setPathTypes] = React.useState>(() => new Map()) + const inFlightPaths = React.useRef(new Set()) + const latestSyncingPaths = React.useRef(syncingPaths) + const pathTypesRef = React.useRef(pathTypes) + const syncingPathCount = syncingPaths.size + + const setPathTypes = React.useEffectEvent( + ( + updater: ( + prevPathTypes: ReadonlyMap + ) => ReadonlyMap + ) => { + _setPathTypes(prevPathTypes => { + const nextPathTypes = updater(prevPathTypes) + pathTypesRef.current = nextPathTypes + return nextPathTypes + }) + } + ) + + React.useEffect(() => { + pathTypesRef.current = pathTypes + }, [pathTypes]) + + React.useEffect(() => { + const syncingPathList = [...syncingPaths] + latestSyncingPaths.current = syncingPaths + setPathTypes(prevPathTypes => { + const nextPathTypes = new Map(prevPathTypes) + let changed = false + for (const path of prevPathTypes.keys()) { + if (!syncingPaths.has(path)) { + nextPathTypes.delete(path) + changed = true + } + } + for (const path of syncingPathList) { + const loadedType = loadedPathItems.get(path)?.type + if (loadedType && loadedType !== T.FS.PathType.Unknown && loadedType !== prevPathTypes.get(path)) { + nextPathTypes.set(path, loadedType) + changed = true + } + } + return changed ? nextPathTypes : prevPathTypes + }) + + const unresolvedPaths = syncingPathList.filter(path => { + const loadedType = loadedPathItems.get(path)?.type + return ( + (!loadedType || loadedType === T.FS.PathType.Unknown) && + !pathTypesRef.current.has(path) && + !inFlightPaths.current.has(path) + ) + }) + if (!unresolvedPaths.length) { + return + } + const f = async () => { + for (let idx = 0; idx < unresolvedPaths.length; idx += statBatchSize) { + const batch = unresolvedPaths + .slice(idx, idx + statBatchSize) + .filter(path => latestSyncingPaths.current.has(path) && !pathTypesRef.current.has(path)) + if (!batch.length) { + continue + } + batch.forEach(path => inFlightPaths.current.add(path)) + const resolvedTypes = await Promise.all( + batch.map(async path => ({ + path, + type: await statPathType(path), + })) + ) + batch.forEach(path => inFlightPaths.current.delete(path)) + setPathTypes(prevPathTypes => { + let nextPathTypes: Map | undefined + for (const {path, type} of resolvedTypes) { + if (!latestSyncingPaths.current.has(path) || prevPathTypes.get(path) === type) { + continue + } + nextPathTypes ??= new Map(prevPathTypes) + nextPathTypes.set(path, type) + } + return nextPathTypes ?? prevPathTypes + }) + if (idx + statBatchSize < unresolvedPaths.length) { + await C.timeoutPromise(statBatchDelayMs) + } + } + } + C.ignorePromise(f()) + }, [loadedPathItems, syncingPathCount, syncingPaths]) + + return [...syncingPaths].filter(path => pathTypes.get(path) !== T.FS.PathType.Folder) +} diff --git a/shared/fs/common/use-open.tsx b/shared/fs/common/use-open.tsx index e5a678fb9f27..06d199af7227 100644 --- a/shared/fs/common/use-open.tsx +++ b/shared/fs/common/use-open.tsx @@ -1,7 +1,6 @@ import * as T from '@/constants/types' import {useSafeNavigation} from '@/util/safe-navigation' -import {useFSState} from '@/stores/fs' -import * as FS from '@/stores/fs' +import {useFsPathItem} from './hooks' type Props = { destinationPickerSource?: T.FS.MoveOrCopySource | T.FS.IncomingShareSource @@ -9,7 +8,7 @@ type Props = { } export const useOpen = (props: Props) => { - const pathItems = useFSState(s => s.pathItems) + const pathItem = useFsPathItem(props.path, {loadOnMount: false, subscribe: false}) const nav = useSafeNavigation() if (!props.destinationPickerSource) { @@ -17,8 +16,7 @@ export const useOpen = (props: Props) => { } const destinationPickerSource = props.destinationPickerSource - const isFolder = - T.FS.getPathLevel(props.path) <= 3 || FS.getPathItem(pathItems, props.path).type === T.FS.PathType.Folder + const isFolder = T.FS.getPathLevel(props.path) <= 3 || pathItem.type === T.FS.PathType.Folder const canOpenInDestinationPicker = isFolder && diff --git a/shared/fs/filepreview/bare-preview.tsx b/shared/fs/filepreview/bare-preview.tsx index 70be2ae0af26..aa57a43510dc 100644 --- a/shared/fs/filepreview/bare-preview.tsx +++ b/shared/fs/filepreview/bare-preview.tsx @@ -8,12 +8,12 @@ import * as Kbfs from '../common' type OwnProps = {path: T.FS.Path} -const ConnectedBarePreview = (ownProps: OwnProps) => { +const ConnectedBarePreviewInner = (ownProps: OwnProps) => { const path = ownProps.path ?? FS.defaultPath const navigateUp = C.Router2.navigateUp const onBack = () => navigateUp() - const onUrlError = Kbfs.useFsFileContext(path) + const {onUrlError} = Kbfs.useFsFileContext(path) return ( @@ -39,6 +39,14 @@ const ConnectedBarePreview = (ownProps: OwnProps) => { ) } +const ConnectedBarePreview = (props: OwnProps) => ( + + + + + +) + const styles = Kb.Styles.styleSheetCreate( () => ({ diff --git a/shared/fs/filepreview/default-view.tsx b/shared/fs/filepreview/default-view.tsx index 8d8c1441ba20..11faaa6aa287 100644 --- a/shared/fs/filepreview/default-view.tsx +++ b/shared/fs/filepreview/default-view.tsx @@ -2,6 +2,7 @@ import * as T from '@/constants/types' import * as C from '@/constants' import * as Kb from '@/common-adapters' import {PathItemAction, LastModifiedLine, ItemIcon, type ClickableProps} from '../common' +import {useFsDownload, useFsErrorActionOrThrow, useFsFileContext} from '../common' import {hasShare} from '../common/path-item-action/layout' import * as FS from '@/stores/fs' import {useFSState} from '@/stores/fs' @@ -16,19 +17,15 @@ const Share = (p: ClickableProps) => { const Container = (ownProps: OwnProps) => { const {path} = ownProps - const {pathItem, sfmiEnabled, _download, fileContext} = useFSState( - C.useShallow(s => ({ - _download: s.dispatch.download, - fileContext: s.fileContext.get(path) || FS.emptyFileContext, - pathItem: FS.getPathItem(s.pathItems, path), - sfmiEnabled: s.sfmi.driverStatus.type === T.FS.DriverStatusType.Enabled, - })) - ) + const {fileContext, pathItem} = useFsFileContext(path) + const errorToActionOrThrow = useFsErrorActionOrThrow() + const _download = useFsDownload() + const sfmiEnabled = useFSState(s => s.sfmi.driverStatus.type === T.FS.DriverStatusType.Enabled) const download = () => { _download(path, 'download') } const showInSystemFileManager = () => { - openPathInSystemFileManagerDesktop(path) + openPathInSystemFileManagerDesktop(path, errorToActionOrThrow) } return ( diff --git a/shared/fs/filepreview/view.tsx b/shared/fs/filepreview/view.tsx index cb179970f839..9cfd0e3c6159 100644 --- a/shared/fs/filepreview/view.tsx +++ b/shared/fs/filepreview/view.tsx @@ -8,7 +8,7 @@ import AVView from './av-view' import PdfView from './pdf-view' import * as Kb from '@/common-adapters' import * as FS from '@/stores/fs' -import {useFSState} from '@/stores/fs' +import {useFsFileContext} from '../common' type Props = { path: T.FS.Path @@ -26,12 +26,7 @@ const FilePreviewView = (p: Props) => { } const FilePreviewViewContent = ({path, onUrlError}: Props) => { - const {pathItem, fileContext} = useFSState( - C.useShallow(s => ({ - fileContext: s.fileContext.get(path) || FS.emptyFileContext, - pathItem: FS.getPathItem(s.pathItems, path), - })) - ) + const {fileContext, pathItem} = useFsFileContext(path) const [loadedLastModifiedTimestamp, setLoadedLastModifiedTimestamp] = React.useState( pathItem.lastModifiedTimestamp ) @@ -71,7 +66,8 @@ const FilePreviewViewContent = ({path, onUrlError}: Props) => { // find out if resource has updated. So embed timestamp into URL to force a // reload when needed. const url = fileContext.url + `&unused_field_ts=${loadedLastModifiedTimestamp}` - switch (fileContext.viewType) { + const viewType: T.RPCGen.GUIViewType = fileContext.viewType + switch (viewType) { case T.RPCGen.GUIViewType.default: { // mobile client only supports heic now if (C.isIOS && Chat.isPathHEIC(pathItem.name)) { diff --git a/shared/fs/footer/download.tsx b/shared/fs/footer/download.tsx index 0ef67bd29f72..db6c40b1ae67 100644 --- a/shared/fs/footer/download.tsx +++ b/shared/fs/footer/download.tsx @@ -34,13 +34,9 @@ const getProgress = (dlState: T.FS.DownloadState) => ( const Download = (props: Props) => { const dlInfo = Kbfs.useFsDownloadInfo(props.downloadID) - const {dlState, dismissDownload, cancelDownload} = useFSState( - C.useShallow(s => ({ - cancelDownload: s.dispatch.cancelDownload, - dismissDownload: s.dispatch.dismissDownload, - dlState: s.downloads.state.get(props.downloadID) || FS.emptyDownloadState, - })) - ) + const dlState = useFSState(s => s.downloads.state.get(props.downloadID) || FS.emptyDownloadState) + const dismissDownload = Kbfs.useFsDismissDownload() + const cancelDownload = Kbfs.useFsCancelDownload() const open = dlState.localPath ? () => openLocalPathInSystemFileManagerDesktop(dlState.localPath) : () => {} diff --git a/shared/fs/footer/upload-container.tsx b/shared/fs/footer/upload-container.tsx index 31193f51a235..0b37eb6ea9cd 100644 --- a/shared/fs/footer/upload-container.tsx +++ b/shared/fs/footer/upload-container.tsx @@ -2,8 +2,8 @@ import * as T from '@/constants/types' import Upload from './upload' import {useUploadCountdown} from './use-upload-countdown' import * as C from '@/constants' -import * as FS from '@/stores/fs' import {useFSState} from '@/stores/fs' +import {useNonFolderSyncingPaths} from '../common/use-non-folder-syncing-paths' // NOTE flip this to show a button to debug the upload banner animations. const enableDebugUploadBanner = false as boolean @@ -26,10 +26,10 @@ const getDebugToggleShow = () => { } const UpoadContainer = () => { - const {kbfsDaemonStatus, pathItems, uploads} = useFSState( + const {kbfsDaemonStatus, uploads} = useFSState( C.useShallow(s => { - const {kbfsDaemonStatus, pathItems, uploads} = s - return {kbfsDaemonStatus, pathItems, uploads} + const {kbfsDaemonStatus, uploads} = s + return {kbfsDaemonStatus, uploads} }) ) const debugToggleShow = getDebugToggleShow() @@ -39,9 +39,7 @@ const UpoadContainer = () => { // flakes on our perception of overall upload status. // Filter out folder paths. - const filePaths = [...uploads.syncingPaths].filter( - path => FS.getPathItem(pathItems, path).type !== T.FS.PathType.Folder - ) + const filePaths = useNonFolderSyncingPaths(uploads.syncingPaths) const np = useUploadCountdown({ // We just use syncingPaths rather than merging with writingToJournal here @@ -49,7 +47,7 @@ const UpoadContainer = () => { // flakes on our perception of overall upload status. debugToggleShow, endEstimate: enableDebugUploadBanner ? (uploads.endEstimate || 0) + 32000 : uploads.endEstimate || 0, - fileName: filePaths.length === 1 ? T.FS.getPathName(filePaths[1] || T.FS.stringToPath('')) : undefined, + fileName: filePaths.length === 1 ? T.FS.getPathName(filePaths[0] || T.FS.stringToPath('')) : undefined, files: filePaths.length, isOnline: kbfsDaemonStatus.onlineStatus !== T.FS.KbfsDaemonOnlineStatus.Offline, totalSyncingBytes: uploads.totalSyncingBytes, diff --git a/shared/fs/index.tsx b/shared/fs/index.tsx index 0ac75fc9ac9b..a7ed66ebd35c 100644 --- a/shared/fs/index.tsx +++ b/shared/fs/index.tsx @@ -19,15 +19,13 @@ type ChooseComponentProps = { const ChooseComponent = (props: ChooseComponentProps) => { const {emitBarePreview} = props - const fileContext = useFSState(s => s.fileContext.get(props.path) || FS.emptyFileContext) - const bare = C.isMobile && fileContext.viewType === T.RPCGen.GUIViewType.image + const {fileContext, onUrlError} = Kbfs.useFsFileContext(props.path) + const viewType: T.RPCGen.GUIViewType = fileContext.viewType + const bare = C.isMobile && viewType === T.RPCGen.GUIViewType.image React.useEffect(() => { bare && emitBarePreview() }, [bare, emitBarePreview]) - Kbfs.useFsPathMetadata(props.path) - const onUrlError = Kbfs.useFsFileContext(props.path) - Kbfs.useFsTlfs() Kbfs.useFsOnlineStatus() Kbfs.useFsTlf(props.path) const softError = Kbfs.useFsSoftError(props.path) @@ -63,15 +61,10 @@ type OwnProps = { path?: T.FS.Path } -const Connected = (ownProps: OwnProps) => { +const ConnectedInner = (ownProps: OwnProps) => { const path = ownProps.path ?? FS.defaultPath - const {_pathItem, kbfsDaemonStatus} = useFSState( - C.useShallow(s => { - const _pathItem = FS.getPathItem(s.pathItems, path) - const kbfsDaemonStatus = s.kbfsDaemonStatus - return {_pathItem, kbfsDaemonStatus} - }) - ) + const _pathItem = Kbfs.useFsPathItem(path) + const kbfsDaemonStatus = useFSState(s => s.kbfsDaemonStatus) const navigateUp = C.Router2.navigateUp const navigateAppend = C.Router2.navigateAppend const emitBarePreview = () => { @@ -86,7 +79,17 @@ const Connected = (ownProps: OwnProps) => { path, pathType: isDefinitelyFolder ? T.FS.PathType.Folder : _pathItem.type, } - return + return ( + + ) } +const Connected = (ownProps: OwnProps) => ( + + + + + +) + export default Connected diff --git a/shared/fs/nav-header/actions.tsx b/shared/fs/nav-header/actions.tsx index 18522920348f..99c54c11c1ca 100644 --- a/shared/fs/nav-header/actions.tsx +++ b/shared/fs/nav-header/actions.tsx @@ -4,23 +4,21 @@ import * as React from 'react' import * as Kb from '@/common-adapters' import * as Kbfs from '../common' import {useModalHeaderState} from '@/stores/modal-header' -import * as FS from '@/stores/fs' -import {useFSState} from '@/stores/fs' +import {FsBrowserEditProvider} from '../browser/edit-state' type Props = { onTriggerFilterMobile: () => void path: T.FS.Path } -const FsNavHeaderRightActions = (props: Props) => { +const FsNavHeaderRightActionsInner = (props: Props) => { const {folderViewFilter, setFolderViewFilter} = useModalHeaderState( C.useShallow(s => ({ folderViewFilter: s.folderViewFilter, setFolderViewFilter: s.dispatch.setFolderViewFilter, })) ) - const softErrors = useFSState(s => s.softErrors) - const hasSoftError = !!FS.getSoftError(softErrors, props.path) + const hasSoftError = !!Kbfs.useFsSoftError(props.path) React.useEffect(() => { !Kb.Styles.isMobile && setFolderViewFilter() // mobile is handled in mobile-header.tsx }, [setFolderViewFilter, props.path]) // clear if path changes or it's a new layer of mount @@ -49,6 +47,16 @@ const FsNavHeaderRightActions = (props: Props) => { ) : null } +const FsNavHeaderRightActions = (props: Props) => ( + + + + + + + +) + export default FsNavHeaderRightActions const styles = Kb.Styles.styleSheetCreate( diff --git a/shared/fs/nav-header/main-banner.tsx b/shared/fs/nav-header/main-banner.tsx index 4c24a1390ca4..3434b32a6e1b 100644 --- a/shared/fs/nav-header/main-banner.tsx +++ b/shared/fs/nav-header/main-banner.tsx @@ -3,6 +3,7 @@ import * as Kb from '@/common-adapters' import * as T from '@/constants/types' import * as FS from '@/stores/fs' import {useFSState} from '@/stores/fs' +import {useFsErrorActionOrThrow} from '../common/error-state' import {useCurrentUserState} from '@/stores/current-user' type Props = { @@ -54,18 +55,29 @@ const styles = Kb.Styles.styleSheetCreate(() => ({ })) const ConnectedBanner = () => { - const {_kbfsDaemonStatus, _overallSyncStatus, loadPathMetadata} = useFSState( + const {_kbfsDaemonStatus, _overallSyncStatus} = useFSState( C.useShallow(s => { const _kbfsDaemonStatus = s.kbfsDaemonStatus const _overallSyncStatus = s.overallSyncStatus - const loadPathMetadata = s.dispatch.loadPathMetadata - return {_kbfsDaemonStatus, _overallSyncStatus, loadPathMetadata} + return {_kbfsDaemonStatus, _overallSyncStatus} }) ) const _name = useCurrentUserState(s => s.username) - // This LoadPathMetadata triggers a sync retry. + const errorToActionOrThrow = useFsErrorActionOrThrow() + // Stat'ing the path nudges the service to retry sync. const onRetry = () => { - loadPathMetadata(T.FS.stringToPath('/keybase/private' + _name)) + const path = T.FS.stringToPath('/keybase/private/' + _name) + const f = async () => { + try { + await T.RPCGen.SimpleFSSimpleFSStatRpcPromise({ + path: FS.pathToRPCPath(path), + refreshSubscription: false, + }) + } catch (error) { + errorToActionOrThrow(error, path) + } + } + C.ignorePromise(f()) } const props = { diff --git a/shared/fs/nav-header/mobile-header.tsx b/shared/fs/nav-header/mobile-header.tsx index 0a1df6b31702..fdddc7bed4f7 100644 --- a/shared/fs/nav-header/mobile-header.tsx +++ b/shared/fs/nav-header/mobile-header.tsx @@ -24,11 +24,11 @@ const MaybePublicTag = ({path}: {path: T.FS.Path}) => FS.hasPublicTag(path) ? : null const FilesTabStatusIcon = () => { - const uploadIcon = FS.useFSState(s => s.getUploadIconForFilesTab()) + const uploadIcon = Kbfs.useFilesTabUploadIcon() return uploadIcon ? : null } -const NavMobileHeader = (props: Props) => { +const NavMobileHeaderInner = (props: Props) => { const {expanded, folderViewFilter, setFolderViewFilter} = useModalHeaderState( C.useShallow(s => ({ expanded: s.folderViewFilter !== undefined, @@ -138,4 +138,12 @@ const styles = Kb.Styles.styleSheetCreate( }) as const ) +const NavMobileHeader = (props: Props) => ( + + + + + +) + export default NavMobileHeader diff --git a/shared/fs/nav-header/title.tsx b/shared/fs/nav-header/title.tsx index d3a7c340cc3a..c2d37ddbd70a 100644 --- a/shared/fs/nav-header/title.tsx +++ b/shared/fs/nav-header/title.tsx @@ -109,7 +109,7 @@ const MainTitle = (props: Props) => ( ) -const FsNavHeaderTitle = (props: Props) => +const FsNavHeaderTitleInner = (props: Props) => props.path === FS.defaultPath ? ( Files @@ -120,6 +120,15 @@ const FsNavHeaderTitle = (props: Props) => ) + +const FsNavHeaderTitle = (props: Props) => ( + + + + + +) + export default FsNavHeaderTitle const styles = Kb.Styles.styleSheetCreate( diff --git a/shared/fs/top-bar/loading.tsx b/shared/fs/top-bar/loading.tsx index 214ecb0a9539..fe9a5d4f3591 100644 --- a/shared/fs/top-bar/loading.tsx +++ b/shared/fs/top-bar/loading.tsx @@ -1,8 +1,7 @@ import * as T from '@/constants/types' -import * as C from '@/constants' import * as Kb from '@/common-adapters' import * as FS from '@/stores/fs' -import {useFSState} from '@/stores/fs' +import {useFsPathItem, useFsTlfs} from '../common' // The behavior is to only show spinner when user first time lands on a screen // and when don't have the data that drives it yet. Since RPCs happen @@ -22,19 +21,14 @@ const styles = Kb.Styles.styleSheetCreate( const Loading = (op: OwnProps) => { const {path} = op - const {_pathItem, _tlfsLoaded} = useFSState( - C.useShallow(s => { - const _pathItem = FS.getPathItem(s.pathItems, path) - const _tlfsLoaded = !!s.tlfs.private.size - return {_pathItem, _tlfsLoaded} - }) - ) + const pathItem = useFsPathItem(path) + const tlfs = useFsTlfs() const parsedPath = FS.parsePath(path) let show = false switch (parsedPath.kind) { case T.FS.PathKind.TlfList: - show = !_tlfsLoaded + show = !tlfs.private.size break case T.FS.PathKind.TeamTlf: case T.FS.PathKind.GroupTlf: @@ -43,11 +37,11 @@ const Loading = (op: OwnProps) => { // Only show the loading spinner when we are first-time loading a pathItem. // If we already have content to show, just don't show spinner anymore even // if we are loading. - if (_pathItem.type === T.FS.PathType.Unknown) { + if (pathItem.type === T.FS.PathType.Unknown) { show = true break } - if (_pathItem.type === T.FS.PathType.Folder && _pathItem.progress === T.FS.ProgressType.Pending) { + if (pathItem.type === T.FS.PathType.Folder && pathItem.progress === T.FS.ProgressType.Pending) { show = true break } diff --git a/shared/fs/top-bar/sort.tsx b/shared/fs/top-bar/sort.tsx index 430dafefd75f..a9ee7343dc71 100644 --- a/shared/fs/top-bar/sort.tsx +++ b/shared/fs/top-bar/sort.tsx @@ -1,8 +1,9 @@ -import * as C from '@/constants' import * as T from '@/constants/types' import * as Kb from '@/common-adapters' import * as FS from '@/stores/fs' import {useFSState} from '@/stores/fs' +import {useFsPathItem} from '../common' +import {useFsBrowserSort} from '../browser/sort-state' type OwnProps = { path: T.FS.Path @@ -30,41 +31,36 @@ const makeSortOptionItem = (sortSetting: T.FS.SortSetting, onClick?: () => void) const Container = (ownProps: OwnProps) => { const {path} = ownProps - const {_kbfsDaemonStatus, _pathItem, setSorting, _sortSetting} = useFSState( - C.useShallow(s => ({ - _kbfsDaemonStatus: s.kbfsDaemonStatus, - _pathItem: FS.getPathItem(s.pathItems, path), - _sortSetting: FS.getPathUserSetting(s.pathUserSettings, path).sort, - setSorting: s.dispatch.setSorting, - })) - ) + const pathItem = useFsPathItem(path) + const {setSortSetting, sortSetting} = useFsBrowserSort(path) + const _kbfsDaemonStatus = useFSState(s => s.kbfsDaemonStatus) - const sortSetting = FS.showSortSetting(path, _pathItem, _kbfsDaemonStatus) ? _sortSetting : undefined + const shownSortSetting = FS.showSortSetting(path, pathItem, _kbfsDaemonStatus) ? sortSetting : undefined const makePopup = (p: Kb.Popup2Parms) => { const {attachTo, hidePopup} = p const sortByNameAsc = path === FS.defaultPath ? undefined : () => { - setSorting(path, T.FS.SortSetting.NameAsc) + setSortSetting(path, T.FS.SortSetting.NameAsc) } const sortByNameDesc = path === FS.defaultPath ? undefined : () => { - setSorting(path, T.FS.SortSetting.NameDesc) + setSortSetting(path, T.FS.SortSetting.NameDesc) } const sortByTimeAsc = path === FS.defaultPath ? undefined : () => { - setSorting(path, T.FS.SortSetting.TimeAsc) + setSortSetting(path, T.FS.SortSetting.TimeAsc) } const sortByTimeDesc = path === FS.defaultPath ? undefined : () => { - setSorting(path, T.FS.SortSetting.TimeDesc) + setSortSetting(path, T.FS.SortSetting.TimeDesc) } return ( { ) } const {showPopup, popup, popupAnchor} = Kb.usePopup2(makePopup) - return sortSetting ? ( + return shownSortSetting ? ( <> - {getTextFromSortSetting(sortSetting)} + {getTextFromSortSetting(shownSortSetting)} diff --git a/shared/fs/top-bar/sync-toggle.tsx b/shared/fs/top-bar/sync-toggle.tsx index 2f04f0a111bb..63d2e0425410 100644 --- a/shared/fs/top-bar/sync-toggle.tsx +++ b/shared/fs/top-bar/sync-toggle.tsx @@ -2,8 +2,8 @@ import * as C from '@/constants' import * as T from '@/constants/types' import * as React from 'react' import * as Kb from '@/common-adapters' +import {useFsErrorActionOrThrow, useFsFolderChildren, useFsRefreshTlf, useFsTlf} from '../common' import * as FS from '@/stores/fs' -import {useFSState} from '@/stores/fs' type OwnProps = { tlfPath: T.FS.Path @@ -11,31 +11,46 @@ type OwnProps = { const Container = (ownProps: OwnProps) => { const {tlfPath} = ownProps - const {_tlfPathItem, _tlfs, setTlfSyncConfig} = useFSState( - C.useShallow(s => ({ - _tlfPathItem: FS.getPathItem(s.pathItems, ownProps.tlfPath), - _tlfs: s.tlfs, - setTlfSyncConfig: s.dispatch.setTlfSyncConfig, - })) - ) + const tlfPathItem = useFsFolderChildren(tlfPath) + const tlf = useFsTlf(tlfPath) + const errorToActionOrThrow = useFsErrorActionOrThrow() + const refreshTlf = useFsRefreshTlf(tlfPath) const waiting = C.Waiting.useAnyWaiting(C.waitingKeyFSSyncToggle) + const setTlfSyncConfig = (enabled: boolean) => { + const f = async () => { + try { + await T.RPCGen.SimpleFSSimpleFSSetFolderSyncConfigRpcPromise( + { + config: {mode: enabled ? T.RPCGen.FolderSyncMode.enabled : T.RPCGen.FolderSyncMode.disabled}, + path: FS.pathToRPCPath(tlfPath), + }, + C.waitingKeyFSSyncToggle + ) + refreshTlf() + } catch (error) { + errorToActionOrThrow(error, tlfPath) + } + } + C.ignorePromise(f()) + } + const enableSync = () => { - setTlfSyncConfig(tlfPath, true) + setTlfSyncConfig(true) } - const syncConfig = FS.getTlfFromPath(_tlfs, tlfPath).syncConfig + const syncConfig = tlf.syncConfig // Disable sync when the TLF is empty and it's not enabled yet. // Band-aid fix for when new user has a non-exisitent TLF which we // can't enable sync for yet. const hideSyncToggle = syncConfig.mode === T.FS.TlfSyncMode.Disabled && - _tlfPathItem.type === T.FS.PathType.Folder && - !_tlfPathItem.children.size + tlfPathItem.type === T.FS.PathType.Folder && + !tlfPathItem.children.size const makePopup = (p: Kb.Popup2Parms) => { const {attachTo, hidePopup, showPopup} = p const disableSync = () => { - setTlfSyncConfig(tlfPath, false) + setTlfSyncConfig(false) } return ( { const {endEstimate, files, following, kbfsDaemonStatus, totalSyncingBytes, fileName} = p - const {outOfDate, windowShownCount, conversationsToSend, remoteTlfUpdates} = p + const {outOfDate, conversationsToSend, remoteTlfUpdates} = p const {httpSrvAddress, httpSrvToken, username} = p - const refreshUserFileEdits = C.useThrottledCallback(() => { - R.remoteDispatch(RemoteGen.createUserFileEditsLoad()) - }, 5000) - - React.useEffect(() => { - if (kbfsDaemonStatus.rpcStatus !== T.FS.KbfsDaemonRpcStatus.Connected) { - return - } - refreshUserFileEdits() - }, [refreshUserFileEdits, windowShownCount, kbfsDaemonStatus.rpcStatus]) - return ( <> diff --git a/shared/menubar/remote-proxy.desktop.tsx b/shared/menubar/remote-proxy.desktop.tsx index 4f78f8bcf75c..d82ccd818fe5 100644 --- a/shared/menubar/remote-proxy.desktop.tsx +++ b/shared/menubar/remote-proxy.desktop.tsx @@ -11,14 +11,14 @@ import KB2 from '@/util/electron.desktop' import useSerializeProps from '../desktop/remote/use-serialize-props.desktop' import type {Props, Conversation, RemoteTlfUpdates} from './index.desktop' import {useColorScheme} from 'react-native' -import * as FS from '@/stores/fs' -import {useFSState} from '@/stores/fs' +import {errorToActionOrThrow, useFSState} from '@/stores/fs' import {useCurrentUserState} from '@/stores/current-user' import {useFollowerState} from '@/stores/followers' import {useDaemonState} from '@/stores/daemon' import {useDarkModeState} from '@/stores/darkmode' import {useNotifState} from '@/stores/notifications' import type * as NotifConstants from '@/stores/notifications' +import {useNonFolderSyncingPaths} from '@/fs/common/use-non-folder-syncing-paths' const {showTray} = KB2.functions @@ -28,6 +28,47 @@ type WidgetProps = { } const emptyConversations: ReadonlyArray = [] +const emptyTlfUpdates: T.FS.UserTlfUpdates = [] + +const pathFromFolderRPC = (folder: T.RPCGen.Folder): T.FS.Path => { + const visibility = T.FS.getVisibilityFromRPCFolderType(folder.folderType) + if (!visibility) return T.FS.stringToPath('') + return T.FS.stringToPath(`/keybase/${visibility}/${folder.name}`) +} + +const fsNotificationTypeToEditType = (fsNotificationType: T.RPCGen.FSNotificationType): T.FS.FileEditType => { + switch (fsNotificationType) { + case T.RPCGen.FSNotificationType.fileCreated: + return T.FS.FileEditType.Created + case T.RPCGen.FSNotificationType.fileModified: + return T.FS.FileEditType.Modified + case T.RPCGen.FSNotificationType.fileDeleted: + return T.FS.FileEditType.Deleted + case T.RPCGen.FSNotificationType.fileRenamed: + return T.FS.FileEditType.Renamed + default: + return T.FS.FileEditType.Unknown + } +} + +const userTlfHistoryRPCToState = ( + history: ReadonlyArray +): T.FS.UserTlfUpdates => + history.flatMap(folder => { + const path = pathFromFolderRPC(folder.folder) + return (folder.history ?? []).map(({writerName, edits}) => ({ + history: edits + ? edits.map(({filename, notificationType, serverTime}) => ({ + editType: fsNotificationTypeToEditType(notificationType), + filename, + serverTime, + })) + : [], + path, + serverTime: folder.serverTime, + writer: writerName, + })) + }) function useWidgetTray(p: WidgetProps) { const {desktopAppBadgeCount, widgetBadge} = p @@ -196,18 +237,71 @@ function useEnsureWidgetData( }, [widgetList]) } +function useMenubarTlfUpdates( + loggedIn: boolean, + userSwitching: boolean, + kbfsDaemonRpcStatus: T.FS.KbfsDaemonRpcStatus, + menuWindowShownCount: number +) { + const [tlfUpdates, setTlfUpdates] = React.useState(emptyTlfUpdates) + const generationRef = React.useRef(0) + const enabled = + loggedIn && + !userSwitching && + kbfsDaemonRpcStatus === T.FS.KbfsDaemonRpcStatus.Connected && + menuWindowShownCount > 0 + const enabledRef = React.useRef(enabled) + React.useLayoutEffect(() => { + enabledRef.current = enabled + }, [enabled]) + const loadUserFileEdits = C.useThrottledCallback(() => { + if (!enabledRef.current) { + return + } + const generation = ++generationRef.current + const f = async () => { + try { + const writerEdits = await T.RPCGen.SimpleFSSimpleFSUserEditHistoryRpcPromise() + if (generation !== generationRef.current || !enabledRef.current) { + return + } + setTlfUpdates(userTlfHistoryRPCToState(writerEdits || [])) + } catch (error) { + if (generation === generationRef.current && enabledRef.current) { + errorToActionOrThrow(error) + } + } + } + C.ignorePromise(f()) + }, 5000) + + React.useEffect(() => { + if (!loggedIn || userSwitching) { + generationRef.current++ + setTlfUpdates(emptyTlfUpdates) + return + } + if (!enabled) { + return + } + loadUserFileEdits() + }, [enabled, loadUserFileEdits, loggedIn, userSwitching]) + + return tlfUpdates +} + function useMenubarRemoteProps(): Props { const username = useCurrentUserState(s => s.username) - const {httpSrv, loggedIn, outOfDate, windowShownCount} = useConfigState( + const {httpSrv, loggedIn, outOfDate, userSwitching, windowShownCount} = useConfigState( C.useShallow(s => { - const {httpSrv, loggedIn, outOfDate, windowShownCount} = s - return {httpSrv, loggedIn, outOfDate, windowShownCount} + const {httpSrv, loggedIn, outOfDate, userSwitching, windowShownCount} = s + return {httpSrv, loggedIn, outOfDate, userSwitching, windowShownCount} }) ) - const {kbfsDaemonStatus, overallSyncStatus, pathItems, sfmi, tlfUpdates, uploads} = useFSState( + const {kbfsDaemonStatus, overallSyncStatus, sfmi, uploads} = useFSState( C.useShallow(s => { - const {kbfsDaemonStatus, overallSyncStatus, pathItems, sfmi, tlfUpdates, uploads} = s - return {kbfsDaemonStatus, overallSyncStatus, pathItems, sfmi, tlfUpdates, uploads} + const {kbfsDaemonStatus, overallSyncStatus, sfmi, uploads} = s + return {kbfsDaemonStatus, overallSyncStatus, sfmi, uploads} }) ) const navBadgesMap = useNotifState(s => s.navBadges) @@ -223,6 +317,13 @@ function useMenubarRemoteProps(): Props { const isDarkMode = useColorScheme() === 'dark' const {diskSpaceStatus, showingBanner} = overallSyncStatus const kbfsEnabled = sfmi.driverStatus.type === T.FS.DriverStatusType.Enabled + const menuWindowShownCount = windowShownCount.get('menu') ?? 0 + const tlfUpdates = useMenubarTlfUpdates( + loggedIn, + userSwitching, + kbfsDaemonStatus.rpcStatus, + menuWindowShownCount + ) const remoteTlfUpdates = tlfUpdates.map(t => toRemoteTlfUpdate(t, uploads)) @@ -230,9 +331,7 @@ function useMenubarRemoteProps(): Props { // We just use syncingPaths rather than merging with writingToJournal here // since journal status comes a bit slower, and merging the two causes // flakes on our perception of overall upload status. - const filePaths = [...uploads.syncingPaths].filter( - path => FS.getPathItem(pathItems, path).type !== T.FS.PathType.Folder - ) + const filePaths = useNonFolderSyncingPaths(uploads.syncingPaths) const upDown = { endEstimate: uploads.endEstimate ?? 0, @@ -262,7 +361,6 @@ function useMenubarRemoteProps(): Props { remoteTlfUpdates, showingDiskSpaceBanner: showingBanner, username, - windowShownCount: windowShownCount.get('menu') ?? 0, } } diff --git a/shared/router-v2/tab-bar.desktop.tsx b/shared/router-v2/tab-bar.desktop.tsx index d2c297b6850f..ef8abbf85236 100644 --- a/shared/router-v2/tab-bar.desktop.tsx +++ b/shared/router-v2/tab-bar.desktop.tsx @@ -14,7 +14,6 @@ import {isLinux} from '@/constants/platform' import KB2 from '@/util/electron.desktop' import './tab-bar.css' import {settingsLogOutTab} from '@/constants/settings' -import {useFSState} from '@/stores/fs' import {useNotifState} from '@/stores/notifications' import {useCurrentUserState} from '@/stores/current-user' import {useShellState} from '@/stores/shell' @@ -30,7 +29,7 @@ export type Props = { } const FilesTabBadge = () => { - const uploadIcon = useFSState(s => s.getUploadIconForFilesTab()) + const uploadIcon = Kbfs.useFilesTabUploadIcon() return uploadIcon ? : null } @@ -213,11 +212,7 @@ type TabProps = { const TabBadge = (p: {name: Tabs.Tab}) => { const {name} = p const badgeNumbers = useNotifState(s => s.navBadges) - const {fsCriticalUpdate} = useFSState( - C.useShallow(s => ({ - fsCriticalUpdate: s.criticalUpdate, - })) - ) + const fsCriticalUpdate = useShellState(s => s.fsCriticalUpdate) const badge = (badgeNumbers.get(name) ?? 0) + (name === Tabs.fsTab && fsCriticalUpdate ? 1 : 0) return badge ? : null } diff --git a/shared/settings/advanced.tsx b/shared/settings/advanced.tsx index fd7989ea6a53..fad99aa86046 100644 --- a/shared/settings/advanced.tsx +++ b/shared/settings/advanced.tsx @@ -4,7 +4,6 @@ import * as T from '@/constants/types' import * as React from 'react' import {ProxySettings} from './proxy' import {processorProfileInProgressKey, traceInProgressKey} from '@/constants/settings' -import {useFSState} from '@/stores/fs' import {useConfigState} from '@/stores/config' import {useShellState} from '@/stores/shell' import {ignorePromise, timeoutPromise} from '@/constants/utils' @@ -307,8 +306,12 @@ const processorProfileDurationSeconds = 30 const Developer = () => { const [clickCount, setClickCount] = React.useState(0) - const setDebugLevel = useFSState(s => s.dispatch.setDebugLevel) - const onExtraKBFSLogging = () => setDebugLevel('vlog2') + const onExtraKBFSLogging = () => { + const f = async () => { + await T.RPCGen.SimpleFSSimpleFSSetDebugLevelRpcPromise({level: 'vlog2'}) + } + ignorePromise(f()) + } const onToggleRuntimeStats = useConfigState(s => s.dispatch.toggleRuntimeStats) const onLabelClick = () => setClickCount(s => { diff --git a/shared/settings/archive/modal.tsx b/shared/settings/archive/modal.tsx index ef0757076a02..2a2ae5931093 100644 --- a/shared/settings/archive/modal.tsx +++ b/shared/settings/archive/modal.tsx @@ -307,7 +307,11 @@ const ArchiveModal = (p: Props) => { case 'fsPath': content = ( - + + + + + ) break diff --git a/shared/settings/files/hooks.tsx b/shared/settings/files/hooks.tsx index e8e29a846900..ee3a52e10932 100644 --- a/shared/settings/files/hooks.tsx +++ b/shared/settings/files/hooks.tsx @@ -1,24 +1,88 @@ import {defaultNotificationThreshold} from '.' import * as C from '@/constants' -import {useFSState} from '@/stores/fs' +import * as React from 'react' +import * as T from '@/constants/types' +import {useEngineActionListener} from '@/engine/action-listener' +import {clientID as fsClientID, useFSState} from '@/stores/fs' + +type FilesSettings = { + isLoading: boolean + spaceAvailableNotificationThreshold: number + syncOnCellular: boolean +} const useFiles = () => { - const {areSettingsLoading, setSpaceAvailableNotificationThreshold, spaceAvailableNotificationThreshold} = - useFSState( - C.useShallow(s => ({ - areSettingsLoading: s.settings.isLoading, - setSpaceAvailableNotificationThreshold: s.dispatch.setSpaceAvailableNotificationThreshold, - spaceAvailableNotificationThreshold: s.settings.spaceAvailableNotificationThreshold, - })) - ) + const [settings, setSettings] = React.useState(() => ({ + isLoading: true, + spaceAvailableNotificationThreshold: 0, + syncOnCellular: false, + })) + const loadSettings = React.useEffectEvent(async () => { + setSettings(s => ({...s, isLoading: true})) + try { + const next = await T.RPCGen.SimpleFSSimpleFSSettingsRpcPromise() + setSettings({ + isLoading: false, + spaceAvailableNotificationThreshold: next.spaceAvailableNotificationThreshold, + syncOnCellular: next.syncOnCellular, + }) + } catch { + setSettings(s => ({...s, isLoading: false})) + } + }) + const refreshGlobalSettings = React.useEffectEvent(() => { + useFSState.getState().dispatch.loadSettings() + }) + + React.useEffect(() => { + C.ignorePromise(loadSettings()) + }, []) + + useEngineActionListener('keybase.1.NotifyFS.FSSubscriptionNotify', action => { + const {clientID, topic} = action.payload.params + if (clientID === fsClientID && topic === T.RPCGen.SubscriptionTopic.settings) { + C.ignorePromise(loadSettings()) + } + }) + + const setSpaceAvailableNotificationThreshold = (threshold: number) => { + const f = async () => { + setSettings(s => ({...s, isLoading: true})) + try { + await T.RPCGen.SimpleFSSimpleFSSetNotificationThresholdRpcPromise({threshold}) + await loadSettings() + refreshGlobalSettings() + } catch { + setSettings(s => ({...s, isLoading: false})) + } + } + C.ignorePromise(f()) + } + const setSyncOnCellular = (syncOnCellular: boolean) => { + const f = async () => { + try { + await T.RPCGen.SimpleFSSimpleFSSetSyncOnCellularRpcPromise( + {syncOnCellular}, + C.waitingKeyFSSetSyncOnCellular + ) + await loadSettings() + refreshGlobalSettings() + } catch {} + } + C.ignorePromise(f()) + } + const onDisableSyncNotifications = () => { setSpaceAvailableNotificationThreshold(0) } return { - areSettingsLoading, + areSettingsLoading: settings.isLoading, onDisableSyncNotifications, onEnableSyncNotifications: () => setSpaceAvailableNotificationThreshold(defaultNotificationThreshold), - spaceAvailableNotificationThreshold, + setSpaceAvailableNotificationThreshold, + setSyncOnCellular, + spaceAvailableNotificationThreshold: settings.spaceAvailableNotificationThreshold, + syncOnCellular: settings.syncOnCellular, } } diff --git a/shared/settings/files/index.desktop.tsx b/shared/settings/files/index.desktop.tsx index bc92dd99855c..250428e7322a 100644 --- a/shared/settings/files/index.desktop.tsx +++ b/shared/settings/files/index.desktop.tsx @@ -4,7 +4,6 @@ import * as Kb from '@/common-adapters' import * as Platform from '@/constants/platform' import * as Kbfs from '@/fs/common' import RefreshDriverStatusOnMount from '@/fs/common/refresh-driver-status-on-mount' -import RefreshSettings from './refresh-settings' import useFiles from './hooks' import * as FS from '@/stores/fs' import {useFSState} from '@/stores/fs' @@ -15,13 +14,13 @@ export const allowedNotificationThresholds = [100 * 1024 ** 2, 1024 ** 3, 3 * 10 export const defaultNotificationThreshold = 100 * 1024 ** 2 const SyncNotificationSetting = ( - p: Pick + p: Pick< + Props, + 'spaceAvailableNotificationThreshold' | 'areSettingsLoading' | 'setSpaceAvailableNotificationThreshold' + > ) => { - const setSpaceAvailableNotificationThreshold = useFSState( - s => s.dispatch.setSpaceAvailableNotificationThreshold - ) const onChangedSyncNotifications = (selectedIdx: number) => - setSpaceAvailableNotificationThreshold(allowedNotificationThresholds[selectedIdx] ?? 0) + p.setSpaceAvailableNotificationThreshold(allowedNotificationThresholds[selectedIdx] ?? 0) const {spaceAvailableNotificationThreshold, areSettingsLoading} = p return ( @@ -136,7 +135,6 @@ const FilesSettings = () => { return ( <> - @@ -154,6 +152,7 @@ const FilesSettings = () => { } checked={props.spaceAvailableNotificationThreshold !== 0} diff --git a/shared/settings/files/index.native.tsx b/shared/settings/files/index.native.tsx index bbec8c0123d0..3e7cd95831f6 100644 --- a/shared/settings/files/index.native.tsx +++ b/shared/settings/files/index.native.tsx @@ -1,22 +1,19 @@ import * as C from '@/constants' import * as React from 'react' import * as Kb from '@/common-adapters' -import * as T from '@/constants/types' import useFiles from './hooks' import * as FS from '@/stores/fs' -import {useFSState} from '@/stores/fs' type Props = ReturnType export const allowedNotificationThresholds = [100 * 1024 ** 2, 1024 ** 3, 3 * 1024 ** 3, 10 * 1024 ** 3] export const defaultNotificationThreshold = 100 * 1024 ** 2 -const ThresholdDropdown = (p: Pick) => { +const ThresholdDropdown = ( + p: Pick +) => { const allowedThresholds = allowedNotificationThresholds.map( i => ({label: FS.humanizeBytes(i, 0), value: i}) as const ) - const setSpaceAvailableNotificationThreshold = useFSState( - s => s.dispatch.setSpaceAvailableNotificationThreshold - ) const {spaceAvailableNotificationThreshold} = p const [notificationThreshold, setNotificationThreshold] = React.useState( spaceAvailableNotificationThreshold @@ -30,7 +27,7 @@ const ThresholdDropdown = (p: Pick const hide = () => setVisible(false) const done = () => { - setSpaceAvailableNotificationThreshold(notificationThreshold) + p.setSpaceAvailableNotificationThreshold(notificationThreshold) setVisible(false) } const select = (selectedVal?: number) => selectedVal && setNotificationThreshold(selectedVal) @@ -68,16 +65,16 @@ const ThresholdDropdown = (p: Pick const Files = () => { const props = useFiles() - const {spaceAvailableNotificationThreshold, onEnableSyncNotifications, onDisableSyncNotifications} = props + const { + onDisableSyncNotifications, + onEnableSyncNotifications, + setSyncOnCellular, + spaceAvailableNotificationThreshold, + syncOnCellular, + } = props const {areSettingsLoading} = props - const syncOnCellular = useFSState(s => s.settings.syncOnCellular) const toggleSyncOnCellular = () => { - T.RPCGen.SimpleFSSimpleFSSetSyncOnCellularRpcPromise( - {syncOnCellular: !syncOnCellular}, - C.waitingKeyFSSetSyncOnCellular - ) - .then(() => {}) - .catch(() => {}) + setSyncOnCellular(!syncOnCellular) } const waitingToggleSyncOnCellular = C.Waiting.useAnyWaiting(C.waitingKeyFSSetSyncOnCellular) return ( @@ -103,6 +100,7 @@ const Files = () => { {!!spaceAvailableNotificationThreshold && ( )} { - const refresh = useFSState(s => s.dispatch.loadSettings) - - React.useEffect(() => { - refresh() - }, [refresh]) - - return null -} - -export default RefreshSettings diff --git a/shared/stores/fs-platform.d.ts b/shared/stores/fs-platform.d.ts index 20e58c9b10a6..d7662a797251 100644 --- a/shared/stores/fs-platform.d.ts +++ b/shared/stores/fs-platform.d.ts @@ -6,7 +6,6 @@ export declare const openLocalPathInSystemFileManagerDesktop: (localPath: string export declare const openPathInSystemFileManagerDesktop: ( path: T.FS.Path, - pathItems: T.FS.PathItems, driverStatus: T.FS.DriverStatus, directMountDir: string ) => Promise diff --git a/shared/stores/fs-platform.desktop.tsx b/shared/stores/fs-platform.desktop.tsx index 7b0f3ea93a5c..43477f58f27c 100644 --- a/shared/stores/fs-platform.desktop.tsx +++ b/shared/stores/fs-platform.desktop.tsx @@ -58,7 +58,6 @@ export const openLocalPathInSystemFileManagerDesktop = async (localPath: string) export const openPathInSystemFileManagerDesktop = async ( path: T.FS.Path, - pathItems: T.FS.PathItems, driverStatus: T.FS.DriverStatus, directMountDir: string ) => { @@ -70,10 +69,25 @@ export const openPathInSystemFileManagerDesktop = async ( return } + const parsedPath = Constants.parsePath(path) + let selectDirectory = ![T.FS.PathKind.InGroupTlf, T.FS.PathKind.InTeamTlf].includes(parsedPath.kind) + if (!selectDirectory) { + try { + selectDirectory = + ( + await T.RPCGen.SimpleFSSimpleFSStatRpcPromise({ + path: Constants.pathToRPCPath(path), + refreshSubscription: false, + }) + ).direntType === T.RPCGen.DirentType.dir + } catch (error) { + logger.warn('failed to stat KBFS path before opening system file manager: ', error) + } + } + await openPathInFinder?.( rebaseKbfsPathToMountLocation(path, directMountDir), - ![T.FS.PathKind.InGroupTlf, T.FS.PathKind.InTeamTlf].includes(Constants.parsePath(path).kind) || - Constants.getPathItem(pathItems, path).type === T.FS.PathType.Folder + selectDirectory ) } diff --git a/shared/stores/fs-platform.native.tsx b/shared/stores/fs-platform.native.tsx index 5ac95641d2a6..e7f044ed9d24 100644 --- a/shared/stores/fs-platform.native.tsx +++ b/shared/stores/fs-platform.native.tsx @@ -98,7 +98,6 @@ export const openLocalPathInSystemFileManagerDesktop = async (_localPath: string export const openPathInSystemFileManagerDesktop = async ( _path: T.FS.Path, - _pathItems: T.FS.PathItems, _driverStatus: T.FS.DriverStatus, _directMountDir: string ) => {} diff --git a/shared/stores/fs.tsx b/shared/stores/fs.tsx index 694331c6c7bb..e59442997435 100644 --- a/shared/stores/fs.tsx +++ b/shared/stores/fs.tsx @@ -1,18 +1,13 @@ import type * as EngineGen from '@/constants/rpc' import {ignorePromise, timeoutPromise} from '@/constants/utils' -import * as S from '@/constants/strings' -import {requestPermissionsToWrite} from '@/util/platform-specific' -import * as Tabs from '@/constants/tabs' import * as T from '@/constants/types' import * as Z from '@/util/zustand' import {NotifyPopup} from '@/util/misc' -import {RPCError} from '@/util/errors' import logger from '@/logger' import {isMobile} from '@/constants/platform' -import {tlfToPreferredOrder} from '@/util/kbfs' import isObject from 'lodash/isObject' import isEqual from 'lodash/isEqual' -import {navigateAppend, navigateUp} from '@/constants/router' +import {navigateAppend} from '@/constants/router' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import {useNotifState} from '@/stores/notifications' @@ -32,186 +27,8 @@ import { export * from '@/constants/fs' -const tlfSyncEnabled = { - mode: T.FS.TlfSyncMode.Enabled, -} satisfies T.FS.TlfSyncEnabled - -const tlfSyncDisabled = { - mode: T.FS.TlfSyncMode.Disabled, -} satisfies T.FS.TlfSyncDisabled - -const makeTlfSyncPartial = ({ - enabledPaths, -}: { - enabledPaths?: T.FS.TlfSyncPartial['enabledPaths'] -}): T.FS.TlfSyncPartial => ({ - enabledPaths: [...(enabledPaths || [])], - mode: T.FS.TlfSyncMode.Partial, -}) - -const makeConflictStateNormalView = ({ - localViewTlfPaths, - resolvingConflict, - stuckInConflict, -}: Partial): T.FS.ConflictStateNormalView => ({ - localViewTlfPaths: [...(localViewTlfPaths || [])], - resolvingConflict: resolvingConflict || false, - stuckInConflict: stuckInConflict || false, - type: T.FS.ConflictStateType.NormalView, -}) - -const tlfNormalViewWithNoConflict = makeConflictStateNormalView({}) - -const makeConflictStateManualResolvingLocalView = ({ - normalViewTlfPath, -}: Partial): T.FS.ConflictStateManualResolvingLocalView => ({ - normalViewTlfPath: normalViewTlfPath || Constants.defaultPath, - type: T.FS.ConflictStateType.ManualResolvingLocalView, -}) - -const makeTlf = (p: Partial): T.FS.Tlf => { - const {conflictState, isFavorite, isIgnored, isNew, name, resetParticipants, syncConfig, teamId, tlfMtime} = - p - return { - conflictState: conflictState || tlfNormalViewWithNoConflict, - isFavorite: isFavorite || false, - isIgnored: isIgnored || false, - isNew: isNew || false, - name: name || '', - resetParticipants: [...(resetParticipants || [])], - syncConfig: syncConfig || tlfSyncDisabled, - teamId: teamId || '', - tlfMtime: tlfMtime || 0, - /* See comment in constants/types/fs.js - needsRekey: false, - waitingForParticipantUnlock: I.List(), - youCanUnlock: I.List(), - */ - } -} - -const rpcFolderTypeToTlfType = (rpcFolderType: T.RPCGen.FolderType) => { - switch (rpcFolderType) { - case T.RPCGen.FolderType.private: - return T.FS.TlfType.Private - case T.RPCGen.FolderType.public: - return T.FS.TlfType.Public - case T.RPCGen.FolderType.team: - return T.FS.TlfType.Team - default: - return null - } -} - const rpcPathToPath = (rpcPath: T.RPCGen.KBFSPath) => T.FS.pathConcat(Constants.defaultPath, rpcPath.path) -const pathFromFolderRPC = (folder: T.RPCGen.Folder): T.FS.Path => { - const visibility = T.FS.getVisibilityFromRPCFolderType(folder.folderType) - if (!visibility) return T.FS.stringToPath('') - return T.FS.stringToPath(`/keybase/${visibility}/${folder.name}`) -} - -const folderRPCFromPath = (path: T.FS.Path): T.RPCGen.FolderHandle | undefined => { - const pathElems = T.FS.getPathElements(path) - if (pathElems.length === 0) return undefined - - const visibility = T.FS.getVisibilityFromElems(pathElems) - if (visibility === undefined) return undefined - - const name = T.FS.getPathNameFromElems(pathElems) - if (name === '') return undefined - - return { - created: false, - folderType: T.FS.getRPCFolderTypeFromVisibility(visibility), - name, - } -} - -const rpcConflictStateToConflictState = (rpcConflictState?: T.RPCGen.ConflictState): T.FS.ConflictState => { - if (rpcConflictState) { - if (rpcConflictState.conflictStateType === T.RPCGen.ConflictStateType.normalview) { - const nv = rpcConflictState.normalview - return makeConflictStateNormalView({ - localViewTlfPaths: (nv.localViews || []).reduce>((arr, p) => { - p.PathType === T.RPCGen.PathType.kbfs && arr.push(rpcPathToPath(p.kbfs)) - return arr - }, []), - resolvingConflict: nv.resolvingConflict, - stuckInConflict: nv.stuckInConflict, - }) - } else { - const nv = rpcConflictState.manualresolvinglocalview.normalView - return makeConflictStateManualResolvingLocalView({ - normalViewTlfPath: - nv.PathType === T.RPCGen.PathType.kbfs ? rpcPathToPath(nv.kbfs) : Constants.defaultPath, - }) - } - } else { - return tlfNormalViewWithNoConflict - } -} - -const getSyncConfigFromRPC = ( - tlfName: string, - tlfType: T.FS.TlfType, - config?: T.RPCGen.FolderSyncConfig -): T.FS.TlfSyncConfig => { - if (!config) { - return tlfSyncDisabled - } - switch (config.mode) { - case T.RPCGen.FolderSyncMode.disabled: - return tlfSyncDisabled - case T.RPCGen.FolderSyncMode.enabled: - return tlfSyncEnabled - case T.RPCGen.FolderSyncMode.partial: - return makeTlfSyncPartial({ - enabledPaths: config.paths - ? config.paths.map(str => T.FS.getPathFromRelative(tlfName, tlfType, str)) - : [], - }) - default: - return tlfSyncDisabled - } -} - -const fsNotificationTypeToEditType = ( - fsNotificationType: T.RPCChat.Keybase1.FSNotificationType -): T.FS.FileEditType => { - switch (fsNotificationType) { - case T.RPCGen.FSNotificationType.fileCreated: - return T.FS.FileEditType.Created - case T.RPCGen.FSNotificationType.fileModified: - return T.FS.FileEditType.Modified - case T.RPCGen.FSNotificationType.fileDeleted: - return T.FS.FileEditType.Deleted - case T.RPCGen.FSNotificationType.fileRenamed: - return T.FS.FileEditType.Renamed - default: - return T.FS.FileEditType.Unknown - } -} - -const userTlfHistoryRPCToState = ( - history: ReadonlyArray -): T.FS.UserTlfUpdates => - history.flatMap(folder => { - const path = pathFromFolderRPC(folder.folder) - return (folder.history ?? []).map(({writerName, edits}) => ({ - history: edits - ? edits.map(({filename, notificationType, serverTime}) => ({ - editType: fsNotificationTypeToEditType(notificationType), - filename, - serverTime, - })) - : [], - path, - serverTime: folder.serverTime, - writer: writerName, - })) - }) - const subscriptionDeduplicateIntervalSecond = 1 export {makeUUID} from '@/util/uuid' @@ -220,8 +37,8 @@ export const clientID = makeUUID() export const makeEditID = (): T.FS.EditID => T.FS.stringToEditID(makeUUID()) -export const resetBannerType = (s: State, path: T.FS.Path): T.FS.ResetBannerType => { - const resetParticipants = Constants.getTlfFromPath(s.tlfs, path).resetParticipants +export const resetBannerTypeFromTlf = (tlf: T.FS.Tlf): T.FS.ResetBannerType => { + const {resetParticipants} = tlf if (resetParticipants.length === 0) { return T.FS.ResetBannerNoOthersType.None } @@ -239,11 +56,27 @@ const noAccessErrorCodes: Array = [ T.RPCGen.StatusCode.scteamreaderror, ] -export const errorToActionOrThrow = (error: unknown, path?: T.FS.Path) => { +type ErrorHandlers = { + checkKbfsDaemonRpcStatus: () => void + redbar: (error: string) => void + setPathSoftError: (path: T.FS.Path, softError?: T.FS.SoftError) => void + setTlfSoftError: (path: T.FS.Path, softError?: T.FS.SoftError) => void +} + +const noopSoftError: ErrorHandlers['setPathSoftError'] = () => {} +const redbarToGlobalError: ErrorHandlers['redbar'] = error => { + useConfigState.getState().dispatch.setGlobalError(new Error(error)) +} + +export const errorToActionOrThrowWithHandlers = ( + {checkKbfsDaemonRpcStatus, redbar, setPathSoftError, setTlfSoftError}: ErrorHandlers, + error: unknown, + path?: T.FS.Path +) => { if (!isObject(error)) return const code = (error as {code?: T.RPCGen.StatusCode}).code if (code === T.RPCGen.StatusCode.sckbfsclienttimeout) { - useFSState.getState().dispatch.checkKbfsDaemonRpcStatus() + checkKbfsDaemonRpcStatus() return } if (code === T.RPCGen.StatusCode.scidentifiesfailed) { @@ -260,79 +93,59 @@ export const errorToActionOrThrow = (error: unknown, path?: T.FS.Path) => { return undefined } if (path && code === T.RPCGen.StatusCode.scsimplefsnotexist) { - useFSState.getState().dispatch.setPathSoftError(path, T.FS.SoftError.Nonexistent) + setPathSoftError(path, T.FS.SoftError.Nonexistent) return } if (path && code && noAccessErrorCodes.includes(code)) { const tlfPath = Constants.getTlfPath(path) if (tlfPath) { - useFSState.getState().dispatch.setTlfSoftError(tlfPath, T.FS.SoftError.NoAccess) + setTlfSoftError(tlfPath, T.FS.SoftError.NoAccess) return } } if (code === T.RPCGen.StatusCode.scdeleted) { // The user is deleted. Let user know and move on. - useFSState.getState().dispatch.redbar('A user in this shared folder has deleted their account.') + redbar('A user in this shared folder has deleted their account.') return } throw error } +export const errorToActionOrThrow = (error: unknown, path?: T.FS.Path) => { + const {checkKbfsDaemonRpcStatus} = useFSState.getState().dispatch + return errorToActionOrThrowWithHandlers( + { + checkKbfsDaemonRpcStatus, + redbar: redbarToGlobalError, + setPathSoftError: noopSoftError, + setTlfSoftError: noopSoftError, + }, + error, + path + ) +} + type Store = T.Immutable<{ - badge: T.RPCGen.FilesTabBadge - criticalUpdate: boolean downloads: T.FS.Downloads - edits: T.FS.Edits - errors: ReadonlyArray - fileContext: ReadonlyMap kbfsDaemonStatus: T.FS.KbfsDaemonStatus overallSyncStatus: T.FS.OverallSyncStatus - pathItemActionMenu: T.FS.PathItemActionMenu - pathItems: T.FS.PathItems - pathInfos: ReadonlyMap - pathUserSettings: ReadonlyMap settings: T.FS.Settings sfmi: T.FS.SystemFileManagerIntegration - softErrors: T.FS.SoftErrors - tlfUpdates: T.FS.UserTlfUpdates - tlfs: T.FS.Tlfs uploads: T.FS.Uploads }> const initialStore: Store = { - badge: T.RPCGen.FilesTabBadge.none, - criticalUpdate: false, downloads: { - info: new Map(), regularDownloads: [], state: new Map(), }, - edits: new Map(), - errors: [], - fileContext: new Map(), kbfsDaemonStatus: Constants.unknownKbfsDaemonStatus, overallSyncStatus: Constants.emptyOverallSyncStatus, - pathInfos: new Map(), - pathItemActionMenu: Constants.emptyPathItemActionMenu, - pathItems: new Map(), - pathUserSettings: new Map(), settings: Constants.emptySettings, sfmi: { directMountDir: '', driverStatus: Constants.defaultDriverStatus, preferredMountDirs: [], }, - softErrors: { - pathErrors: new Map(), - tlfErrors: new Map(), - }, - tlfUpdates: [], - tlfs: { - additionalTlfs: new Map(), - loaded: false, - private: new Map(), - public: new Map(), - team: new Map(), - }, uploads: { endEstimate: undefined, syncingPaths: new Set(), @@ -344,200 +157,26 @@ const initialStore: Store = { export type State = Store & { dispatch: { afterKbfsDaemonRpcStatusChanged: () => void - cancelDownload: (downloadID: string) => void checkKbfsDaemonRpcStatus: () => void - commitEdit: (editID: T.FS.EditID) => void - deleteFile: (path: T.FS.Path) => void - discardEdit: (editID: T.FS.EditID) => void - dismissDownload: (downloadID: string) => void - dismissRedbar: (index: number) => void - dismissUpload: (uploadID: string) => void - download: (path: T.FS.Path, type: 'download' | 'share' | 'saveMedia') => void driverDisable: () => void - driverDisabling: () => void driverEnable: (isRetry?: boolean) => void - driverKextPermissionError: () => void - editError: (editID: T.FS.EditID, error: string) => void - editSuccess: (editID: T.FS.EditID) => void - favoriteIgnore: (path: T.FS.Path) => void - favoritesLoad: () => void - finishManualConflictResolution: (localViewTlfPath: T.FS.Path) => void - folderListLoad: (path: T.FS.Path, recursive: boolean) => void getOnlineStatus: () => void journalUpdate: (syncingPaths: Array, totalSyncingBytes: number, endEstimate?: number) => void - kbfsDaemonOnlineStatusChanged: (onlineStatus: T.RPCGen.KbfsOnlineStatus) => void - kbfsDaemonRpcStatusChanged: (rpcStatus: T.FS.KbfsDaemonRpcStatus) => void - letResetUserBackIn: (id: T.RPCGen.TeamID, username: string) => void - loadAdditionalTlf: (tlfPath: T.FS.Path) => void - loadFileContext: (path: T.FS.Path) => void - loadFilesTabBadge: () => void - loadPathInfo: (path: T.FS.Path) => void - loadPathMetadata: (path: T.FS.Path) => void loadSettings: () => void - loadTlfSyncConfig: (tlfPath: T.FS.Path) => void - loadUploadStatus: () => void - loadDownloadInfo: (downloadID: string) => void loadDownloadStatus: () => void - loadedPathInfo: (path: T.FS.Path, info: T.FS.PathInfo) => void - newFolderRow: (parentPath: T.FS.Path) => void - moveOrCopy: ( - destinationParentPath: T.FS.Path, - source: T.FS.MoveOrCopySource | T.FS.IncomingShareSource, - type: 'move' | 'copy' - ) => void onChangedFocus: (appFocused: boolean) => void onEngineIncomingImpl: (action: EngineGen.Actions) => void - onPathChange: ( - clientID: string, - path: string, - topics: ReadonlyArray - ) => void - onSubscriptionNotify: (clientID: string, topic: T.RPCGen.SubscriptionTopic) => void - pollJournalStatus: () => void - redbar: (error: string) => void refreshDriverStatusDesktop: () => void - refreshMountDirsDesktop: () => void resetState: () => void - setCriticalUpdate: (u: boolean) => void - setDebugLevel: (level: string) => void - setDirectMountDir: (directMountDir: string) => void - setDriverStatus: (driverStatus: T.FS.DriverStatus) => void - setEditName: (editID: T.FS.EditID, name: string) => void - setPathItemActionMenuDownload: (downloadID?: string, intent?: T.FS.DownloadIntent) => void - setPreferredMountDirs: (preferredMountDirs: ReadonlyArray) => void - setPathSoftError: (path: T.FS.Path, softError?: T.FS.SoftError) => void - setSpaceAvailableNotificationThreshold: (spaceAvailableNotificationThreshold: number) => void - setTlfSoftError: (path: T.FS.Path, softError?: T.FS.SoftError) => void - setTlfsAsUnloaded: () => void - setTlfSyncConfig: (tlfPath: T.FS.Path, enabled: boolean) => void - setSorting: (path: T.FS.Path, sortSetting: T.FS.SortSetting) => void - startManualConflictResolution: (tlfPath: T.FS.Path) => void - startRename: (path: T.FS.Path) => void - subscribeNonPath: (subscriptionID: string, topic: T.RPCGen.SubscriptionTopic) => void - subscribePath: (subscriptionID: string, path: T.FS.Path, topic: T.RPCGen.PathSubscriptionTopic) => void - syncStatusChanged: (status: T.RPCGen.FolderSyncStatus) => void - unsubscribe: (subscriptionID: string) => void - upload: (parentPath: T.FS.Path, localPath: string) => void userIn: () => void userOut: () => void - userFileEditsLoad: () => void - waitForKbfsDaemon: () => void - } - getUploadIconForFilesTab: () => T.FS.UploadIcon | undefined -} - -const emptyPrefetchInProgress = { - bytesFetched: 0, - bytesTotal: 0, - endEstimate: 0, - startTime: 0, - state: T.FS.PrefetchState.InProgress, -} satisfies T.FS.PrefetchInProgress - -const getPrefetchStatusFromRPC = ( - prefetchStatus: T.RPCGen.PrefetchStatus, - prefetchProgress: T.RPCGen.PrefetchProgress -) => { - switch (prefetchStatus) { - case T.RPCGen.PrefetchStatus.notStarted: - return Constants.prefetchNotStarted - case T.RPCGen.PrefetchStatus.inProgress: - return { - ...emptyPrefetchInProgress, - bytesFetched: prefetchProgress.bytesFetched, - bytesTotal: prefetchProgress.bytesTotal, - endEstimate: prefetchProgress.endEstimate, - startTime: prefetchProgress.start, - } - case T.RPCGen.PrefetchStatus.complete: - return Constants.prefetchComplete - default: - return Constants.prefetchNotStarted - } -} - -const direntToMetadata = (d: T.RPCGen.Dirent) => ({ - lastModifiedTimestamp: d.time, - lastWriter: d.lastWriterUnverified.username, - name: d.name.split('/').pop(), - prefetchStatus: getPrefetchStatusFromRPC(d.prefetchStatus, d.prefetchProgress), - size: d.size, - writable: d.writable, -}) - -const makeEntry = (d: T.RPCGen.Dirent, children?: Set): T.FS.PathItem => { - switch (d.direntType) { - case T.RPCGen.DirentType.dir: - return { - ...Constants.emptyFolder, - ...direntToMetadata(d), - children: new Set(children || []), - progress: children ? T.FS.ProgressType.Loaded : T.FS.ProgressType.Pending, - } as T.FS.PathItem - case T.RPCGen.DirentType.sym: - return { - ...Constants.emptySymlink, - ...direntToMetadata(d), - // TODO: plumb link target - } as T.FS.PathItem - case T.RPCGen.DirentType.file: - case T.RPCGen.DirentType.exec: - return { - ...Constants.emptyFile, - ...direntToMetadata(d), - } as T.FS.PathItem - } -} - -const updatePathItem = ( - oldPathItem: T.Immutable, - newPathItemFromAction: T.Immutable -): T.Immutable => { - if ( - oldPathItem.type === T.FS.PathType.Folder && - newPathItemFromAction.type === T.FS.PathType.Folder && - oldPathItem.progress === T.FS.ProgressType.Loaded && - newPathItemFromAction.progress === T.FS.ProgressType.Pending - ) { - // The new one doesn't have children, but the old one has. We don't - // want to override a loaded folder into pending. So first set the children - // in new one using what we already have, see if they are equal. - const newPathItemNoOverridingChildrenAndProgress = { - ...newPathItemFromAction, - children: oldPathItem.children, - progress: T.FS.ProgressType.Loaded, - } - return newPathItemNoOverridingChildrenAndProgress } - return newPathItemFromAction } export const useFSState = Z.createZustand('fs', (set, get) => { // Can't rely on kbfsDaemonStatus.rpcStatus === 'waiting' as that's set by // reducer and happens before this. let waitForKbfsDaemonInProgress = false - let lastFavoritesBadgeState = {newTlfs: 0, rekeysNeeded: 0} - - const shouldReloadFavoritesFromBadgeState = (badgeState: T.RPCGen.BadgeState) => { - const {newTlfs, rekeysNeeded} = badgeState - const same = - newTlfs === lastFavoritesBadgeState.newTlfs && rekeysNeeded === lastFavoritesBadgeState.rekeysNeeded - lastFavoritesBadgeState = {newTlfs, rekeysNeeded} - return !same - } - - const getUploadIconForFilesTab = () => { - switch (get().badge) { - case T.RPCGen.FilesTabBadge.awaitingUpload: - return T.FS.UploadIcon.AwaitingToUpload - case T.RPCGen.FilesTabBadge.uploadingStuck: - return T.FS.UploadIcon.UploadingStuck - case T.RPCGen.FilesTabBadge.uploading: - return T.FS.UploadIcon.Uploading - case T.RPCGen.FilesTabBadge.none: - return undefined - } - } // At start-up we might have a race where we get connected to a kbfs daemon // which dies soon after, and we get an EOF here. So retry for a few times @@ -547,7 +186,7 @@ export const useFSState = Z.createZustand('fs', (set, get) => { const checkIfWeReConnectedToMDServerUpToNTimes = async (n: number): Promise => { try { const onlineStatus = await T.RPCGen.SimpleFSSimpleFSGetOnlineStatusRpcPromise({clientID}) - get().dispatch.kbfsDaemonOnlineStatusChanged(onlineStatus) + kbfsDaemonOnlineStatusChanged(onlineStatus) return } catch (error) { if (n > 0) { @@ -561,7 +200,6 @@ export const useFSState = Z.createZustand('fs', (set, get) => { } } - const fsBadgeSub = {id: ''} const settingsSub = {id: ''} const uploadStatusSub = {id: ''} const journalStatusSub = {id: ''} @@ -577,16 +215,15 @@ export const useFSState = Z.createZustand('fs', (set, get) => { generation === asyncGeneration && shouldRunBackgroundFSRPC() const clearSubscriptions = () => { - fsBadgeSub.id = '' settingsSub.id = '' uploadStatusSub.id = '' journalStatusSub.id = '' } const unsubscribeAll = () => { - const subscriptionIDs = [fsBadgeSub.id, settingsSub.id, uploadStatusSub.id, journalStatusSub.id] + const subscriptionIDs = [settingsSub.id, uploadStatusSub.id, journalStatusSub.id] subscriptionIDs.forEach(subscriptionID => { - subscriptionID && get().dispatch.unsubscribe(subscriptionID) + subscriptionID && unsubscribe(subscriptionID) }) clearSubscriptions() } @@ -595,8 +232,8 @@ export const useFSState = Z.createZustand('fs', (set, get) => { const oldID = sub.id sub.id = makeUUID() if (get().kbfsDaemonStatus.rpcStatus === T.FS.KbfsDaemonRpcStatus.Connected) { - if (oldID) get().dispatch.unsubscribe(oldID) - get().dispatch.subscribeNonPath(sub.id, topic) + if (oldID) unsubscribe(oldID) + subscribeNonPath(sub.id, topic) load() } } @@ -612,6 +249,316 @@ export const useFSState = Z.createZustand('fs', (set, get) => { ignorePromise(f()) } + const driverDisabling = () => { + set(s => { + if (s.sfmi.driverStatus.type === T.FS.DriverStatusType.Enabled) { + s.sfmi.driverStatus.isDisabling = true + } + }) + const f = async () => { + const {sfmi} = get() + await afterDriverDisablingInPlatform(sfmi.driverStatus) + get().dispatch.refreshDriverStatusDesktop() + } + ignorePromise(f()) + } + + const driverKextPermissionError = () => { + set(s => { + if (s.sfmi.driverStatus.type === T.FS.DriverStatusType.Disabled) { + s.sfmi.driverStatus.kextPermissionError = true + s.sfmi.driverStatus.isEnabling = false + } + }) + } + + const kbfsDaemonOnlineStatusChanged = (onlineStatus: T.RPCGen.KbfsOnlineStatus) => { + set(s => { + switch (onlineStatus) { + case T.RPCGen.KbfsOnlineStatus.offline: + s.kbfsDaemonStatus.onlineStatus = T.FS.KbfsDaemonOnlineStatus.Offline + break + case T.RPCGen.KbfsOnlineStatus.trying: + s.kbfsDaemonStatus.onlineStatus = T.FS.KbfsDaemonOnlineStatus.Trying + break + case T.RPCGen.KbfsOnlineStatus.online: + s.kbfsDaemonStatus.onlineStatus = T.FS.KbfsDaemonOnlineStatus.Online + break + default: + s.kbfsDaemonStatus.onlineStatus = T.FS.KbfsDaemonOnlineStatus.Unknown + } + }) + } + + const loadUploadStatus = () => { + const f = async () => { + try { + const uploadStates = await T.RPCGen.SimpleFSSimpleFSGetUploadStatusRpcPromise() + set(s => { + const writingToJournal = new Map( + uploadStates?.map(uploadState => { + const path = rpcPathToPath(uploadState.targetPath) + const oldUploadState = s.uploads.writingToJournal.get(path) + return [ + path, + oldUploadState && + uploadState.error === oldUploadState.error && + uploadState.canceled === oldUploadState.canceled && + uploadState.uploadID === oldUploadState.uploadID + ? oldUploadState + : uploadState, + ] + }) + ) + if (!isEqual(writingToJournal, s.uploads.writingToJournal)) { + s.uploads.writingToJournal = writingToJournal + } + }) + } catch (err) { + errorToActionOrThrow(err) + } + } + ignorePromise(f()) + } + + const onSubscriptionNotify = (cid: string, topic: T.RPCGen.SubscriptionTopic) => { + const f = async () => { + if (cid !== clientID || !shouldRunBackgroundFSRPC()) { + return + } + switch (topic) { + case T.RPCGen.SubscriptionTopic.journalStatus: + pollJournalStatus() + break + case T.RPCGen.SubscriptionTopic.onlineStatus: + await checkIfWeReConnectedToMDServerUpToNTimes(1) + break + case T.RPCGen.SubscriptionTopic.downloadStatus: + get().dispatch.loadDownloadStatus() + break + case T.RPCGen.SubscriptionTopic.uploadStatus: + loadUploadStatus() + break + case T.RPCGen.SubscriptionTopic.settings: + get().dispatch.loadSettings() + break + default: + } + } + ignorePromise(f()) + } + + const refreshMountDirsDesktop = () => { + const f = async () => { + const {sfmi} = get() + if (sfmi.driverStatus.type !== T.FS.DriverStatusType.Enabled) { + return + } + try { + const {directMountDir, preferredMountDirs} = await refreshMountDirsInPlatform() + setDirectMountDir(directMountDir) + setPreferredMountDirs(preferredMountDirs) + } catch (e) { + errorToActionOrThrow(e) + } + } + ignorePromise(f()) + } + + const setDirectMountDir = (directMountDir: string) => { + set(s => { + s.sfmi.directMountDir = directMountDir + }) + } + + const setDriverStatus = (driverStatus: T.FS.DriverStatus) => { + set(s => { + s.sfmi.driverStatus = driverStatus + }) + refreshMountDirsDesktop() + } + + const setPreferredMountDirs = (preferredMountDirs: ReadonlyArray) => { + set(s => { + s.sfmi.preferredMountDirs = T.castDraft(preferredMountDirs) + }) + } + + const subscribeNonPath = (subscriptionID: string, topic: T.RPCGen.SubscriptionTopic) => { + const f = async () => { + try { + await T.RPCGen.SimpleFSSimpleFSSubscribeNonPathRpcPromise({ + clientID, + deduplicateIntervalSecond: subscriptionDeduplicateIntervalSecond, + identifyBehavior: T.RPCGen.TLFIdentifyBehavior.fsGui, + subscriptionID, + topic, + }) + } catch (err) { + errorToActionOrThrow(err) + } + } + ignorePromise(f()) + } + + const syncStatusChanged = (status: T.RPCGen.FolderSyncStatus) => { + const diskSpaceStatus = status.outOfSyncSpace + ? T.FS.DiskSpaceStatus.Error + : status.localDiskBytesAvailable < get().settings.spaceAvailableNotificationThreshold + ? T.FS.DiskSpaceStatus.Warning + : T.FS.DiskSpaceStatus.Ok + + const oldStatus = get().overallSyncStatus.diskSpaceStatus + set(s => { + s.overallSyncStatus.syncingFoldersProgress = status.prefetchProgress + s.overallSyncStatus.diskSpaceStatus = diskSpaceStatus + }) + + // Only notify about the disk space status if it has changed. + if (oldStatus !== diskSpaceStatus) { + switch (diskSpaceStatus) { + case T.FS.DiskSpaceStatus.Error: { + NotifyPopup('Sync Error', { + body: 'You are out of disk space. Some folders could not be synced.', + sound: true, + }) + useNotifState.getState().dispatch.badgeApp('outOfSpace', status.outOfSyncSpace) + break + } + case T.FS.DiskSpaceStatus.Warning: + { + const threshold = Constants.humanizeBytes(get().settings.spaceAvailableNotificationThreshold, 0) + NotifyPopup('Disk Space Low', { + body: `You have less than ${threshold} of storage space left.`, + }) + // Only show the banner if the previous state was OK and the new state + // is warning. Otherwise we rely on the previous state of the banner. + if (oldStatus === T.FS.DiskSpaceStatus.Ok) { + set(s => { + s.overallSyncStatus.showingBanner = true + }) + } + } + break + case T.FS.DiskSpaceStatus.Ok: + break + default: + } + } + } + + const unsubscribe = (subscriptionID: string) => { + const f = async () => { + try { + await T.RPCGen.SimpleFSSimpleFSUnsubscribeRpcPromise({ + clientID, + identifyBehavior: T.RPCGen.TLFIdentifyBehavior.fsGui, + subscriptionID, + }) + } catch {} + } + ignorePromise(f()) + } + + const pollJournalStatus = () => { + if (pollJournalStatusPolling || !shouldRunBackgroundFSRPC()) { + return + } + pollJournalStatusPolling = true + const generation = asyncGeneration + + const getWaitDuration = (endEstimate: number | undefined, lower: number, upper: number): number => { + if (!endEstimate) { + return upper + } + const diff = endEstimate - Date.now() + return diff < lower ? lower : diff > upper ? upper : diff + } + + const f = async () => { + let shouldRefreshDaemonStatus = false + try { + while (isCurrentAsyncGeneration(generation)) { + const {syncingPaths, totalSyncingBytes, endEstimate} = + await T.RPCGen.SimpleFSSimpleFSSyncStatusRpcPromise({ + filter: T.RPCGen.ListFilter.filterSystemHidden, + }) + if (!isCurrentAsyncGeneration(generation)) { + return + } + get().dispatch.journalUpdate( + (syncingPaths || []).map(T.FS.stringToPath), + totalSyncingBytes, + endEstimate ?? undefined + ) + + // It's possible syncingPaths has not been emptied before + // totalSyncingBytes becomes 0. So check both. + if (totalSyncingBytes <= 0 && !syncingPaths?.length) { + break + } + useNotifState.getState().dispatch.badgeApp('kbfsUploading', true) + await timeoutPromise(getWaitDuration(endEstimate || undefined, 100, 4000)) // 0.1s to 4s + } + } finally { + if (generation === asyncGeneration) { + pollJournalStatusPolling = false + } + shouldRefreshDaemonStatus = isCurrentAsyncGeneration(generation) + useNotifState.getState().dispatch.badgeApp('kbfsUploading', false) + } + if (!shouldRefreshDaemonStatus) { + return + } + get().dispatch.checkKbfsDaemonRpcStatus() + } + ignorePromise(f()) + } + + const waitForKbfsDaemon = () => { + if (waitForKbfsDaemonInProgress || !shouldRunBackgroundFSRPC()) { + return + } + waitForKbfsDaemonInProgress = true + const generation = asyncGeneration + set(s => { + s.kbfsDaemonStatus.rpcStatus = T.FS.KbfsDaemonRpcStatus.Waiting + }) + const f = async () => { + try { + await T.RPCGen.configWaitForClientRpcPromise({ + clientType: T.RPCGen.ClientType.kbfs, + timeout: 60, // 1min. This is arbitrary since we're gonna check again anyway if we're not connected. + }) + } catch { + } finally { + if (generation === asyncGeneration) { + waitForKbfsDaemonInProgress = false + } + } + if (!isCurrentAsyncGeneration(generation)) { + return + } + get().dispatch.checkKbfsDaemonRpcStatus() + } + ignorePromise(f()) + } + + const kbfsDaemonRpcStatusChanged = (rpcStatus: T.FS.KbfsDaemonRpcStatus) => { + set(s => { + if (rpcStatus !== T.FS.KbfsDaemonRpcStatus.Connected) { + s.kbfsDaemonStatus.onlineStatus = T.FS.KbfsDaemonOnlineStatus.Offline + } + s.kbfsDaemonStatus.rpcStatus = rpcStatus + }) + + subscribeAndLoad(settingsSub, T.RPCGen.SubscriptionTopic.settings, () => get().dispatch.loadSettings()) + subscribeAndLoad(uploadStatusSub, T.RPCGen.SubscriptionTopic.uploadStatus, loadUploadStatus) + subscribeAndLoad(journalStatusSub, T.RPCGen.SubscriptionTopic.journalStatus, pollJournalStatus) + // how this works isn't great. This function gets called way early before we set this + get().dispatch.afterKbfsDaemonRpcStatusChanged() + } + const dispatch: State['dispatch'] = { afterKbfsDaemonRpcStatusChanged: () => { const f = async () => { @@ -623,13 +570,7 @@ export const useFSState = Z.createZustand('fs', (set, get) => { if (kbfsDaemonStatus.rpcStatus === T.FS.KbfsDaemonRpcStatus.Connected) { dispatch.refreshDriverStatusDesktop() } - dispatch.refreshMountDirsDesktop() - } - ignorePromise(f()) - }, - cancelDownload: downloadID => { - const f = async () => { - await T.RPCGen.SimpleFSSimpleFSCancelDownloadRpcPromise({downloadID}) + refreshMountDirsDesktop() } ignorePromise(f()) }, @@ -648,7 +589,6 @@ export const useFSState = Z.createZustand('fs', (set, get) => { } const newStatus = connected ? T.FS.KbfsDaemonRpcStatus.Connected : T.FS.KbfsDaemonRpcStatus.Waiting const kbfsDaemonStatus = get().kbfsDaemonStatus - const {kbfsDaemonRpcStatusChanged, waitForKbfsDaemon} = get().dispatch if (kbfsDaemonStatus.rpcStatus !== newStatus) { kbfsDaemonRpcStatusChanged(newStatus) @@ -659,411 +599,40 @@ export const useFSState = Z.createZustand('fs', (set, get) => { } ignorePromise(f()) }, - commitEdit: editID => { - const edit = get().edits.get(editID) - if (!edit) { - return - } + driverDisable: () => { const f = async () => { - switch (edit.type) { - case T.FS.EditType.NewFolder: - try { - await T.RPCGen.SimpleFSSimpleFSOpenRpcPromise( - { - dest: Constants.pathToRPCPath(T.FS.pathConcat(edit.parentPath, edit.name)), - flags: T.RPCGen.OpenFlags.directory, - opID: makeUUID(), - }, - S.waitingKeyFSCommitEdit - ) - get().dispatch.editSuccess(editID) - return - } catch (error) { - errorToActionOrThrow(error, edit.parentPath) - return - } - case T.FS.EditType.Rename: - try { - const opID = makeUUID() - await T.RPCGen.SimpleFSSimpleFSMoveRpcPromise({ - dest: Constants.pathToRPCPath(T.FS.pathConcat(edit.parentPath, edit.name)), - opID, - overwriteExistingFiles: false, - src: Constants.pathToRPCPath(T.FS.pathConcat(edit.parentPath, edit.originalName)), - }) - await T.RPCGen.SimpleFSSimpleFSWaitRpcPromise({opID}, S.waitingKeyFSCommitEdit) - get().dispatch.editSuccess(editID) - return - } catch (error) { - if ( - error instanceof RPCError && - [ - T.RPCGen.StatusCode.scsimplefsnameexists, - T.RPCGen.StatusCode.scsimplefsdirnotempty, - ].includes(error.code) - ) { - get().dispatch.editError(editID, error.desc || 'name exists') - return - } - errorToActionOrThrow(error, edit.parentPath) - return - } + const {dispatch, sfmi} = get() + _setSfmiBannerDismissedDesktop(false) + const result = await afterDriverDisableInPlatform(sfmi.driverStatus) + if (result === 'disabling') { + driverDisabling() + } else if (result === 'refresh') { + dispatch.refreshDriverStatusDesktop() } } ignorePromise(f()) }, - deleteFile: path => { + driverEnable: isRetry => { + set(s => { + if (s.sfmi.driverStatus.type === T.FS.DriverStatusType.Disabled) { + s.sfmi.driverStatus.isEnabling = true + } + }) const f = async () => { - const opID = makeUUID() + const {dispatch} = get() + _setSfmiBannerDismissedDesktop(false) try { - await T.RPCGen.SimpleFSSimpleFSRemoveRpcPromise({ - opID, - path: Constants.pathToRPCPath(path), - recursive: true, - }) - await T.RPCGen.SimpleFSSimpleFSWaitRpcPromise({opID}) + const result = await afterDriverEnabledInPlatform(!!isRetry) + if (result === 'kextPermissionError' || result === 'kextPermissionErrorRetry') { + driverKextPermissionError() + if (result === 'kextPermissionError') { + navigateAppend({name: 'kextPermission', params: {}}) + } + return + } + dispatch.refreshDriverStatusDesktop() } catch (e) { - errorToActionOrThrow(e, path) - } - } - ignorePromise(f()) - }, - discardEdit: editID => { - set(s => { - s.edits.delete(editID) - }) - }, - dismissDownload: downloadID => { - const f = async () => { - await T.RPCGen.SimpleFSSimpleFSDismissDownloadRpcPromise({downloadID}) - } - ignorePromise(f()) - }, - dismissRedbar: index => { - set(s => { - s.errors = [...s.errors.slice(0, index), ...s.errors.slice(index + 1)] - }) - }, - dismissUpload: uploadID => { - const f = async () => { - try { - await T.RPCGen.SimpleFSSimpleFSDismissUploadRpcPromise({uploadID}) - } catch {} - } - ignorePromise(f()) - }, - download: (path, type) => { - const f = async () => { - await requestPermissionsToWrite() - const downloadID = await T.RPCGen.SimpleFSSimpleFSStartDownloadRpcPromise({ - isRegularDownload: type === 'download', - path: Constants.pathToRPCPath(path).kbfs, - }) - set(s => { - const info = s.downloads.info.get(downloadID) - s.downloads.info.set(downloadID, { - filename: info?.filename ?? T.FS.getPathName(path), - intent: - type === 'share' - ? T.FS.DownloadIntent.Share - : type === 'saveMedia' - ? T.FS.DownloadIntent.CameraRoll - : info?.intent, - isRegularDownload: type === 'download', - path, - startTime: info?.startTime ?? 0, - }) - }) - if (type !== 'download') { - get().dispatch.setPathItemActionMenuDownload( - downloadID, - type === 'share' ? T.FS.DownloadIntent.Share : T.FS.DownloadIntent.CameraRoll - ) - } - } - ignorePromise(f()) - }, - driverDisable: () => { - const f = async () => { - const {dispatch, sfmi} = get() - _setSfmiBannerDismissedDesktop(false) - const result = await afterDriverDisableInPlatform(sfmi.driverStatus) - if (result === 'disabling') { - dispatch.driverDisabling() - } else if (result === 'refresh') { - dispatch.refreshDriverStatusDesktop() - } - } - ignorePromise(f()) - }, - driverDisabling: () => { - set(s => { - if (s.sfmi.driverStatus.type === T.FS.DriverStatusType.Enabled) { - s.sfmi.driverStatus.isDisabling = true - } - }) - const f = async () => { - const {dispatch, sfmi} = get() - await afterDriverDisablingInPlatform(sfmi.driverStatus) - dispatch.refreshDriverStatusDesktop() - } - ignorePromise(f()) - }, - driverEnable: isRetry => { - set(s => { - if (s.sfmi.driverStatus.type === T.FS.DriverStatusType.Disabled) { - s.sfmi.driverStatus.isEnabling = true - } - }) - const f = async () => { - const {dispatch} = get() - _setSfmiBannerDismissedDesktop(false) - try { - const result = await afterDriverEnabledInPlatform(!!isRetry) - if (result === 'kextPermissionError' || result === 'kextPermissionErrorRetry') { - dispatch.driverKextPermissionError() - if (result === 'kextPermissionError') { - navigateAppend({name: 'kextPermission', params: {}}) - } - return - } - dispatch.refreshDriverStatusDesktop() - } catch (e) { - errorToActionOrThrow(e) - } - } - ignorePromise(f()) - }, - driverKextPermissionError: () => { - set(s => { - if (s.sfmi.driverStatus.type === T.FS.DriverStatusType.Disabled) { - s.sfmi.driverStatus.kextPermissionError = true - s.sfmi.driverStatus.isEnabling = false - } - }) - }, - editError: (editID, error) => { - set(s => { - const edit = s.edits.get(editID) - if (edit) { - edit.error = error - } - }) - }, - editSuccess: editID => { - set(s => { - s.edits.delete(editID) - }) - }, - favoriteIgnore: path => { - const setTlfIgnored = (isIgnored: boolean) => { - set(s => { - const elems = T.FS.getPathElements(path) - const visibility = T.FS.getVisibilityFromElems(elems) - if (!visibility) return - const name = elems[2] ?? '' - s.tlfs[visibility].set( - name, - T.castDraft({...(s.tlfs[visibility].get(name) || Constants.unknownTlf), isIgnored}) - ) - }) - } - const f = async () => { - const folder = folderRPCFromPath(path) - if (!folder) { - throw new Error('No folder specified') - } - try { - await T.RPCGen.favoriteFavoriteIgnoreRpcPromise({folder}) - } catch (error) { - errorToActionOrThrow(error, path) - setTlfIgnored(false) - } - } - setTlfIgnored(true) - ignorePromise(f()) - }, - favoritesLoad: () => { - const f = async () => { - try { - if (!useConfigState.getState().loggedIn) { - return - } - const results = await T.RPCGen.SimpleFSSimpleFSListFavoritesRpcPromise() - const payload = { - private: new Map(), - public: new Map(), - team: new Map(), - } as const - const fs = [ - ...(results.favoriteFolders - ? [{folders: results.favoriteFolders, isFavorite: true, isIgnored: false, isNew: false}] - : []), - ...(results.ignoredFolders - ? [{folders: results.ignoredFolders, isFavorite: false, isIgnored: true, isNew: false}] - : []), - ...(results.newFolders - ? [{folders: results.newFolders, isFavorite: true, isIgnored: false, isNew: true}] - : []), - ] - fs.forEach(({folders, isFavorite, isIgnored, isNew}) => - folders.forEach(folder => { - const tlfType = rpcFolderTypeToTlfType(folder.folderType) - const tlfName = - tlfType === T.FS.TlfType.Private || tlfType === T.FS.TlfType.Public - ? tlfToPreferredOrder(folder.name, useCurrentUserState.getState().username) - : folder.name - tlfType && - payload[tlfType].set( - tlfName, - makeTlf({ - conflictState: rpcConflictStateToConflictState(folder.conflictState || undefined), - isFavorite, - isIgnored, - isNew, - name: tlfName, - resetParticipants: (folder.reset_members || []).map(({username}) => username), - syncConfig: getSyncConfigFromRPC(tlfName, tlfType, folder.syncConfig || undefined), - teamId: folder.team_id || '', - tlfMtime: folder.mtime || 0, - }) - ) - }) - ) - - if (payload.private.size) { - set(s => { - s.tlfs.private = T.castDraft(payload.private) - s.tlfs.public = T.castDraft(payload.public) - s.tlfs.team = T.castDraft(payload.team) - s.tlfs.loaded = true - }) - const counts = new Map() - counts.set(Tabs.fsTab, Constants.computeBadgeNumberForAll(get().tlfs)) - useNotifState.getState().dispatch.setBadgeCounts(counts) - } - } catch (e) { - errorToActionOrThrow(e) - } - return - } - ignorePromise(f()) - }, - finishManualConflictResolution: localViewTlfPath => { - const f = async () => { - await T.RPCGen.SimpleFSSimpleFSFinishResolvingConflictRpcPromise({ - path: Constants.pathToRPCPath(localViewTlfPath), - }) - get().dispatch.favoritesLoad() - } - ignorePromise(f()) - }, - folderListLoad: (rootPath, isRecursive) => { - const f = async () => { - try { - const opID = makeUUID() - if (isRecursive) { - await T.RPCGen.SimpleFSSimpleFSListRecursiveToDepthRpcPromise({ - depth: 1, - filter: T.RPCGen.ListFilter.filterSystemHidden, - opID, - path: Constants.pathToRPCPath(rootPath), - refreshSubscription: false, - }) - } else { - await T.RPCGen.SimpleFSSimpleFSListRpcPromise({ - filter: T.RPCGen.ListFilter.filterSystemHidden, - opID, - path: Constants.pathToRPCPath(rootPath), - refreshSubscription: false, - }) - } - - await T.RPCGen.SimpleFSSimpleFSWaitRpcPromise({opID}, S.waitingKeyFSFolderList) - - const result = await T.RPCGen.SimpleFSSimpleFSReadListRpcPromise({opID}) - const entries = result.entries || [] - const childMap = entries.reduce((m, d) => { - const [parent, child] = d.name.split('/') - if (child) { - // Only add to the children set if the parent definitely has children. - const fullParent = T.FS.pathConcat(rootPath, parent ?? '') - let children = m.get(fullParent) - if (!children) { - children = new Set() - m.set(fullParent, children) - } - children.add(child) - } else { - let children = m.get(rootPath) - if (!children) { - children = new Set() - m.set(rootPath, children) - } - children.add(d.name) - } - return m - }, new Map>()) - - const direntToPathAndPathItem = (d: T.RPCGen.Dirent) => { - const path = T.FS.pathConcat(rootPath, d.name) - const entry = makeEntry(d, childMap.get(path)) - if (entry.type === T.FS.PathType.Folder && isRecursive && !d.name.includes('/')) { - // Since we are loading with a depth of 2, first level directories are - // considered "loaded". - return [ - path, - { - ...entry, - progress: T.FS.ProgressType.Loaded, - }, - ] as const - } - return [path, entry] as const - } - - // Get metadata fields of the directory that we just loaded from state to - // avoid overriding them. - const rootPathItem = Constants.getPathItem(get().pathItems, rootPath) - const rootFolder: T.FS.FolderPathItem = { - ...(rootPathItem.type === T.FS.PathType.Folder - ? rootPathItem - : {...Constants.emptyFolder, name: T.FS.getPathName(rootPath)}), - children: new Set(childMap.get(rootPath)), - progress: T.FS.ProgressType.Loaded, - } - - const pathItems = new Map([ - ...(T.FS.getPathLevel(rootPath) > 2 ? [[rootPath, rootFolder] as const] : []), - ...entries.map(direntToPathAndPathItem), - ] as const) - set(s => { - pathItems.forEach((pathItemFromAction, path) => { - const oldPathItem = Constants.getPathItem(s.pathItems, path) - const newPathItem = updatePathItem(oldPathItem, pathItemFromAction) - if (oldPathItem.type === T.FS.PathType.Folder) { - oldPathItem.children.forEach(name => { - if (newPathItem.type !== T.FS.PathType.Folder || !newPathItem.children.has(name)) { - s.pathItems.delete(T.FS.pathConcat(path, name)) - } - }) - } - s.pathItems.set(path, T.castDraft(newPathItem)) - }) - - // Remove rename edits once the original item disappears from the folder. - s.edits.forEach((edit, editID) => { - if (edit.type === T.FS.EditType.Rename) { - const parent = Constants.getPathItem(s.pathItems, edit.parentPath) - if (!(parent.type === T.FS.PathType.Folder && parent.children.has(edit.originalName))) { - s.edits.delete(editID) - } - } - }) - }) - } catch (error) { - errorToActionOrThrow(error, rootPath) - return + errorToActionOrThrow(e) } } ignorePromise(f()) @@ -1084,137 +653,6 @@ export const useFSState = Z.createZustand('fs', (set, get) => { s.uploads.endEstimate = endEstimate }) }, - kbfsDaemonOnlineStatusChanged: onlineStatus => { - set(s => { - switch (onlineStatus) { - case T.RPCGen.KbfsOnlineStatus.offline: - s.kbfsDaemonStatus.onlineStatus = T.FS.KbfsDaemonOnlineStatus.Offline - break - case T.RPCGen.KbfsOnlineStatus.trying: - s.kbfsDaemonStatus.onlineStatus = T.FS.KbfsDaemonOnlineStatus.Trying - break - case T.RPCGen.KbfsOnlineStatus.online: - s.kbfsDaemonStatus.onlineStatus = T.FS.KbfsDaemonOnlineStatus.Online - break - default: - s.kbfsDaemonStatus.onlineStatus = T.FS.KbfsDaemonOnlineStatus.Unknown - } - }) - }, - kbfsDaemonRpcStatusChanged: rpcStatus => { - set(s => { - if (rpcStatus !== T.FS.KbfsDaemonRpcStatus.Connected) { - s.kbfsDaemonStatus.onlineStatus = T.FS.KbfsDaemonOnlineStatus.Offline - } - s.kbfsDaemonStatus.rpcStatus = rpcStatus - }) - - const kbfsDaemonStatus = get().kbfsDaemonStatus - if (kbfsDaemonStatus.rpcStatus !== T.FS.KbfsDaemonRpcStatus.Connected) { - get().dispatch.setTlfsAsUnloaded() - } - - subscribeAndLoad(fsBadgeSub, T.RPCGen.SubscriptionTopic.filesTabBadge, () => - get().dispatch.loadFilesTabBadge() - ) - subscribeAndLoad(settingsSub, T.RPCGen.SubscriptionTopic.settings, () => get().dispatch.loadSettings()) - subscribeAndLoad(uploadStatusSub, T.RPCGen.SubscriptionTopic.uploadStatus, () => - get().dispatch.loadUploadStatus() - ) - subscribeAndLoad(journalStatusSub, T.RPCGen.SubscriptionTopic.journalStatus, () => - get().dispatch.pollJournalStatus() - ) - // how this works isn't great. This function gets called way early before we set this - get().dispatch.afterKbfsDaemonRpcStatusChanged() - }, - letResetUserBackIn: (id, username) => { - const f = async () => { - try { - await T.RPCGen.teamsTeamReAddMemberAfterResetRpcPromise({id, username}) - } catch (error) { - errorToActionOrThrow(error) - } - } - ignorePromise(f()) - }, - loadAdditionalTlf: tlfPath => { - const f = async () => { - if (T.FS.getPathLevel(tlfPath) !== 3) { - logger.warn('loadAdditionalTlf called on non-TLF path') - return - } - try { - const {folder, isFavorite, isIgnored, isNew} = await T.RPCGen.SimpleFSSimpleFSGetFolderRpcPromise({ - path: Constants.pathToRPCPath(tlfPath).kbfs, - }) - const tlfType = rpcFolderTypeToTlfType(folder.folderType) - const tlfName = - tlfType === T.FS.TlfType.Private || tlfType === T.FS.TlfType.Public - ? tlfToPreferredOrder(folder.name, useCurrentUserState.getState().username) - : folder.name - - if (tlfType) { - set(s => { - s.tlfs.additionalTlfs.set( - tlfPath, - T.castDraft( - makeTlf({ - conflictState: rpcConflictStateToConflictState(folder.conflictState || undefined), - isFavorite, - isIgnored, - isNew, - name: tlfName, - resetParticipants: (folder.reset_members || []).map(({username}) => username), - syncConfig: getSyncConfigFromRPC(tlfName, tlfType, folder.syncConfig || undefined), - teamId: folder.team_id || '', - tlfMtime: folder.mtime || 0, - }) - ) - ) - }) - } - } catch (error) { - if (!(error instanceof RPCError)) { - return - } - if (error.code === T.RPCGen.StatusCode.scteamcontactsettingsblock) { - const fields = error.fields as undefined | Array<{key?: string; value?: string}> - const users = fields?.filter(elem => elem.key === 'usernames') - const usernames = users?.map(elem => elem.value ?? '') ?? [] - // Don't leave the user on a broken FS dir screen. - navigateUp() - navigateAppend({ - name: 'contactRestricted', - params: {source: 'newFolder', usernames}, - }) - } - errorToActionOrThrow(error, tlfPath) - } - } - ignorePromise(f()) - }, - loadDownloadInfo: downloadID => { - const f = async () => { - try { - const res = await T.RPCGen.SimpleFSSimpleFSGetDownloadInfoRpcPromise({ - downloadID, - }) - set(s => { - const old = s.downloads.info.get(downloadID) - s.downloads.info.set(downloadID, { - filename: res.filename, - intent: old?.intent, - isRegularDownload: res.isRegularDownload, - path: T.FS.stringToPath('/keybase' + res.path.path), - startTime: res.startTime, - }) - }) - } catch (error) { - errorToActionOrThrow(error) - } - } - ignorePromise(f()) - }, loadDownloadStatus: () => { const f = async () => { try { @@ -1238,10 +676,6 @@ export const useFSState = Z.createZustand('fs', (set, get) => { set(s => { s.downloads.regularDownloads = T.castDraft(regularDownloads) s.downloads.state = state - - for (const downloadID of s.downloads.info.keys()) { - if (!state.has(downloadID)) s.downloads.info.delete(downloadID) - } }) } catch (error) { errorToActionOrThrow(error) @@ -1249,254 +683,20 @@ export const useFSState = Z.createZustand('fs', (set, get) => { } ignorePromise(f()) }, - loadFileContext: path => { - const f = async () => { - try { - const res = await T.RPCGen.SimpleFSSimpleFSGetGUIFileContextRpcPromise({ - path: Constants.pathToRPCPath(path).kbfs, - }) - - set(s => { - s.fileContext.set(path, { - contentType: res.contentType, - url: res.url, - viewType: res.viewType, - }) - }) - } catch (err) { - errorToActionOrThrow(err) - return - } - } - ignorePromise(f()) - }, - loadFilesTabBadge: () => { - const f = async () => { - try { - const badge = await T.RPCGen.SimpleFSSimpleFSGetFilesTabBadgeRpcPromise() - set(s => { - s.badge = badge - }) - } catch { - // retry once HOTPOT-1226 - try { - const badge = await T.RPCGen.SimpleFSSimpleFSGetFilesTabBadgeRpcPromise() - set(s => { - s.badge = badge - }) - } catch {} - } - } - ignorePromise(f()) - }, - loadPathInfo: path => { - const f = async () => { - const pathInfo = await T.RPCGen.kbfsMountGetKBFSPathInfoRpcPromise({ - standardPath: T.FS.pathToString(path), - }) - get().dispatch.loadedPathInfo(path, { - deeplinkPath: pathInfo.deeplinkPath, - platformAfterMountPath: pathInfo.platformAfterMountPath, - }) - } - ignorePromise(f()) - }, - loadPathMetadata: path => { - const f = async () => { - try { - const dirent = await T.RPCGen.SimpleFSSimpleFSStatRpcPromise( - { - path: Constants.pathToRPCPath(path), - refreshSubscription: false, - }, - S.waitingKeyFSStat - ) - - const pathItem = makeEntry(dirent) - set(s => { - const oldPathItem = Constants.getPathItem(s.pathItems, path) - s.pathItems.set(path, T.castDraft(updatePathItem(oldPathItem, pathItem))) - s.softErrors.pathErrors.delete(path) - s.softErrors.tlfErrors.delete(path) - }) - } catch (err) { - errorToActionOrThrow(err, path) - return - } - } - ignorePromise(f()) - }, loadSettings: () => { const f = async () => { - set(s => { - s.settings.isLoading = true - }) try { const settings = await T.RPCGen.SimpleFSSimpleFSSettingsRpcPromise() set(s => { const o = s.settings - o.isLoading = false o.loaded = true o.sfmiBannerDismissed = settings.sfmiBannerDismissed o.spaceAvailableNotificationThreshold = settings.spaceAvailableNotificationThreshold - o.syncOnCellular = settings.syncOnCellular - }) - } catch { - set(s => { - s.settings.isLoading = false - }) - } - } - ignorePromise(f()) - }, - loadTlfSyncConfig: tlfPath => { - const f = async () => { - const parsedPath = Constants.parsePath(tlfPath) - if (parsedPath.kind !== T.FS.PathKind.GroupTlf && parsedPath.kind !== T.FS.PathKind.TeamTlf) { - return - } - try { - const result = await T.RPCGen.SimpleFSSimpleFSFolderSyncConfigAndStatusRpcPromise({ - path: Constants.pathToRPCPath(tlfPath), - }) - const syncConfig = getSyncConfigFromRPC(parsedPath.tlfName, parsedPath.tlfType, result.config) - const tlfName = parsedPath.tlfName - const tlfType = parsedPath.tlfType - - set(s => { - const existing = s.tlfs[tlfType].get(tlfName) - if (existing && existing !== Constants.unknownTlf) { - s.tlfs[tlfType].set(tlfName, T.castDraft({...existing, syncConfig})) - return - } - - const additionalPath = T.FS.pathConcat(T.FS.pathConcat(Constants.defaultPath, tlfType), tlfName) - const existingAdditional = s.tlfs.additionalTlfs.get(additionalPath) - if (existingAdditional && existingAdditional !== Constants.unknownTlf) { - s.tlfs.additionalTlfs.set(additionalPath, T.castDraft({...existingAdditional, syncConfig})) - } }) - } catch (e) { - errorToActionOrThrow(e, tlfPath) - return - } - } - ignorePromise(f()) - }, - loadUploadStatus: () => { - const f = async () => { - try { - const uploadStates = await T.RPCGen.SimpleFSSimpleFSGetUploadStatusRpcPromise() - set(s => { - const writingToJournal = new Map( - uploadStates?.map(uploadState => { - const path = rpcPathToPath(uploadState.targetPath) - const oldUploadState = s.uploads.writingToJournal.get(path) - return [ - path, - oldUploadState && - uploadState.error === oldUploadState.error && - uploadState.canceled === oldUploadState.canceled && - uploadState.uploadID === oldUploadState.uploadID - ? oldUploadState - : uploadState, - ] - }) - ) - if (!isEqual(writingToJournal, s.uploads.writingToJournal)) { - s.uploads.writingToJournal = writingToJournal - } - }) - } catch (err) { - errorToActionOrThrow(err) - } - } - ignorePromise(f()) - }, - loadedPathInfo: (path, info) => { - set(s => { - s.pathInfos.set(path, info) - }) - }, - moveOrCopy: (destinationParentPath, source, type) => { - const f = async () => { - const params = - source.type === T.FS.DestinationPickerSource.MoveOrCopy - ? [ - { - dest: Constants.pathToRPCPath( - T.FS.pathConcat(destinationParentPath, T.FS.getPathName(source.path)) - ), - opID: makeUUID(), - overwriteExistingFiles: false, - src: Constants.pathToRPCPath(source.path), - }, - ] - : source.source - .map(item => ({originalPath: item.originalPath ?? '', scaledPath: item.scaledPath})) - .filter(({originalPath}) => !!originalPath) - .map(({originalPath, scaledPath}) => ({ - dest: Constants.pathToRPCPath( - T.FS.pathConcat( - destinationParentPath, - T.FS.getLocalPathName(originalPath) - // We use the local path name here since we only care about file name. - ) - ), - opID: makeUUID(), - overwriteExistingFiles: false, - src: { - PathType: T.RPCGen.PathType.local, - local: T.FS.getNormalizedLocalPath( - useConfigState.getState().incomingShareUseOriginal - ? originalPath - : scaledPath || originalPath - ), - } as T.RPCGen.Path, - })) - - try { - const rpc = - type === 'move' - ? T.RPCGen.SimpleFSSimpleFSMoveRpcPromise - : T.RPCGen.SimpleFSSimpleFSCopyRecursiveRpcPromise - await Promise.all(params.map(async p => rpc(p))) - await Promise.all(params.map(async ({opID}) => T.RPCGen.SimpleFSSimpleFSWaitRpcPromise({opID}))) - // We get source/dest paths from state rather than action, so we can't - // just retry it. If we do want retry in the future we can include those - // paths in the action. - } catch (e) { - errorToActionOrThrow(e, destinationParentPath) - return - } + } catch {} } ignorePromise(f()) }, - newFolderRow: parentPath => { - const parentPathItem = Constants.getPathItem(get().pathItems, parentPath) - if (parentPathItem.type !== T.FS.PathType.Folder) { - console.warn(`bad parentPath: ${parentPathItem.type}`) - return - } - - const existingNewFolderNames = new Set([...get().edits.values()].map(({name}) => name)) - - let newFolderName = 'New Folder' - let i = 2 - while (parentPathItem.children.has(newFolderName) || existingNewFolderNames.has(newFolderName)) { - newFolderName = `New Folder ${i}` - ++i - } - - set(s => { - s.edits.set(makeEditID(), { - ...Constants.emptyNewFolder, - name: newFolderName, - originalName: newFolderName, - parentPath, - }) - }) - }, onChangedFocus: appFocused => { const driverStatus = get().sfmi.driverStatus if ( @@ -1509,156 +709,28 @@ export const useFSState = Z.createZustand('fs', (set, get) => { }, onEngineIncomingImpl: action => { switch (action.type) { - case 'keybase.1.NotifyBadges.badgeState': - if ( - !isMobile && - shouldRunBackgroundFSRPC() && - shouldReloadFavoritesFromBadgeState(action.payload.params.badgeState) - ) { - get().dispatch.favoritesLoad() - } - break case 'keybase.1.NotifyFS.FSOverallSyncStatusChanged': - get().dispatch.syncStatusChanged(action.payload.params.status) - break - case 'keybase.1.NotifyFS.FSSubscriptionNotifyPath': { - const {clientID, path, topics} = action.payload.params - get().dispatch.onPathChange(clientID, path, topics ?? []) + syncStatusChanged(action.payload.params.status) break - } case 'keybase.1.NotifyFS.FSSubscriptionNotify': { const {clientID, topic} = action.payload.params - get().dispatch.onSubscriptionNotify(clientID, topic) + onSubscriptionNotify(clientID, topic) break } default: } }, - onPathChange: (cid, path, topics) => { - if (cid !== clientID) { - return - } - - const {folderListLoad} = useFSState.getState().dispatch - topics.forEach(topic => { - switch (topic) { - case T.RPCGen.PathSubscriptionTopic.children: - folderListLoad(T.FS.stringToPath(path), false) - break - case T.RPCGen.PathSubscriptionTopic.stat: - get().dispatch.loadPathMetadata(T.FS.stringToPath(path)) - break - } - }) - }, - onSubscriptionNotify: (cid, topic) => { - const f = async () => { - if (cid !== clientID || !shouldRunBackgroundFSRPC()) { - return - } - switch (topic) { - case T.RPCGen.SubscriptionTopic.favorites: - get().dispatch.favoritesLoad() - break - case T.RPCGen.SubscriptionTopic.journalStatus: - get().dispatch.pollJournalStatus() - break - case T.RPCGen.SubscriptionTopic.onlineStatus: - await checkIfWeReConnectedToMDServerUpToNTimes(1) - break - case T.RPCGen.SubscriptionTopic.downloadStatus: - get().dispatch.loadDownloadStatus() - break - case T.RPCGen.SubscriptionTopic.uploadStatus: - get().dispatch.loadUploadStatus() - break - case T.RPCGen.SubscriptionTopic.filesTabBadge: - get().dispatch.loadFilesTabBadge() - break - case T.RPCGen.SubscriptionTopic.settings: - get().dispatch.loadSettings() - break - case T.RPCGen.SubscriptionTopic.overallSyncStatus: - break - } - } - ignorePromise(f()) - }, - pollJournalStatus: () => { - if (pollJournalStatusPolling || !shouldRunBackgroundFSRPC()) { - return - } - pollJournalStatusPolling = true - const generation = asyncGeneration - - const getWaitDuration = (endEstimate: number | undefined, lower: number, upper: number): number => { - if (!endEstimate) { - return upper - } - const diff = endEstimate - Date.now() - return diff < lower ? lower : diff > upper ? upper : diff - } - - const f = async () => { - let shouldRefreshDaemonStatus = false - try { - while (isCurrentAsyncGeneration(generation)) { - const {syncingPaths, totalSyncingBytes, endEstimate} = - await T.RPCGen.SimpleFSSimpleFSSyncStatusRpcPromise({ - filter: T.RPCGen.ListFilter.filterSystemHidden, - }) - if (!isCurrentAsyncGeneration(generation)) { - return - } - get().dispatch.journalUpdate( - (syncingPaths || []).map(T.FS.stringToPath), - totalSyncingBytes, - endEstimate ?? undefined - ) - - // It's possible syncingPaths has not been emptied before - // totalSyncingBytes becomes 0. So check both. - if (totalSyncingBytes <= 0 && !syncingPaths?.length) { - break - } - useNotifState.getState().dispatch.badgeApp('kbfsUploading', true) - await timeoutPromise(getWaitDuration(endEstimate || undefined, 100, 4000)) // 0.1s to 4s - } - } finally { - if (generation === asyncGeneration) { - pollJournalStatusPolling = false - } - shouldRefreshDaemonStatus = isCurrentAsyncGeneration(generation) - useNotifState.getState().dispatch.badgeApp('kbfsUploading', false) - } - if (!shouldRefreshDaemonStatus) { - return - } - get().dispatch.checkKbfsDaemonRpcStatus() - } - ignorePromise(f()) - }, - redbar: error => { - set(s => { - s.errors.push(error) - }) - }, refreshDriverStatusDesktop: () => { const f = async () => { try { const previousType = get().sfmi.driverStatus.type const status = await refreshDriverStatusInPlatform() - get().dispatch.setDriverStatus(fuseStatusToDriverStatus(status)) + setDriverStatus(fuseStatusToDriverStatus(status)) if (status?.kextStarted && previousType === T.FS.DriverStatusType.Disabled) { const path = T.FS.stringToPath('/keybase') - const {sfmi, pathItems} = get() + const {sfmi} = get() try { - await openPathInSystemFileManagerInPlatform( - path, - pathItems, - sfmi.driverStatus, - sfmi.directMountDir - ) + await openPathInSystemFileManagerInPlatform(path, sfmi.driverStatus, sfmi.directMountDir) } catch (e) { errorToActionOrThrow(e, path) } @@ -1669,25 +741,8 @@ export const useFSState = Z.createZustand('fs', (set, get) => { } ignorePromise(f()) }, - refreshMountDirsDesktop: () => { - const f = async () => { - const {sfmi, dispatch} = get() - if (sfmi.driverStatus.type !== T.FS.DriverStatusType.Enabled) { - return - } - try { - const {directMountDir, preferredMountDirs} = await refreshMountDirsInPlatform() - dispatch.setDirectMountDir(directMountDir) - dispatch.setPreferredMountDirs(preferredMountDirs) - } catch (e) { - errorToActionOrThrow(e) - } - } - ignorePromise(f()) - }, resetState: () => { asyncGeneration++ - lastFavoritesBadgeState = {newTlfs: 0, rekeysNeeded: 0} pollJournalStatusPolling = false waitForKbfsDaemonInProgress = false unsubscribeAll() @@ -1697,257 +752,6 @@ export const useFSState = Z.createZustand('fs', (set, get) => { dispatch: s.dispatch, })) }, - setCriticalUpdate: u => { - set(s => { - s.criticalUpdate = u - }) - }, - setDebugLevel: level => { - const f = async () => { - await T.RPCGen.SimpleFSSimpleFSSetDebugLevelRpcPromise({level}) - } - ignorePromise(f()) - }, - setDirectMountDir: directMountDir => { - set(s => { - s.sfmi.directMountDir = directMountDir - }) - }, - setDriverStatus: driverStatus => { - set(s => { - s.sfmi.driverStatus = driverStatus - }) - get().dispatch.refreshMountDirsDesktop() - }, - setEditName: (editID, name) => { - set(s => { - const edit = s.edits.get(editID) - if (!edit || edit.name === name) { - return - } - edit.error = undefined - edit.name = name - }) - }, - setPathItemActionMenuDownload: (downloadID, intent) => { - set(s => { - s.pathItemActionMenu.downloadID = downloadID - s.pathItemActionMenu.downloadIntent = intent - }) - }, - setPathSoftError: (path, softError) => { - set(s => { - if (softError) { - s.softErrors.pathErrors.set(path, softError) - } else { - s.softErrors.pathErrors.delete(path) - } - }) - }, - setPreferredMountDirs: preferredMountDirs => { - set(s => { - s.sfmi.preferredMountDirs = T.castDraft(preferredMountDirs) - }) - }, - setSorting: (path, sortSetting) => { - set(s => { - const old = s.pathUserSettings.get(path) - if (old) { - old.sort = sortSetting - } else { - s.pathUserSettings.set(path, {...Constants.defaultPathUserSetting, sort: sortSetting}) - } - }) - }, - setSpaceAvailableNotificationThreshold: threshold => { - const f = async () => { - await T.RPCGen.SimpleFSSimpleFSSetNotificationThresholdRpcPromise({ - threshold, - }) - get().dispatch.loadSettings() - } - ignorePromise(f()) - }, - setTlfSoftError: (path, softError) => { - set(s => { - if (softError) { - s.softErrors.tlfErrors.set(path, softError) - } else { - s.softErrors.tlfErrors.delete(path) - } - }) - }, - setTlfSyncConfig: (tlfPath, enabled) => { - const f = async () => { - await T.RPCGen.SimpleFSSimpleFSSetFolderSyncConfigRpcPromise( - { - config: {mode: enabled ? T.RPCGen.FolderSyncMode.enabled : T.RPCGen.FolderSyncMode.disabled}, - path: Constants.pathToRPCPath(tlfPath), - }, - S.waitingKeyFSSyncToggle - ) - get().dispatch.loadTlfSyncConfig(tlfPath) - } - ignorePromise(f()) - }, - setTlfsAsUnloaded: () => { - set(s => { - s.tlfs.loaded = false - }) - }, - startManualConflictResolution: tlfPath => { - const f = async () => { - await T.RPCGen.SimpleFSSimpleFSClearConflictStateRpcPromise({ - path: Constants.pathToRPCPath(tlfPath), - }) - get().dispatch.favoritesLoad() - } - ignorePromise(f()) - }, - startRename: path => { - const parentPath = T.FS.getPathParent(path) - const originalName = T.FS.getPathName(path) - set(s => { - s.edits.set(makeEditID(), { - name: originalName, - originalName, - parentPath, - type: T.FS.EditType.Rename, - }) - }) - }, - subscribeNonPath: (subscriptionID, topic) => { - const f = async () => { - try { - await T.RPCGen.SimpleFSSimpleFSSubscribeNonPathRpcPromise({ - clientID, - deduplicateIntervalSecond: subscriptionDeduplicateIntervalSecond, - identifyBehavior: T.RPCGen.TLFIdentifyBehavior.fsGui, - subscriptionID, - topic, - }) - } catch (err) { - errorToActionOrThrow(err) - } - } - ignorePromise(f()) - }, - subscribePath: (subscriptionID, path, topic) => { - const f = async () => { - try { - await T.RPCGen.SimpleFSSimpleFSSubscribePathRpcPromise({ - clientID, - deduplicateIntervalSecond: subscriptionDeduplicateIntervalSecond, - identifyBehavior: T.RPCGen.TLFIdentifyBehavior.fsGui, - kbfsPath: T.FS.pathToString(path), - subscriptionID, - topic, - }) - } catch (error) { - if (!(error instanceof RPCError)) { - return - } - if (error.code !== T.RPCGen.StatusCode.scteamcontactsettingsblock) { - // We'll handle this error in loadAdditionalTLF instead. - errorToActionOrThrow(error, path) - } - } - } - ignorePromise(f()) - }, - syncStatusChanged: status => { - const diskSpaceStatus = status.outOfSyncSpace - ? T.FS.DiskSpaceStatus.Error - : status.localDiskBytesAvailable < get().settings.spaceAvailableNotificationThreshold - ? T.FS.DiskSpaceStatus.Warning - : T.FS.DiskSpaceStatus.Ok - - const oldStatus = get().overallSyncStatus.diskSpaceStatus - set(s => { - s.overallSyncStatus.syncingFoldersProgress = status.prefetchProgress - s.overallSyncStatus.diskSpaceStatus = diskSpaceStatus - }) - - // Only notify about the disk space status if it has changed. - if (oldStatus !== diskSpaceStatus) { - switch (diskSpaceStatus) { - case T.FS.DiskSpaceStatus.Error: { - NotifyPopup('Sync Error', { - body: 'You are out of disk space. Some folders could not be synced.', - sound: true, - }) - useNotifState.getState().dispatch.badgeApp('outOfSpace', status.outOfSyncSpace) - break - } - case T.FS.DiskSpaceStatus.Warning: - { - const threshold = Constants.humanizeBytes(get().settings.spaceAvailableNotificationThreshold, 0) - NotifyPopup('Disk Space Low', { - body: `You have less than ${threshold} of storage space left.`, - }) - // Only show the banner if the previous state was OK and the new state - // is warning. Otherwise we rely on the previous state of the banner. - if (oldStatus === T.FS.DiskSpaceStatus.Ok) { - set(s => { - s.overallSyncStatus.showingBanner = true - }) - } - } - break - case T.FS.DiskSpaceStatus.Ok: - break - default: - } - } - }, - unsubscribe: subscriptionID => { - const f = async () => { - try { - await T.RPCGen.SimpleFSSimpleFSUnsubscribeRpcPromise({ - clientID, - identifyBehavior: T.RPCGen.TLFIdentifyBehavior.fsGui, - subscriptionID, - }) - } catch {} - } - ignorePromise(f()) - }, - upload: (parentPath, localPath) => { - const f = async () => { - try { - await T.RPCGen.SimpleFSSimpleFSStartUploadRpcPromise({ - sourceLocalPath: T.FS.getNormalizedLocalPath(localPath), - targetParentPath: Constants.pathToRPCPath(parentPath).kbfs, - }) - } catch (err) { - errorToActionOrThrow(err) - } - } - ignorePromise(f()) - }, - userFileEditsLoad: () => { - const f = async () => { - if (!shouldRunBackgroundFSRPC()) { - return - } - const generation = asyncGeneration - try { - const writerEdits = await T.RPCGen.SimpleFSSimpleFSUserEditHistoryRpcPromise() - if (!isCurrentAsyncGeneration(generation)) { - return - } - set(s => { - s.tlfUpdates = T.castDraft(userTlfHistoryRPCToState(writerEdits || [])) - }) - } catch (error) { - if (!isCurrentAsyncGeneration(generation)) { - return - } - errorToActionOrThrow(error) - } - } - ignorePromise(f()) - }, userIn: () => { const f = async () => { await T.RPCGen.SimpleFSSimpleFSUserInRpcPromise({clientID}) @@ -1961,39 +765,10 @@ export const useFSState = Z.createZustand('fs', (set, get) => { } ignorePromise(f()) }, - waitForKbfsDaemon: () => { - if (waitForKbfsDaemonInProgress || !shouldRunBackgroundFSRPC()) { - return - } - waitForKbfsDaemonInProgress = true - const generation = asyncGeneration - set(s => { - s.kbfsDaemonStatus.rpcStatus = T.FS.KbfsDaemonRpcStatus.Waiting - }) - const f = async () => { - try { - await T.RPCGen.configWaitForClientRpcPromise({ - clientType: T.RPCGen.ClientType.kbfs, - timeout: 60, // 1min. This is arbitrary since we're gonna check again anyway if we're not connected. - }) - } catch { - } finally { - if (generation === asyncGeneration) { - waitForKbfsDaemonInProgress = false - } - } - if (!isCurrentAsyncGeneration(generation)) { - return - } - get().dispatch.checkKbfsDaemonRpcStatus() - } - ignorePromise(f()) - }, } return { ...initialStore, dispatch, - getUploadIconForFilesTab, } }) diff --git a/shared/stores/shell.tsx b/shared/stores/shell.tsx index 9e421887c46a..7bb9237e8ee0 100644 --- a/shared/stores/shell.tsx +++ b/shared/stores/shell.tsx @@ -25,6 +25,7 @@ type Store = T.Immutable<{ active: boolean appFocused: boolean forceSmallNav: boolean + fsCriticalUpdate: boolean mobileAppState: 'active' | 'background' | 'inactive' | 'unknown' networkStatus?: {online: boolean; type: ConnectionType; isInit?: boolean} notifySound: boolean @@ -37,6 +38,7 @@ const initialStore: Store = { active: true, appFocused: true, forceSmallNav: false, + fsCriticalUpdate: false, mobileAppState: 'unknown', networkStatus: undefined, notifySound: false, @@ -65,6 +67,7 @@ export type State = Store & { resetState: (isDebug?: boolean) => void setActive: (a: boolean) => void setForceSmallNav: (f: boolean) => void + setFsCriticalUpdate: (u: boolean) => void setMobileAppState: (nextAppState: 'active' | 'background' | 'inactive') => void setNotifySound: (n: boolean) => void setOpenAtLogin: (open: boolean) => void @@ -172,7 +175,7 @@ export const useShellState = Z.createZustand('shell', (set, get) => { } ignorePromise(updateFS()) }, - // Shell-owned prefs and focus/window state should survive account-level resets. + // Shell-owned prefs, focus/window state, and local shell badges should survive account-level resets. resetState: () => {}, setActive: a => { set(s => { @@ -194,6 +197,12 @@ export const useShellState = Z.createZustand('shell', (set, get) => { } ignorePromise(f()) }, + setFsCriticalUpdate: u => { + if (get().fsCriticalUpdate === u) return + set(s => { + s.fsCriticalUpdate = u + }) + }, setMobileAppState: nextAppState => { if (get().mobileAppState === nextAppState) return set(s => { diff --git a/shared/stores/tests/fs.test.ts b/shared/stores/tests/fs.test.ts index 1fcf4f713f78..6990642acaf5 100644 --- a/shared/stores/tests/fs.test.ts +++ b/shared/stores/tests/fs.test.ts @@ -1,28 +1,46 @@ /// -import * as Constants from '../../constants/fs' -import {isMobile} from '../../constants/platform' -import * as T from '../../constants/types' +import * as T from '@/constants/types' import {useConfigState} from '../config' import {useCurrentUserState} from '../current-user' -import {makeEditID, resetBannerType, useFSState} from '../fs' +import { + computeBadgeNumberForTlfList, + errorToActionOrThrowWithHandlers, + getUploadIconForTlfType, + makeEditID, + resetBannerTypeFromTlf, + unknownTlf, + useFSState, +} from '../fs' + +const normalConflictState = { + localViewTlfPaths: [], + resolvingConflict: false, + stuckInConflict: false, + type: T.FS.ConflictStateType.NormalView, +} satisfies T.FS.ConflictStateNormalView + +const makeTlf = (p: Partial = {}): T.FS.Tlf => ({ + ...unknownTlf, + conflictState: p.conflictState ?? normalConflictState, + name: p.name ?? 'alice,bob', + resetParticipants: p.resetParticipants ?? [], + ...p, +}) -const bootstrapCurrentUser = () => { +beforeEach(() => { + useConfigState.setState({loggedIn: false, userSwitching: false} as any) useCurrentUserState.getState().dispatch.setBootstrap({ deviceID: 'device-id', - deviceName: 'device-name', + deviceName: 'test-device', uid: 'uid', username: 'alice', }) -} - -beforeEach(() => { - bootstrapCurrentUser() - useConfigState.setState({loggedIn: false, userSwitching: false} as any) useFSState.getState().dispatch.resetState() }) afterEach(() => { useConfigState.setState({loggedIn: false, userSwitching: false} as any) + useCurrentUserState.getState().dispatch.resetState() useFSState.getState().dispatch.resetState() }) @@ -35,123 +53,93 @@ test('makeEditID returns distinct non-empty edit identifiers', () => { expect(first).not.toBe(second) }) -test('soft error setters add and remove path and tlf errors', () => { - const {dispatch} = useFSState.getState() - const path = T.FS.stringToPath('/keybase/private/alice/file.txt') - const tlfPath = T.FS.stringToPath('/keybase/private/alice') +test('resetBannerTypeFromTlf classifies no reset, self reset, and other participant resets', () => { + expect(resetBannerTypeFromTlf(makeTlf())).toBe(T.FS.ResetBannerNoOthersType.None) + expect(resetBannerTypeFromTlf(makeTlf({resetParticipants: ['alice']}))).toBe( + T.FS.ResetBannerNoOthersType.Self + ) + expect(resetBannerTypeFromTlf(makeTlf({resetParticipants: ['bob', 'charlie']}))).toBe(2) +}) - dispatch.setPathSoftError(path, T.FS.SoftError.Nonexistent) - dispatch.setTlfSoftError(tlfPath, T.FS.SoftError.NoAccess) - expect(useFSState.getState().softErrors.pathErrors.get(path)).toBe(T.FS.SoftError.Nonexistent) - expect(useFSState.getState().softErrors.tlfErrors.get(tlfPath)).toBe(T.FS.SoftError.NoAccess) +test('errorToActionOrThrowWithHandlers routes FS soft errors and redbars', () => { + const checkKbfsDaemonRpcStatus = jest.fn() + const redbar = jest.fn() + const setPathSoftError = jest.fn() + const setTlfSoftError = jest.fn() + const handlers = {checkKbfsDaemonRpcStatus, redbar, setPathSoftError, setTlfSoftError} + const path = T.FS.stringToPath('/keybase/private/alice,bob/file') - dispatch.setPathSoftError(path) - dispatch.setTlfSoftError(tlfPath) - expect(useFSState.getState().softErrors.pathErrors.has(path)).toBe(false) - expect(useFSState.getState().softErrors.tlfErrors.has(tlfPath)).toBe(false) -}) + errorToActionOrThrowWithHandlers(handlers, {code: T.RPCGen.StatusCode.sckbfsclienttimeout}, path) + expect(checkKbfsDaemonRpcStatus).toHaveBeenCalledTimes(1) -test('resetBannerType distinguishes between self resets, other resets, and no resets', () => { - const privateTlfName = 'alice,bob' - const path = T.FS.stringToPath(`/keybase/private/${privateTlfName}`) - - useFSState.setState({ - tlfs: { - ...useFSState.getState().tlfs, - private: new Map([ - [ - privateTlfName, - { - ...Constants.unknownTlf, - name: privateTlfName, - resetParticipants: ['alice'], - }, - ], - ]), - }, - } as any) - expect(resetBannerType(useFSState.getState(), path)).toBe(T.FS.ResetBannerNoOthersType.Self) - - useFSState.setState({ - tlfs: { - ...useFSState.getState().tlfs, - private: new Map([ - [ - privateTlfName, - { - ...Constants.unknownTlf, - name: privateTlfName, - resetParticipants: ['bob', 'carol'], - }, - ], - ]), - }, - } as any) - expect(resetBannerType(useFSState.getState(), path)).toBe(2) - - useFSState.setState({ - tlfs: { - ...useFSState.getState().tlfs, - private: new Map([[privateTlfName, {...Constants.unknownTlf, name: privateTlfName, resetParticipants: []}]]), - }, - } as any) - expect(resetBannerType(useFSState.getState(), path)).toBe(T.FS.ResetBannerNoOthersType.None) -}) + errorToActionOrThrowWithHandlers(handlers, {code: T.RPCGen.StatusCode.scsimplefsnotexist}, path) + expect(setPathSoftError).toHaveBeenCalledWith(path, T.FS.SoftError.Nonexistent) -test('badge engine refreshes favorites when fs badge counters change', () => { - const store = useFSState - const favoritesLoad = jest.fn() - useConfigState.setState({loggedIn: true, userSwitching: false} as any) - store.setState( - { - ...store.getState(), - dispatch: { - ...store.getState().dispatch, - favoritesLoad, - }, - }, - true + errorToActionOrThrowWithHandlers(handlers, {code: T.RPCGen.StatusCode.scsimplefsnoaccess}, path) + expect(setTlfSoftError).toHaveBeenCalledWith( + T.FS.stringToPath('/keybase/private/alice,bob'), + T.FS.SoftError.NoAccess ) - const action = { - payload: {params: {badgeState: {newTlfs: 1, rekeysNeeded: 0}}}, - type: 'keybase.1.NotifyBadges.badgeState', - } as any - - store.getState().dispatch.onEngineIncomingImpl(action) - store.getState().dispatch.onEngineIncomingImpl(action) - store.getState().dispatch.onEngineIncomingImpl({ - payload: {params: {badgeState: {newTlfs: 1, rekeysNeeded: 2}}}, - type: 'keybase.1.NotifyBadges.badgeState', - } as any) + errorToActionOrThrowWithHandlers(handlers, {code: T.RPCGen.StatusCode.scdeleted}, path) + expect(redbar).toHaveBeenCalledWith('A user in this shared folder has deleted their account.') - expect(favoritesLoad).toHaveBeenCalledTimes(isMobile ? 0 : 2) + expect(() => + errorToActionOrThrowWithHandlers(handlers, {code: T.RPCGen.StatusCode.scgeneric}, path) + ).toThrow() }) -test('pre-login badge events do not consume the first eligible favorites refresh', () => { - const store = useFSState - const favoritesLoad = jest.fn() - store.setState( - { - ...store.getState(), - dispatch: { - ...store.getState().dispatch, - favoritesLoad, - }, - }, - true - ) - - const action = { - payload: {params: {badgeState: {newTlfs: 1, rekeysNeeded: 0}}}, - type: 'keybase.1.NotifyBadges.badgeState', - } as any +test('computeBadgeNumberForTlfList counts only new non-ignored TLFs', () => { + const tlfList = new Map([ + ['new', makeTlf({isNew: true})], + ['ignored-new', makeTlf({isIgnored: true, isNew: true})], + ['old', makeTlf({isNew: false})], + ]) - useConfigState.setState({loggedIn: false, userSwitching: false} as any) - store.getState().dispatch.onEngineIncomingImpl(action) + expect(computeBadgeNumberForTlfList(tlfList)).toBe(1) +}) - useConfigState.setState({loggedIn: true, userSwitching: false} as any) - store.getState().dispatch.onEngineIncomingImpl(action) +test('getUploadIconForTlfType derives conflict, uploading, and offline upload status', () => { + const tlfType = T.FS.TlfType.Private + const tlfList = new Map([ + [ + 'alice,bob', + makeTlf({ + conflictState: { + ...normalConflictState, + stuckInConflict: true, + }, + }), + ], + ]) + const baseStatus = { + onlineStatus: T.FS.KbfsDaemonOnlineStatus.Online, + rpcStatus: T.FS.KbfsDaemonRpcStatus.Connected, + } + const baseUploads = { + endEstimate: undefined, + syncingPaths: new Set(), + totalSyncingBytes: 0, + writingToJournal: new Map(), + } + + expect(getUploadIconForTlfType(baseStatus, baseUploads, tlfList, tlfType)).toBe( + T.FS.UploadIcon.UploadingStuck + ) - expect(favoritesLoad).toHaveBeenCalledTimes(isMobile ? 0 : 1) + const activeUploads = { + ...baseUploads, + syncingPaths: new Set([T.FS.stringToPath('/keybase/private/alice,bob/file')]), + } + expect(getUploadIconForTlfType(baseStatus, activeUploads, new Map(), tlfType)).toBe( + T.FS.UploadIcon.Uploading + ) + expect( + getUploadIconForTlfType( + {...baseStatus, onlineStatus: T.FS.KbfsDaemonOnlineStatus.Offline}, + activeUploads, + new Map(), + tlfType + ) + ).toBe(T.FS.UploadIcon.AwaitingToUpload) }) diff --git a/shared/util/fs-storeless-actions.tsx b/shared/util/fs-storeless-actions.tsx index 251a5a29967e..00af60379ec9 100644 --- a/shared/util/fs-storeless-actions.tsx +++ b/shared/util/fs-storeless-actions.tsx @@ -6,24 +6,30 @@ import { openPathInSystemFileManagerDesktop as openPathInSystemFileManagerInPlatform, } from '@/stores/fs-platform' -export const openLocalPathInSystemFileManagerDesktop = (localPath: string) => { +export const openLocalPathInSystemFileManagerDesktop = ( + localPath: string, + onErrorOrThrow: (error: unknown) => void = errorToActionOrThrow +) => { const f = async () => { try { await openLocalPathInSystemFileManagerInPlatform(localPath) } catch (e) { - errorToActionOrThrow(e) + onErrorOrThrow(e) } } ignorePromise(f()) } -export const openPathInSystemFileManagerDesktop = (path: T.FS.Path) => { +export const openPathInSystemFileManagerDesktop = ( + path: T.FS.Path, + onErrorOrThrow: (error: unknown, path?: T.FS.Path) => void = errorToActionOrThrow +) => { const f = async () => { - const {sfmi, pathItems} = useFSState.getState() + const {sfmi} = useFSState.getState() try { - await openPathInSystemFileManagerInPlatform(path, pathItems, sfmi.driverStatus, sfmi.directMountDir) + await openPathInSystemFileManagerInPlatform(path, sfmi.driverStatus, sfmi.directMountDir) } catch (e) { - errorToActionOrThrow(e, path) + onErrorOrThrow(e, path) } } ignorePromise(f()) From 07a7f40d6848a2f93c61c534174ee15fe99fbb07 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Fri, 24 Apr 2026 17:29:59 -0400 Subject: [PATCH 07/27] WIP --- shared/constants/utils.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/constants/utils.tsx b/shared/constants/utils.tsx index 28be192c43f2..645dd8577ed7 100644 --- a/shared/constants/utils.tsx +++ b/shared/constants/utils.tsx @@ -47,6 +47,6 @@ export {default as useRPC} from '@/util/use-rpc' export {produce} from 'immer' export * from './immer' export {default as featureFlags} from '../util/feature-flags' -export {deferEffectUpdate, useOnMountOnce, useOnUnMountOnce, useLogMount} from './react' +export {useOnMountOnce, useOnUnMountOnce, useLogMount} from './react' export {debugWarning} from '@/util/debug-warning' export {isNetworkErr, RPCError} from '@/util/errors' From 36d7cef51bdeeac079751f65c7b1a7d57b004ac9 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 24 Apr 2026 17:32:27 -0400 Subject: [PATCH 08/27] WIP --- plans/use-effects-lint-todo.md | 8 ++-- shared/chat/blocking/block-modal.tsx | 52 ++++++++---------------- shared/chat/conversation/bot/install.tsx | 36 ++++++++++------ skill/react-effect-lints/SKILL.md | 5 ++- 4 files changed, 48 insertions(+), 53 deletions(-) diff --git a/plans/use-effects-lint-todo.md b/plans/use-effects-lint-todo.md index 58bd9922113f..5233b4a818ad 100644 --- a/plans/use-effects-lint-todo.md +++ b/plans/use-effects-lint-todo.md @@ -23,10 +23,10 @@ Scope: only `react-hooks/set-state-in-effect` findings. Ignore `refs`, `immutabi ## Batch 2: Chat Modals And Bot Install -- [ ] `shared/chat/blocking/block-modal.tsx:244:7` -- [ ] `shared/chat/conversation/attachment-get-titles.tsx:122:5` -- [ ] `shared/chat/conversation/bot/install.tsx:68:5` -- [ ] `shared/chat/conversation/bot/install.tsx:242:5` +- [x] `shared/chat/blocking/block-modal.tsx:244:7` - moved default block settings into state initializers and left the effect for the one-time external block-state refresh. +- [x] `shared/chat/conversation/attachment-get-titles.tsx:122:5` - already absent in current source; the logged `kbfsPreviewURL` reset effect is no longer present. +- [x] `shared/chat/conversation/bot/install.tsx:68:5` - derived the visible conversation ID from the input conversation or a team-tagged async lookup result. +- [x] `shared/chat/conversation/bot/install.tsx:242:5` - tagged loaded bot public commands by username instead of clearing command state in an effect. ## Batch 3: Chat Input And Inbox State diff --git a/shared/chat/blocking/block-modal.tsx b/shared/chat/blocking/block-modal.tsx index 051f01eb9f89..87d3aa01ce9b 100644 --- a/shared/chat/blocking/block-modal.tsx +++ b/shared/chat/blocking/block-modal.tsx @@ -209,11 +209,26 @@ const Container = function BlockModal(ownProps: OwnProps) { navigateUp() } } - const [blockTeam, setBlockTeam] = React.useState(true) + const [blockTeam, setBlockTeam] = React.useState(context !== 'message-popup') const [finishClicked, setFinishClicked] = React.useState(false) // newBlocks holds a Map of blocks that will be applied when user clicks // "Finish" button. reports is the same thing for reporting. - const [newBlocks, setNewBlocks] = React.useState(new Map()) + const [newBlocks, setNewBlocks] = React.useState(() => { + const initialBlocks = new Map() + if (blockUserByDefault && adderUsername) { + initialBlocks.set(adderUsername, { + chatBlocked: true, + followBlocked: true, + report: reportsUserByDefault + ? { + ...defaultReport, + ...(flagUserByDefault ? {reason: reasons[reasons.length - 2] ?? defaultReport.reason} : {}), + } + : undefined, + }) + } + return initialBlocks + }) const loadedOnceRef = React.useRef(false) React.useEffect(() => { @@ -226,38 +241,7 @@ const Container = function BlockModal(ownProps: OwnProps) { if (usernames.length) { refreshBlocksFor(usernames) } - - // Set default checkbox block values for adder user. We don't care if they - // are already blocked, setting a block is idempotent. - if (blockUserByDefault && adderUsername) { - const map = newBlocks - map.set(adderUsername, { - chatBlocked: true, - followBlocked: true, - report: reportsUserByDefault - ? { - ...defaultReport, - ...(flagUserByDefault ? {reason: reasons[reasons.length - 2]} : {}), - } - : undefined, - }) - setNewBlocks(new Map(map)) - } - if (context === 'message-popup') { - // Do not block conversation by default when coming from message popup - // menu. - setBlockTeam(false) - } - }, [ - adderUsername, - blockUserByDefault, - context, - flagUserByDefault, - newBlocks, - otherUsernames, - refreshBlocksFor, - reportsUserByDefault, - ]) + }, [adderUsername, otherUsernames, refreshBlocksFor]) const lastFinishWaitingRef = React.useRef(finishWaiting) React.useEffect(() => { diff --git a/shared/chat/conversation/bot/install.tsx b/shared/chat/conversation/bot/install.tsx index 01b093e9e387..bee739e89a0f 100644 --- a/shared/chat/conversation/bot/install.tsx +++ b/shared/chat/conversation/bot/install.tsx @@ -60,13 +60,18 @@ export const useRefreshBotMembershipOnSuccess = ( export const useBotConversationIDKey = (inConvIDKey?: T.Chat.ConversationIDKey, teamID?: T.Teams.TeamID) => { const cleanInConvIDKey = T.Chat.isValidConversationIDKey(inConvIDKey ?? '') ? inConvIDKey : undefined - const [conversationIDKey, setConversationIDKey] = React.useState(cleanInConvIDKey) + const [generalConversation, setGeneralConversation] = React.useState< + | { + conversationIDKey: T.Chat.ConversationIDKey + teamID: T.Teams.TeamID + } + | undefined + >() const findGeneralConvIDFromTeamID = C.useRPC(T.RPCChat.localFindGeneralConvFromTeamIDRpcPromise) const requestIDRef = React.useRef(0) - - React.useEffect(() => { - setConversationIDKey(cleanInConvIDKey) - }, [cleanInConvIDKey]) + const conversationIDKey = + cleanInConvIDKey ?? + (generalConversation?.teamID === teamID ? generalConversation.conversationIDKey : undefined) React.useEffect(() => { requestIDRef.current += 1 @@ -85,7 +90,7 @@ export const useBotConversationIDKey = (inConvIDKey?: T.Chat.ConversationIDKey, return } ConvoState.metasReceived([meta]) - setConversationIDKey(meta.conversationIDKey) + setGeneralConversation({conversationIDKey: meta.conversationIDKey, teamID}) }, () => {} ) @@ -135,7 +140,13 @@ const InstallBotPopup = (props: Props) => { const [installWithRestrict, setInstallWithRestrict] = React.useState(true) const [installInConvs, setInstallInConvs] = React.useState>([]) const [disableDone, setDisableDone] = React.useState(false) - const [botPublicCommands, setBotPublicCommands] = React.useState() + const [loadedBotPublicCommands, setLoadedBotPublicCommands] = React.useState< + | { + botUsername: string + commands: T.Chat.BotPublicCommands + } + | undefined + >() const meta = ConvoState.useChatContext(s => s.meta) const commandsFromMeta = ( @@ -148,7 +159,9 @@ const InstallBotPopup = (props: Props) => { const commands = commandsFromMeta.length > 0 ? ({commands: commandsFromMeta, loadError: false} satisfies T.Chat.BotPublicCommands) - : botPublicCommands + : loadedBotPublicCommands?.botUsername === botUsername + ? loadedBotPublicCommands.commands + : undefined const featured = useFeaturedBot(botUsername) const teamRole = ConvoState.useChatContext(s => s.botTeamRoleMap.get(botUsername)) @@ -238,9 +251,6 @@ const InstallBotPopup = (props: Props) => { const loadBotPublicCommands = C.useRPC(T.RPCChat.localListPublicBotCommandsLocalRpcPromise) const botPublicCommandsRequestIDRef = React.useRef(0) const clearedWaitingForBotRef = React.useRef(undefined) - React.useEffect(() => { - setBotPublicCommands(undefined) - }, [botUsername]) React.useEffect(() => { if (!mutationWaiting && clearedWaitingForBotRef.current !== botUsername) { clearedWaitingForBotRef.current = botUsername @@ -260,13 +270,13 @@ const InstallBotPopup = (props: Props) => { return } const commands = (res.commands ?? []).map(command => command.name) - setBotPublicCommands({commands, loadError: false}) + setLoadedBotPublicCommands({botUsername, commands: {commands, loadError: false}}) }, () => { if (botPublicCommandsRequestIDRef.current !== requestID) { return } - setBotPublicCommands({commands: [], loadError: true}) + setLoadedBotPublicCommands({botUsername, commands: {commands: [], loadError: true}}) } ) return () => { diff --git a/skill/react-effect-lints/SKILL.md b/skill/react-effect-lints/SKILL.md index 3a136d159c13..0ba304e4f0de 100644 --- a/skill/react-effect-lints/SKILL.md +++ b/skill/react-effect-lints/SKILL.md @@ -31,8 +31,9 @@ Authoritative references: - External synchronization or async request 3. Prefer the matching refactor pattern below. 4. Preserve existing guards, platform branches, waiting keys, route behavior, and stale async protection unless proven dead. -5. Remove now-unused imports, state, refs, helpers, styles, and type parameters. -6. In this repo, do not run `yarn`, `npm`, lint, or TypeScript unless `node_modules` exists and the user's machine guidance allows it. +5. Do not move hook or component logic to module scope to avoid a lint. Module-level work runs at import time, bypasses React lifecycle and providers, and can leak behavior across accounts, routes, tests, or remounts. +6. Remove now-unused imports, state, refs, helpers, styles, and type parameters. +7. In this repo, do not run `yarn`, `npm`, lint, or TypeScript unless `node_modules` exists and the user's machine guidance allows it. ## Refactor Patterns From df882f74f449f5488f84abaf4b2637e9d9ad22a7 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 24 Apr 2026 17:34:53 -0400 Subject: [PATCH 09/27] WIP --- skill/react-effect-lints/SKILL.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/skill/react-effect-lints/SKILL.md b/skill/react-effect-lints/SKILL.md index 0ba304e4f0de..08f305b6fd95 100644 --- a/skill/react-effect-lints/SKILL.md +++ b/skill/react-effect-lints/SKILL.md @@ -32,8 +32,9 @@ Authoritative references: 3. Prefer the matching refactor pattern below. 4. Preserve existing guards, platform branches, waiting keys, route behavior, and stale async protection unless proven dead. 5. Do not move hook or component logic to module scope to avoid a lint. Module-level work runs at import time, bypasses React lifecycle and providers, and can leak behavior across accounts, routes, tests, or remounts. -6. Remove now-unused imports, state, refs, helpers, styles, and type parameters. -7. In this repo, do not run `yarn`, `npm`, lint, or TypeScript unless `node_modules` exists and the user's machine guidance allows it. +6. When working from a plan that groups lint fixes into batches, do exactly one batch per turn. After validating and updating the checklist for that batch, stop and report the result instead of starting the next batch. +7. Remove now-unused imports, state, refs, helpers, styles, and type parameters. +8. In this repo, do not run `yarn`, `npm`, lint, or TypeScript unless `node_modules` exists and the user's machine guidance allows it. ## Refactor Patterns From b94976d5bb87d4166d66b13c391e591feec6e321 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 24 Apr 2026 17:44:42 -0400 Subject: [PATCH 10/27] WIP --- shared/app/index.native.tsx | 3 +-- shared/chat/conversation/info-panel/index.tsx | 8 ++------ shared/util/use-debounce.tsx | 4 ++-- skill/react-effect-lints/SKILL.md | 12 +++++++++++- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/shared/app/index.native.tsx b/shared/app/index.native.tsx index c61dd7e86221..783d0376645e 100644 --- a/shared/app/index.native.tsx +++ b/shared/app/index.native.tsx @@ -51,8 +51,6 @@ const initDarkMode = () => { } catch {} } -initDarkMode() - const useDarkHookup = () => { const appStateRef = React.useRef('active') const setSystemDarkMode = DarkMode.useDarkModeState(s => s.dispatch.setSystemDarkMode) @@ -123,6 +121,7 @@ let inited = false const useInit = () => { if (inited) return inited = true + initDarkMode() Animated.addWhitelistedNativeProps({text: true}) install() const {batch} = C.useWaitingState.getState().dispatch diff --git a/shared/chat/conversation/info-panel/index.tsx b/shared/chat/conversation/info-panel/index.tsx index 874fbf5d175d..312028871375 100644 --- a/shared/chat/conversation/info-panel/index.tsx +++ b/shared/chat/conversation/info-panel/index.tsx @@ -18,7 +18,6 @@ type Props = { } const InfoPanelConnector = (ownProps: Props) => { - const initialTab = ownProps.tab const conversationIDKey = ConvoState.useChatContext(s => s.id) const meta = ConvoState.useConvoState(conversationIDKey, s => s.meta) const shouldNavigateOut = meta.conversationIDKey === Chat.noConversationIDKey @@ -27,7 +26,8 @@ const InfoPanelConnector = (ownProps: Props) => { const teamname = meta.teamname const {role: yourRole} = useChatTeam(meta.teamID, teamname) - const [selectedTab, onSelectTab] = React.useState(initialTab ?? 'members') + const [uncontrolledSelectedTab, onSelectTab] = React.useState('members') + const selectedTab = ownProps.tab ?? uncontrolledSelectedTab const [lastSNO, setLastSNO] = React.useState(shouldNavigateOut) const showInfoPanel = ConvoState.useChatContext(s => s.dispatch.showInfoPanel) @@ -50,10 +50,6 @@ const InfoPanelConnector = (ownProps: Props) => { } } - if (ownProps.tab !== undefined && ownProps.tab !== selectedTab) { - onSelectTab(ownProps.tab) - } - const getTabs = (): Array> => { const showSettings = !isPreview || Teams.isAdmin(yourRole) || Teams.isOwner(yourRole) diff --git a/shared/util/use-debounce.tsx b/shared/util/use-debounce.tsx index 88f807a7db2d..2df3d14c6423 100644 --- a/shared/util/use-debounce.tsx +++ b/shared/util/use-debounce.tsx @@ -30,7 +30,7 @@ export function useDebouncedCallback( options?: DebounceOptions ): DebouncedState { const funcRef = React.useRef(func) - React.useEffect(() => { + React.useLayoutEffect(() => { funcRef.current = func }, [func]) const runtimeRef = React.useRef>({}) @@ -147,7 +147,7 @@ export function useThrottledCallback( options?: DebounceOptions ): DebouncedState { const funcRef = React.useRef(func) - React.useEffect(() => { + React.useLayoutEffect(() => { funcRef.current = func }, [func]) const runtimeRef = React.useRef<{ diff --git a/skill/react-effect-lints/SKILL.md b/skill/react-effect-lints/SKILL.md index 08f305b6fd95..e8e9c1f05280 100644 --- a/skill/react-effect-lints/SKILL.md +++ b/skill/react-effect-lints/SKILL.md @@ -31,7 +31,7 @@ Authoritative references: - External synchronization or async request 3. Prefer the matching refactor pattern below. 4. Preserve existing guards, platform branches, waiting keys, route behavior, and stale async protection unless proven dead. -5. Do not move hook or component logic to module scope to avoid a lint. Module-level work runs at import time, bypasses React lifecycle and providers, and can leak behavior across accounts, routes, tests, or remounts. +5. Do not move hook or component logic to module scope to avoid a lint. Module-level work runs at import time, bypasses React lifecycle and providers, and can leak behavior across accounts, routes, tests, or remounts; keep initialization in an existing idempotent init path or component effect instead. 6. When working from a plan that groups lint fixes into batches, do exactly one batch per turn. After validating and updating the checklist for that batch, stop and report the result instead of starting the next batch. 7. Remove now-unused imports, state, refs, helpers, styles, and type parameters. 8. In this repo, do not run `yarn`, `npm`, lint, or TypeScript unless `node_modules` exists and the user's machine guidance allows it. @@ -90,6 +90,15 @@ Keep exported component names stable unless callers need a new export. First try to store a stable ID and derive the selected object or validity during render. This often removes the need to reset selection at all. +If a prop sometimes controls a value and otherwise the component owns it, derive the visible value during render instead of syncing state from the prop: + +```tsx +const [internalTab, setInternalTab] = React.useState('members') +const selectedTab = props.tab ?? internalTab +``` + +Do not use a render-time `setState` just to mirror a controlled prop into local state. + ```tsx const [selectedID, setSelectedID] = React.useState() const selected = items.find(item => item.id === selectedID) @@ -159,6 +168,7 @@ React.useEffect(() => { Prefer request/version IDs over broad `isMounted` refs when rejecting stale async results. If a real mount guard is required, set it true inside the effect body and false in cleanup so Strict Mode remounts do not leave it stuck false. +For stable callbacks that must always call the latest implementation, do not update a ref during render. Prefer `React.useEffectEvent` when the callback is used from an effect/subscription, and use `React.useLayoutEffect` for ref assignment when event handlers or timers need the latest callback immediately after commit. Keep `useEffectEvent` functions out of dependency arrays. ### Timers And Delayed UI From a18f4fc460a96b561c277755ad8fd93155b99b6b Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 24 Apr 2026 17:45:57 -0400 Subject: [PATCH 11/27] WIP --- shared/chat/conversation/info-panel/index.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/shared/chat/conversation/info-panel/index.tsx b/shared/chat/conversation/info-panel/index.tsx index 312028871375..069c3f9ab711 100644 --- a/shared/chat/conversation/info-panel/index.tsx +++ b/shared/chat/conversation/info-panel/index.tsx @@ -28,7 +28,6 @@ const InfoPanelConnector = (ownProps: Props) => { const [uncontrolledSelectedTab, onSelectTab] = React.useState('members') const selectedTab = ownProps.tab ?? uncontrolledSelectedTab - const [lastSNO, setLastSNO] = React.useState(shouldNavigateOut) const showInfoPanel = ConvoState.useChatContext(s => s.dispatch.showInfoPanel) const clearAttachmentView = ConvoState.useConvoState(conversationIDKey, s => s.dispatch.clearAttachmentView) @@ -43,12 +42,15 @@ const InfoPanelConnector = (ownProps: Props) => { clearAttachmentView() } }, [showInfoPanel, clearAttachmentView]) - if (lastSNO !== shouldNavigateOut) { - setLastSNO(shouldNavigateOut) - if (!lastSNO && shouldNavigateOut) { + + const lastShouldNavigateOutRef = React.useRef(shouldNavigateOut) + React.useEffect(() => { + const lastShouldNavigateOut = lastShouldNavigateOutRef.current + lastShouldNavigateOutRef.current = shouldNavigateOut + if (!lastShouldNavigateOut && shouldNavigateOut) { navigateToInbox() } - } + }, [shouldNavigateOut]) const getTabs = (): Array> => { const showSettings = !isPreview || Teams.isAdmin(yourRole) || Teams.isOwner(yourRole) From aa969858666478aee8c7d085e635a82041b16984 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 24 Apr 2026 17:55:02 -0400 Subject: [PATCH 12/27] WIP --- .../conversation/list-area/index.desktop.tsx | 71 +++++++++---------- skill/react-effect-lints/SKILL.md | 7 +- 2 files changed, 38 insertions(+), 40 deletions(-) diff --git a/shared/chat/conversation/list-area/index.desktop.tsx b/shared/chat/conversation/list-area/index.desktop.tsx index dcb93f3fdf2a..f73aa4ff9602 100644 --- a/shared/chat/conversation/list-area/index.desktop.tsx +++ b/shared/chat/conversation/list-area/index.desktop.tsx @@ -367,29 +367,8 @@ const useItems = (p: { }) => { const {messageOrdinals, centeredHighlightOrdinal, centeredOrdinal, editingOrdinal} = p const ordinalsInAWaypoint = 10 - const rowRenderer = (ordinal: T.Chat.Ordinal) => { - return ( -
- - -
- ) - } - - // TODO doesn't need all messageOrdinals in there, could just find buckets and push details down - const items = (() => { - const items: Array = [] + const waypointData = React.useMemo(() => { + const items: Array<{key: string; ordinals: Array}> = [] const numOrdinals = messageOrdinals.length let ordinals: Array = [] @@ -416,9 +395,7 @@ const useItems = (p: { const chunks = chunk(ordinals, 10) chunks.forEach((toAdd, cidx) => { const key = `${lastBucket || ''}:${cidx + baseIndex}` - items.push( - - ) + items.push({key, ordinals: toAdd}) }) // we pass previous so the OrdinalWaypoint can render the top item correctly ordinals = [] @@ -427,15 +404,7 @@ const useItems = (p: { } // If this is the centered ordinal, it goes into its own waypoint so we can easily scroll to it if (isCenteredOrdinal) { - const key = scrollOrdinalKey - items.push( - - ) + items.push({key: scrollOrdinalKey, ordinals: [ordinal]}) lastBucket = 0 baseIndex++ // push this up if we drop the centered ordinal waypoint } else { @@ -443,8 +412,36 @@ const useItems = (p: { } }) - return [, ...items, ] - })() + return items + }, [centeredOrdinal, messageOrdinals, ordinalsInAWaypoint]) + + const rowRenderer = (ordinal: T.Chat.Ordinal) => { + return ( +
+ + +
+ ) + } + + const items = [ + , + ...waypointData.map(({key, ordinals}) => ( + + )), + , + ] return items } diff --git a/skill/react-effect-lints/SKILL.md b/skill/react-effect-lints/SKILL.md index e8e9c1f05280..54e18aefa8b3 100644 --- a/skill/react-effect-lints/SKILL.md +++ b/skill/react-effect-lints/SKILL.md @@ -32,9 +32,10 @@ Authoritative references: 3. Prefer the matching refactor pattern below. 4. Preserve existing guards, platform branches, waiting keys, route behavior, and stale async protection unless proven dead. 5. Do not move hook or component logic to module scope to avoid a lint. Module-level work runs at import time, bypasses React lifecycle and providers, and can leak behavior across accounts, routes, tests, or remounts; keep initialization in an existing idempotent init path or component effect instead. -6. When working from a plan that groups lint fixes into batches, do exactly one batch per turn. After validating and updating the checklist for that batch, stop and report the result instead of starting the next batch. -7. Remove now-unused imports, state, refs, helpers, styles, and type parameters. -8. In this repo, do not run `yarn`, `npm`, lint, or TypeScript unless `node_modules` exists and the user's machine guidance allows it. +6. Preserve intentional render identity, memoization, virtualization, and caching behavior. Do not remove a cache or stable prop identity just to avoid a lint; replace render-time ref mutation with a React-safe equivalent such as render-derived memoized data. +7. When working from a plan that groups lint fixes into batches, do exactly one batch per turn. After validating and updating the checklist for that batch, stop and report the result instead of starting the next batch. +8. Remove now-unused imports, state, refs, helpers, styles, and type parameters. +9. In this repo, do not run `yarn`, `npm`, lint, or TypeScript unless `node_modules` exists and the user's machine guidance allows it. ## Refactor Patterns From 2f8e409085c108138e295da04df4a572491782fd Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 24 Apr 2026 17:59:19 -0400 Subject: [PATCH 13/27] WIP --- plans/use-effects-lint-todo.md | 2 +- .../conversation/list-area/index.desktop.tsx | 38 +++++++------------ 2 files changed, 14 insertions(+), 26 deletions(-) diff --git a/plans/use-effects-lint-todo.md b/plans/use-effects-lint-todo.md index 5233b4a818ad..258862b6eae9 100644 --- a/plans/use-effects-lint-todo.md +++ b/plans/use-effects-lint-todo.md @@ -37,7 +37,7 @@ Scope: only `react-hooks/set-state-in-effect` findings. Ignore `refs`, `immutabi ## Batch 4: Chat Message Wrappers And Timers -- [ ] `shared/chat/conversation/list-area/index.desktop.tsx:662:9` +- [x] `shared/chat/conversation/list-area/index.desktop.tsx:662:9` - preserved waypoint virtualization height with a measured-height ref instead of state set from an effect. - [ ] `shared/chat/conversation/messages/wrapper/exploding-height-retainer/index.desktop.tsx:20:7` - [ ] `shared/chat/conversation/messages/wrapper/exploding-height-retainer/index.native.tsx:135:7` - [ ] `shared/chat/conversation/messages/wrapper/exploding-meta.tsx:82:7` diff --git a/shared/chat/conversation/list-area/index.desktop.tsx b/shared/chat/conversation/list-area/index.desktop.tsx index f73aa4ff9602..77a492fb85eb 100644 --- a/shared/chat/conversation/list-area/index.desktop.tsx +++ b/shared/chat/conversation/list-area/index.desktop.tsx @@ -615,38 +615,26 @@ if (colorWaypoints) { const OrdinalWaypoint = function OrdinalWaypoint(p: OrdinalWaypointProps) { const {ordinals, id, rowRenderer} = p const estimatedHeight = 40 * ordinals.length - const [height, setHeight] = React.useState(-1) - const [isVisible, setVisible] = React.useState(false) + const measuredHeightRef = React.useRef(-1) const [wRef, setRef] = React.useState(null) + const [setContentRef] = React.useState(() => (ref: HTMLDivElement | null) => { + if (ref) { + const height = ref.offsetHeight + if (height) { + measuredHeightRef.current = height + } + } + setRef(ref) + }) const root = wRef?.closest('.chat-scroller') as HTMLElement | undefined const {isIntersecting} = useIntersectionObserver(wRef, {root}) - const lastIsIntersecting = React.useRef(isIntersecting) - - React.useEffect(() => { - if (lastIsIntersecting.current === isIntersecting) return - lastIsIntersecting.current = isIntersecting - setVisible(isIntersecting) - }, [isIntersecting]) - - const renderMessages = isVisible + const renderMessages = isIntersecting let content: React.ReactElement - const lastRenderMessages = React.useRef(false) - React.useEffect(() => { - if (!wRef) return - if (lastRenderMessages.current === renderMessages) return - if (renderMessages) { - const h = wRef.offsetHeight - if (h) { - setHeight(h) - } - } - lastRenderMessages.current = renderMessages - }, [renderMessages, wRef]) - if (renderMessages) { - content = + content = } else { + const height = measuredHeightRef.current content = } From 026651fadc91c52f20bd1ac130bafc771269dcfc Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 24 Apr 2026 18:14:24 -0400 Subject: [PATCH 14/27] WIP --- plans/use-effects-lint-todo.md | 2 +- shared/chat/conversation/list-area/index.desktop.tsx | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/plans/use-effects-lint-todo.md b/plans/use-effects-lint-todo.md index 258862b6eae9..b1653ce4f6e6 100644 --- a/plans/use-effects-lint-todo.md +++ b/plans/use-effects-lint-todo.md @@ -37,7 +37,7 @@ Scope: only `react-hooks/set-state-in-effect` findings. Ignore `refs`, `immutabi ## Batch 4: Chat Message Wrappers And Timers -- [x] `shared/chat/conversation/list-area/index.desktop.tsx:662:9` - preserved waypoint virtualization height with a measured-height ref instead of state set from an effect. +- [x] `shared/chat/conversation/list-area/index.desktop.tsx:662:9` - preserved waypoint virtualization height with a guarded ref-callback state update instead of state set from an effect. - [ ] `shared/chat/conversation/messages/wrapper/exploding-height-retainer/index.desktop.tsx:20:7` - [ ] `shared/chat/conversation/messages/wrapper/exploding-height-retainer/index.native.tsx:135:7` - [ ] `shared/chat/conversation/messages/wrapper/exploding-meta.tsx:82:7` diff --git a/shared/chat/conversation/list-area/index.desktop.tsx b/shared/chat/conversation/list-area/index.desktop.tsx index 77a492fb85eb..ac2e1a9e3cb6 100644 --- a/shared/chat/conversation/list-area/index.desktop.tsx +++ b/shared/chat/conversation/list-area/index.desktop.tsx @@ -615,13 +615,13 @@ if (colorWaypoints) { const OrdinalWaypoint = function OrdinalWaypoint(p: OrdinalWaypointProps) { const {ordinals, id, rowRenderer} = p const estimatedHeight = 40 * ordinals.length - const measuredHeightRef = React.useRef(-1) + const [height, setHeight] = React.useState(-1) const [wRef, setRef] = React.useState(null) const [setContentRef] = React.useState(() => (ref: HTMLDivElement | null) => { if (ref) { const height = ref.offsetHeight if (height) { - measuredHeightRef.current = height + setHeight(oldHeight => (oldHeight === height ? oldHeight : height)) } } setRef(ref) @@ -634,7 +634,6 @@ const OrdinalWaypoint = function OrdinalWaypoint(p: OrdinalWaypointProps) { if (renderMessages) { content = } else { - const height = measuredHeightRef.current content = } From c2991052a99063ee58b75308a6f5d2520b9b48ea Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 24 Apr 2026 18:21:50 -0400 Subject: [PATCH 15/27] WIP --- plans/use-effects-lint-todo.md | 8 +- .../conversation/input-area/normal/index.tsx | 9 +-- .../input-area/normal/input.native.tsx | 14 ++-- shared/chat/inbox/use-inbox-state.tsx | 76 ++++++++++++------- shared/chat/user-emoji.tsx | 43 +++++++---- 5 files changed, 93 insertions(+), 57 deletions(-) diff --git a/plans/use-effects-lint-todo.md b/plans/use-effects-lint-todo.md index b1653ce4f6e6..da51720d4cee 100644 --- a/plans/use-effects-lint-todo.md +++ b/plans/use-effects-lint-todo.md @@ -30,10 +30,10 @@ Scope: only `react-hooks/set-state-in-effect` findings. Ignore `refs`, `immutabi ## Batch 3: Chat Input And Inbox State -- [ ] `shared/chat/conversation/input-area/normal/index.tsx:264:5` -- [ ] `shared/chat/conversation/input-area/normal/input.native.tsx:233:5` -- [ ] `shared/chat/inbox/use-inbox-state.tsx:45:5` -- [ ] `shared/chat/user-emoji.tsx:34:7` +- [x] `shared/chat/conversation/input-area/normal/index.tsx:264:5` - derived exploding-mode seconds directly from conversation state instead of mirroring it in local state. +- [x] `shared/chat/conversation/input-area/normal/input.native.tsx:233:5` - replaced the emoji picker repeat guard state with an effect-local ref. +- [x] `shared/chat/inbox/use-inbox-state.tsx:45:5` - keyed small-row and expansion state by username so username changes render defaults without a reset effect. +- [x] `shared/chat/user-emoji.tsx:34:7` - tagged emoji request completion and derived loading instead of synchronously clearing loading in the effect. ## Batch 4: Chat Message Wrappers And Timers diff --git a/shared/chat/conversation/input-area/normal/index.tsx b/shared/chat/conversation/input-area/normal/index.tsx index 171da7ec3a7c..90e45fa52e8d 100644 --- a/shared/chat/conversation/input-area/normal/index.tsx +++ b/shared/chat/conversation/input-area/normal/index.tsx @@ -153,8 +153,7 @@ const ConnectedPlatformInput = function ConnectedPlatformInput() { const setEditing = ConvoState.useChatUIContext(s => s.dispatch.setEditing) const updateUnsentText = ConvoState.useChatUIContext(s => s.dispatch.injectIntoInput) - const [explodingModeSeconds, setExplodingModeSeconds] = React.useState(explodingModeSecondsRaw) - const isExploding = explodingModeSeconds !== 0 + const isExploding = explodingModeSecondsRaw !== 0 const hintText = useHintText({cannotWrite, isEditing, isExploding, minWriterRole}) const inputRef = React.useRef(null) @@ -260,10 +259,6 @@ const ConnectedPlatformInput = function ConnectedPlatformInput() { setInputRef(inputRef.current) }, [setInputRef]) - React.useEffect(() => { - setExplodingModeSeconds(explodingModeSecondsRaw) - }, [explodingModeSecondsRaw]) - return ( s.pickerMap.get(pickKey)?.emojiStr) ?? '' const updatePickerMap = usePickerState(s => s.dispatch.updatePickerMap) - const [lastEmoji, setLastEmoji] = React.useState('') + const lastEmojiRef = React.useRef('') React.useEffect(() => { - if (lastEmoji === emojiStr) { + if (lastEmojiRef.current === emojiStr) { return } - setLastEmoji(emojiStr) - emojiStr && insertText(emojiStr + ' ') - updatePickerMap(pickKey, undefined) - }, [emojiStr, insertText, lastEmoji, updatePickerMap]) + lastEmojiRef.current = emojiStr + if (emojiStr) { + insertText(emojiStr + ' ') + updatePickerMap(pickKey, undefined) + } + }, [emojiStr, insertText, updatePickerMap]) const navigateAppend = ConvoState.useChatNavigateAppend() const openEmojiPicker = () => { diff --git a/shared/chat/inbox/use-inbox-state.tsx b/shared/chat/inbox/use-inbox-state.tsx index 6c57e2248a22..65f8033a0ad5 100644 --- a/shared/chat/inbox/use-inbox-state.tsx +++ b/shared/chat/inbox/use-inbox-state.tsx @@ -35,26 +35,30 @@ export function useInboxState( inboxRetriedOnCurrentEmpty, setInboxRetriedOnCurrentEmpty, } = chatState - const [inboxNumSmallRows, setInboxNumSmallRowsState] = React.useState(5) - const [smallTeamsExpanded, setSmallTeamsExpanded] = React.useState(false) + const [inboxControls, setInboxControls] = React.useState(() => ({ + inboxNumSmallRows: 5, + inboxNumSmallRowsLoaded: false, + inboxNumSmallRowsUserChanged: false, + smallTeamsExpanded: false, + username, + })) + const controlsMatchUser = inboxControls.username === username + const inboxNumSmallRows = controlsMatchUser ? inboxControls.inboxNumSmallRows : 5 + const inboxNumSmallRowsLoaded = controlsMatchUser ? inboxControls.inboxNumSmallRowsLoaded : false + const smallTeamsExpanded = controlsMatchUser ? inboxControls.smallTeamsExpanded : false const inboxNumSmallRowsLoadVersionRef = React.useRef(0) - const inboxNumSmallRowsLoadedRef = React.useRef(false) - const inboxNumSmallRowsUserChangedRef = React.useRef(false) - - React.useEffect(() => { - setInboxNumSmallRowsState(5) - setSmallTeamsExpanded(false) - inboxNumSmallRowsLoadedRef.current = false - inboxNumSmallRowsUserChangedRef.current = false - }, [username]) const setInboxNumSmallRows = (rows: number, persist = true) => { if (rows <= 0) { return } - inboxNumSmallRowsLoadedRef.current = true - inboxNumSmallRowsUserChangedRef.current = true - setInboxNumSmallRowsState(rows) + setInboxControls(state => ({ + inboxNumSmallRows: rows, + inboxNumSmallRowsLoaded: true, + inboxNumSmallRowsUserChanged: true, + smallTeamsExpanded: state.username === username ? state.smallTeamsExpanded : false, + username, + })) if (!persist) { return } @@ -69,7 +73,14 @@ export function useInboxState( C.ignorePromise(f()) } const toggleSmallTeamsExpanded = () => { - setSmallTeamsExpanded(expanded => !expanded) + setInboxControls(state => ({ + inboxNumSmallRows: state.username === username ? state.inboxNumSmallRows : 5, + inboxNumSmallRowsLoaded: state.username === username ? state.inboxNumSmallRowsLoaded : false, + inboxNumSmallRowsUserChanged: + state.username === username ? state.inboxNumSmallRowsUserChanged : false, + smallTeamsExpanded: !(state.username === username ? state.smallTeamsExpanded : false), + username, + })) } const { @@ -133,7 +144,7 @@ export function useInboxState( if (!ready) { return } - if (inboxNumSmallRowsLoadedRef.current) { + if (inboxNumSmallRowsLoaded) { return } const loadVersion = inboxNumSmallRowsLoadVersionRef.current + 1 @@ -141,23 +152,36 @@ export function useInboxState( loadInboxNumSmallRows( [{path: 'ui.inboxSmallRows'}], rows => { - if ( - inboxNumSmallRowsLoadVersionRef.current !== loadVersion || - inboxNumSmallRowsUserChangedRef.current - ) { + if (inboxNumSmallRowsLoadVersionRef.current !== loadVersion) { return } - inboxNumSmallRowsLoadedRef.current = true const count = rows.i ?? -1 - if (count > 0) { - setInboxNumSmallRowsState(count) - } + setInboxControls(state => { + if (state.username === username && state.inboxNumSmallRowsUserChanged) { + return state + } + return { + inboxNumSmallRows: + count > 0 ? count : state.username === username ? state.inboxNumSmallRows : 5, + inboxNumSmallRowsLoaded: true, + inboxNumSmallRowsUserChanged: false, + smallTeamsExpanded: state.username === username ? state.smallTeamsExpanded : false, + username, + } + }) }, () => { if (inboxNumSmallRowsLoadVersionRef.current !== loadVersion) { return } - inboxNumSmallRowsLoadedRef.current = true + setInboxControls(state => ({ + inboxNumSmallRows: state.username === username ? state.inboxNumSmallRows : 5, + inboxNumSmallRowsLoaded: true, + inboxNumSmallRowsUserChanged: + state.username === username ? state.inboxNumSmallRowsUserChanged : false, + smallTeamsExpanded: state.username === username ? state.smallTeamsExpanded : false, + username, + })) } ) return () => { @@ -165,7 +189,7 @@ export function useInboxState( inboxNumSmallRowsLoadVersionRef.current++ } } - }, [loadInboxNumSmallRows, loggedIn, username]) + }, [inboxNumSmallRowsLoaded, loadInboxNumSmallRows, loggedIn, username]) React.useEffect(() => { const ready = loggedIn && !!username && (!C.isMobile || isFocused) diff --git a/shared/chat/user-emoji.tsx b/shared/chat/user-emoji.tsx index dadb2271d90c..67532c2a333b 100644 --- a/shared/chat/user-emoji.tsx +++ b/shared/chat/user-emoji.tsx @@ -13,6 +13,12 @@ const flattenUserEmojis = (groups: ReadonlyArray) => { return emojis } +type UserEmojiLoadState = { + completedKey: string + emojiGroups: ReadonlyArray + emojis: ReadonlyArray +} + export const useUserEmoji = ({ conversationIDKey, disabled, @@ -23,21 +29,25 @@ export const useUserEmoji = ({ onlyInTeam?: boolean }) => { const loadUserEmoji = C.useRPC(T.RPCChat.localUserEmojisRpcPromise) - const [emojiGroups, setEmojiGroups] = React.useState(emptyEmojiGroups) - const [emojis, setEmojis] = React.useState(emptyEmojis) - const [loading, setLoading] = React.useState(false) + const requestOnlyInTeam = onlyInTeam ?? false + const requestKey = `${conversationIDKey ?? T.Chat.noConversationIDKey}:${ + requestOnlyInTeam ? 'team' : 'all' + }` + const [loadState, setLoadState] = React.useState(() => ({ + completedKey: '', + emojiGroups: emptyEmojiGroups, + emojis: emptyEmojis, + })) const requestIDRef = React.useRef(0) React.useEffect(() => { if (disabled) { requestIDRef.current += 1 - setLoading(false) return } const requestID = requestIDRef.current + 1 requestIDRef.current = requestID - setLoading(true) loadUserEmoji( [ @@ -49,7 +59,7 @@ export const useUserEmoji = ({ opts: { getAliases: true, getCreationInfo: false, - onlyInTeam: onlyInTeam ?? false, + onlyInTeam: requestOnlyInTeam, }, }, ], @@ -58,15 +68,20 @@ export const useUserEmoji = ({ return } const nextGroups = results.emojis.emojis ?? emptyEmojiGroups - setEmojiGroups(nextGroups) - setEmojis(flattenUserEmojis(nextGroups)) - setLoading(false) + setLoadState({ + completedKey: requestKey, + emojiGroups: nextGroups, + emojis: flattenUserEmojis(nextGroups), + }) }, () => { if (requestIDRef.current !== requestID) { return } - setLoading(false) + setLoadState(state => ({ + ...state, + completedKey: requestKey, + })) } ) @@ -75,11 +90,11 @@ export const useUserEmoji = ({ requestIDRef.current += 1 } } - }, [conversationIDKey, disabled, loadUserEmoji, onlyInTeam]) + }, [conversationIDKey, disabled, loadUserEmoji, requestKey, requestOnlyInTeam]) return { - emojiGroups: disabled ? undefined : emojiGroups, - emojis, - loading: disabled ? false : loading, + emojiGroups: disabled ? undefined : loadState.emojiGroups, + emojis: loadState.emojis, + loading: !disabled && loadState.completedKey !== requestKey, } } From 65f0aa3897b5034e4b43f0144bc5cc96e0880db0 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 24 Apr 2026 18:31:10 -0400 Subject: [PATCH 16/27] WIP --- plans/use-effects-lint-todo.md | 10 +- .../index.desktop.tsx | 52 +++--- .../index.native.tsx | 74 ++++----- .../messages/wrapper/exploding-meta.tsx | 151 +++++++++--------- .../messages/wrapper/send-indicator.tsx | 89 ++++++----- 5 files changed, 199 insertions(+), 177 deletions(-) diff --git a/plans/use-effects-lint-todo.md b/plans/use-effects-lint-todo.md index da51720d4cee..a0224f669fd5 100644 --- a/plans/use-effects-lint-todo.md +++ b/plans/use-effects-lint-todo.md @@ -38,11 +38,11 @@ Scope: only `react-hooks/set-state-in-effect` findings. Ignore `refs`, `immutabi ## Batch 4: Chat Message Wrappers And Timers - [x] `shared/chat/conversation/list-area/index.desktop.tsx:662:9` - preserved waypoint virtualization height with a guarded ref-callback state update instead of state set from an effect. -- [ ] `shared/chat/conversation/messages/wrapper/exploding-height-retainer/index.desktop.tsx:20:7` -- [ ] `shared/chat/conversation/messages/wrapper/exploding-height-retainer/index.native.tsx:135:7` -- [ ] `shared/chat/conversation/messages/wrapper/exploding-meta.tsx:82:7` -- [ ] `shared/chat/conversation/messages/wrapper/exploding-meta.tsx:105:9` -- [ ] `shared/chat/conversation/messages/wrapper/send-indicator.tsx:86:7` +- [x] `shared/chat/conversation/messages/wrapper/exploding-height-retainer/index.desktop.tsx:20:7` - derived the active burn animation from retained-message state and left the effect only to finish the timer. +- [x] `shared/chat/conversation/messages/wrapper/exploding-height-retainer/index.native.tsx:135:7` - moved random emoji child creation into the animated listener path instead of a render-data effect. +- [x] `shared/chat/conversation/messages/wrapper/exploding-meta.tsx:82:7` - initialized countdown state from keyed message/pending inputs instead of starting it in an effect. +- [x] `shared/chat/conversation/messages/wrapper/exploding-meta.tsx:105:9` - keyed pending transitions so countdown state restarts without a pending-change reset effect. +- [x] `shared/chat/conversation/messages/wrapper/send-indicator.tsx:86:7` - derived status from props plus local timer state and kept effects for the encrypting/sent timers only. ## Batch 5: Chat Conversation Container And Search diff --git a/shared/chat/conversation/messages/wrapper/exploding-height-retainer/index.desktop.tsx b/shared/chat/conversation/messages/wrapper/exploding-height-retainer/index.desktop.tsx index 49fadb04ed38..577a42d94fa4 100644 --- a/shared/chat/conversation/messages/wrapper/exploding-height-retainer/index.desktop.tsx +++ b/shared/chat/conversation/messages/wrapper/exploding-height-retainer/index.desktop.tsx @@ -7,29 +7,45 @@ export const animationDuration = 2000 const ExplodingHeightRetainer = (p: Props) => { const {retainHeight, explodedBy, style, children, messageKey} = p - const boxRef = React.useRef(null) - const [animating, setAnimating] = React.useState(false) + const [animationState, setAnimationState] = React.useState(() => ({ + animationKey: undefined as string | undefined, + doneKey: retainHeight ? messageKey : undefined, + retainHeight, + })) const [height, setHeight] = React.useState(17) - const lastRetainHeight = React.useRef(retainHeight) + let currentAnimationState = animationState + if (animationState.retainHeight !== retainHeight) { + currentAnimationState = { + animationKey: retainHeight ? messageKey : undefined, + doneKey: retainHeight ? undefined : animationState.doneKey, + retainHeight, + } + setAnimationState(currentAnimationState) + } + const animating = + retainHeight && + currentAnimationState.animationKey === messageKey && + currentAnimationState.doneKey !== messageKey React.useEffect(() => { - if (lastRetainHeight.current === retainHeight) return - lastRetainHeight.current = retainHeight - if (retainHeight) { - setAnimating(true) - const timerID = setTimeout(() => setAnimating(false), animationDuration) - return () => { - clearTimeout(timerID) - } + if (!animating) { + return undefined } - return undefined - }, [retainHeight, messageKey]) + const timerID = setTimeout(() => { + setAnimationState(state => + state.animationKey === messageKey ? {...state, doneKey: messageKey} : state + ) + }, animationDuration) + return () => { + clearTimeout(timerID) + } + }, [animating, messageKey]) - React.useEffect(() => { - const m = boxRef.current?.getBoundingClientRect() - if (m) { - m.height && setHeight(m.height) + const setBoxRef = React.useCallback((ref: Kb.MeasureRef | null) => { + const measuredHeight = ref?.getBoundingClientRect().height + if (measuredHeight) { + setHeight(lastHeight => (lastHeight === measuredHeight ? lastHeight : measuredHeight)) } }, []) @@ -47,7 +63,7 @@ const ExplodingHeightRetainer = (p: Props) => { position: 'relative', }, ])} - ref={boxRef} + ref={setBoxRef} > {retainHeight ? null : children} diff --git a/shared/chat/conversation/messages/wrapper/exploding-height-retainer/index.native.tsx b/shared/chat/conversation/messages/wrapper/exploding-height-retainer/index.native.tsx index 0a4d9a9e9151..c0773cd4bf59 100644 --- a/shared/chat/conversation/messages/wrapper/exploding-height-retainer/index.native.tsx +++ b/shared/chat/conversation/messages/wrapper/exploding-height-retainer/index.native.tsx @@ -102,62 +102,58 @@ const AnimatedAshTower = (p: AshTowerProps) => { ) } +const makeEmojiTowerChildren = (numImages: number) => { + const children: Array = [] + for (let i = 0; i < numImages * 4; i++) { + const r = Math.random() + let emoji: string + if (Kb.Styles.isAndroid) { + emoji = r < 0.5 ? '💥' : '💣' + } else { + if (r < 0.33) { + emoji = '💥' + } else if (r < 0.66) { + emoji = '💣' + } else { + emoji = '🤯' + } + } + children.push( + + {emoji} + + ) + } + return children +} + const EmojiTower = (p: {numImages: number; animatedValue: NativeAnimated.Value}) => { const {numImages, animatedValue} = p - const [running, setRunning] = React.useState(false) - const [force, setForce] = React.useState(0) + const runningRef = React.useRef(false) + const [, setForce] = React.useState(0) + const [children, setChildren] = React.useState(null) const forceRender = C.useThrottledCallback(() => setForce(f => f + 1), 100) React.useEffect(() => { animatedValue.addListener((evt: {value: number}) => { if ([0, 100].includes(evt.value)) { - setRunning(false) + runningRef.current = false + setChildren(null) return } - if (!running) { - setRunning(true) + if (!runningRef.current) { + runningRef.current = true + setChildren(makeEmojiTowerChildren(numImages)) return } forceRender() }) return () => { + runningRef.current = false animatedValue.removeAllListeners() } - }, [animatedValue, running, forceRender]) - - force // just to trigger - - const [children, setChildren] = React.useState(null) - - React.useEffect(() => { - if (!running) { - setChildren(null) - return - } - const children: Array = [] - for (let i = 0; i < numImages * 4; i++) { - const r = Math.random() - let emoji: string - if (Kb.Styles.isAndroid) { - emoji = r < 0.5 ? '💥' : '💣' - } else { - if (r < 0.33) { - emoji = '💥' - } else if (r < 0.66) { - emoji = '💣' - } else { - emoji = '🤯' - } - } - children.push( - - {emoji} - - ) - } - setChildren(children) - }, [running, numImages]) + }, [animatedValue, forceRender, numImages]) return {children} } diff --git a/shared/chat/conversation/messages/wrapper/exploding-meta.tsx b/shared/chat/conversation/messages/wrapper/exploding-meta.tsx index 909bb3d95e34..9f85639ad012 100644 --- a/shared/chat/conversation/messages/wrapper/exploding-meta.tsx +++ b/shared/chat/conversation/messages/wrapper/exploding-meta.tsx @@ -17,26 +17,68 @@ export type OwnProps = { } function ExplodingMetaContainer(p: OwnProps) { - const {exploded, exploding, explodesAt, messageKey, onClick, submitState} = p - const [now, setNow] = React.useState(() => Date.now()) - const pending = submitState === 'pending' || submitState === 'failed' + const pending = isPendingSubmitState(p.submitState) + return ( + + ) +} + +type ExplodingMetaInnerProps = OwnProps & {pending: boolean} +type Mode = 'none' | 'countdown' | 'boom' | 'hidden' +type TimerState = { + exploded: boolean + inter: number + mode: Mode + now: number +} - const lastMessageKeyRef = React.useRef(messageKey) - const [mode, setMode] = React.useState('none') +const isPendingSubmitState = (submitState?: T.Chat.Message['submitState']) => + submitState === 'pending' || submitState === 'failed' - React.useEffect(() => { - if (messageKey !== lastMessageKeyRef.current) { - lastMessageKeyRef.current = messageKey - setMode('none') +const cappedLoopInterval = (difference: number) => Math.min(getLoopInterval(difference), 60000) + +const makeInitialTimerState = (p: { + exploded: boolean + explodesAt: number + pending: boolean +}): TimerState => { + const now = Date.now() + if (p.pending) { + return {exploded: p.exploded, inter: 0, mode: 'none', now} + } + const difference = p.explodesAt - now + if (difference <= 0 || p.exploded) { + return {exploded: p.exploded, inter: 0, mode: 'hidden', now} + } + return {exploded: p.exploded, inter: cappedLoopInterval(difference), mode: 'countdown', now} +} + +function ExplodingMetaInner(p: ExplodingMetaInnerProps) { + const {exploded, exploding, explodesAt, messageKey, onClick, pending} = p + const [timerState, setTimerState] = React.useState(() => + makeInitialTimerState({exploded, explodesAt, pending}) + ) + + let currentTimerState = timerState + if (timerState.exploded !== exploded) { + currentTimerState = { + ...timerState, + exploded, + inter: exploded && !timerState.exploded ? 0 : timerState.inter, + mode: exploded && !timerState.exploded ? 'boom' : timerState.mode, } - }, [messageKey]) + setTimerState(currentTimerState) + } + const {inter, mode, now} = currentTimerState const sharedTimerIDRef = React.useRef(0) const sharedTimerKeyRef = React.useRef('') const isParentHighlighted = useIsHighlighted() - const [inter, setInter] = React.useState(0) - React.useEffect(() => { if (!inter) return () => {} @@ -44,98 +86,57 @@ function ExplodingMetaContainer(p: OwnProps) { // switch to 'seconds' mode const id = addTicker(() => { const n = Date.now() - setNow(n) const difference = explodesAt - n - if (difference <= 0 || exploded) { - if (mode === 'countdown') { - setMode('boom') + setTimerState(state => { + if (difference <= 0 || exploded) { + return state.mode === 'countdown' ? {...state, mode: 'boom', now: n} : {...state, now: n} } - } + return {...state, now: n} + }) }) return () => { removeTicker(id) } } else { const id = setTimeout(() => { - setNow(Date.now()) + const n = Date.now() if (pending) { - setInter(0) + setTimerState(state => ({...state, inter: 0, now: n})) return } - const difference = explodesAt - Date.now() + const difference = explodesAt - n if (difference <= 0 || exploded) { - setMode('boom') - setInter(0) + setTimerState(state => ({...state, inter: 0, mode: 'boom', now: n})) return } // we don't need a timer longer than 60000 (android complains also) - setInter(Math.min(getLoopInterval(difference), 60000)) + setTimerState(state => ({...state, inter: cappedLoopInterval(difference), now: n})) }, inter) return () => { clearTimeout(id) } } - }, [inter, explodesAt, exploded, mode, pending]) - - React.useEffect(() => { - if (mode === 'none' && !pending && (Date.now() >= explodesAt || exploded)) { - setMode('hidden') - return - } - if (!pending) { - if (mode !== 'countdown') { - setMode('countdown') - // inline updateLoop logic for initial countdown start - setNow(Date.now()) - const difference = explodesAt - Date.now() - if (difference <= 0 || exploded) { - setMode('boom') - setInter(0) - } else { - setInter(Math.min(getLoopInterval(difference), 60000)) - } - } - } - }, [mode, pending, explodesAt, exploded]) + }, [inter, explodesAt, exploded, pending]) - const lastPendingRef = React.useRef(pending) React.useEffect(() => { - if (!pending && lastPendingRef.current) { - if (mode === 'none' && (Date.now() >= explodesAt || exploded)) { - setMode('hidden') - } else if (mode !== 'countdown') { - setMode('countdown') - setNow(Date.now()) - const difference = explodesAt - Date.now() - if (difference <= 0 || exploded) { - setMode('boom') - setInter(0) - } else { - setInter(Math.min(getLoopInterval(difference), 60000)) - } - } + if (!exploded || mode !== 'boom') { + return undefined } - lastPendingRef.current = pending - }, [pending, mode, explodesAt, exploded]) - - const lastExplodedRef = React.useRef(exploded) - React.useEffect(() => { - if (exploded && !lastExplodedRef.current) { - setMode('boom') - sharedTimerIDRef.current && SharedTimer.removeObserver(messageKey, sharedTimerIDRef.current) - sharedTimerKeyRef.current = messageKey - sharedTimerIDRef.current = SharedTimer.addObserver(() => setMode('hidden'), { + sharedTimerIDRef.current && SharedTimer.removeObserver(messageKey, sharedTimerIDRef.current) + sharedTimerKeyRef.current = messageKey + sharedTimerIDRef.current = SharedTimer.addObserver( + () => setTimerState(state => ({...state, mode: 'hidden'})), + { key: sharedTimerKeyRef.current, ms: animationDuration, - }) - } - lastExplodedRef.current = exploded + } + ) return () => { sharedTimerIDRef.current && SharedTimer.removeObserver(sharedTimerKeyRef.current, sharedTimerIDRef.current) } - }, [exploded, messageKey, setMode, sharedTimerIDRef, sharedTimerKeyRef]) + }, [exploded, messageKey, mode]) const backgroundColor = pending ? Kb.Styles.globalColors.black @@ -201,8 +202,6 @@ const oneMinuteInMs = 60 * 1000 const oneHourInMs = oneMinuteInMs * 60 const oneDayInMs = oneHourInMs * 24 -type Mode = 'none' | 'countdown' | 'boom' | 'hidden' - const getLoopInterval = (diff: number) => { let nearestUnit: number = 0 diff --git a/shared/chat/conversation/messages/wrapper/send-indicator.tsx b/shared/chat/conversation/messages/wrapper/send-indicator.tsx index ac464cd4692c..3264c1a59a19 100644 --- a/shared/chat/conversation/messages/wrapper/send-indicator.tsx +++ b/shared/chat/conversation/messages/wrapper/send-indicator.tsx @@ -53,55 +53,66 @@ type OwnProps = { } function SendIndicatorContainer(p: OwnProps) { - const {failed, id, isExploding, sent} = p + return +} - const [status, setStatus] = React.useState( - sent ? 'sent' : failed ? 'error' : !shownEncryptingSet.has(id) ? 'encrypting' : 'sending' - ) - const [visible, setVisible] = React.useState(!sent) - const timeoutRef = React.useRef | undefined>(undefined) +type IndicatorState = { + encrypting: boolean + failed: boolean + sent: boolean + sentHidden: boolean +} - React.useEffect(() => { - if (status === 'encrypting' && !timeoutRef.current) { - timeoutRef.current = setTimeout(() => { - setStatus('sending') - timeoutRef.current = undefined - }, 600) - } +function SendIndicator(p: OwnProps) { + const {failed, id, isExploding, sent} = p - if (status === 'encrypting') { - shownEncryptingSet.add(id) + const [indicatorState, setIndicatorState] = React.useState(() => ({ + encrypting: !sent && !failed && !shownEncryptingSet.has(id), + failed, + sent, + sentHidden: sent, + })) + + let currentIndicatorState = indicatorState + if (indicatorState.failed !== failed || indicatorState.sent !== sent) { + const hasTerminalState = indicatorState.failed || indicatorState.sent || failed || sent + currentIndicatorState = { + encrypting: hasTerminalState ? false : indicatorState.encrypting, + failed, + sent, + sentHidden: sent ? (indicatorState.sent === sent ? indicatorState.sentHidden : false) : false, } + setIndicatorState(currentIndicatorState) + } + const {encrypting, sentHidden} = currentIndicatorState + React.useEffect(() => { + if (!encrypting || failed || sent) { + return undefined + } + shownEncryptingSet.add(id) + const timeoutID = setTimeout(() => { + setIndicatorState(state => ({...state, encrypting: false})) + }, 600) return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current) - timeoutRef.current = undefined - } + clearTimeout(timeoutID) } - }, [status, id]) + }, [encrypting, failed, id, sent]) React.useEffect(() => { - if (failed) { - setStatus('error') - if (timeoutRef.current) { - clearTimeout(timeoutRef.current) - timeoutRef.current = undefined - } - } else if (sent) { - setStatus('sent') - if (timeoutRef.current) { - clearTimeout(timeoutRef.current) - } - timeoutRef.current = setTimeout(() => { - setVisible(false) - timeoutRef.current = undefined - }, 400) - } else { - setVisible(true) - setStatus('sending') + if (!sent || failed || sentHidden) { + return undefined } - }, [failed, sent]) + const timeoutID = setTimeout(() => { + setIndicatorState(state => (state.sent ? {...state, sentHidden: true} : state)) + }, 400) + return () => { + clearTimeout(timeoutID) + } + }, [failed, sent, sentHidden]) + + const visible = failed || !sent || !sentHidden + const status: AnimationStatus = failed ? 'error' : sent ? 'sent' : encrypting ? 'encrypting' : 'sending' const isDarkMode = useColorScheme() === 'dark' From 2ff83f4f73adb0bd49cdc817d87238948a094ee3 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Fri, 24 Apr 2026 20:08:22 -0400 Subject: [PATCH 17/27] WIP --- plans/use-effects-lint-todo.md | 12 +- shared/chat/conversation/normal/container.tsx | 91 +++++++++------ shared/chat/conversation/search.tsx | 72 +++++++----- shared/chat/conversation/team-hooks.tsx | 106 +++++++++++++----- 4 files changed, 185 insertions(+), 96 deletions(-) diff --git a/plans/use-effects-lint-todo.md b/plans/use-effects-lint-todo.md index a0224f669fd5..3cdf589fda75 100644 --- a/plans/use-effects-lint-todo.md +++ b/plans/use-effects-lint-todo.md @@ -46,12 +46,12 @@ Scope: only `react-hooks/set-state-in-effect` findings. Ignore `refs`, `immutabi ## Batch 5: Chat Conversation Container And Search -- [ ] `shared/chat/conversation/normal/container.tsx:40:5` -- [ ] `shared/chat/conversation/normal/container.tsx:79:9` -- [ ] `shared/chat/conversation/search.tsx:254:5` -- [ ] `shared/chat/conversation/search.tsx:264:5` -- [ ] `shared/chat/conversation/team-hooks.tsx:290:10` -- [ ] `shared/chat/conversation/team-hooks.tsx:373:10` +- [x] `shared/chat/conversation/normal/container.tsx:40:5` - keyed orange-line state by conversation and let the load effect fetch without synchronously resetting state. +- [x] `shared/chat/conversation/normal/container.tsx:79:9` - folded mobile app-state clearing into the guarded orange-line state adjustment during render. +- [x] `shared/chat/conversation/search.tsx:254:5` - keyed thread-search internals by conversation/query instead of clearing search state in an effect. +- [x] `shared/chat/conversation/search.tsx:264:5` - seeded initial-query state on mount and started the request without syncing input state from an effect. +- [x] `shared/chat/conversation/team-hooks.tsx:290:10` - split team-member effect loading from manual reload loading state and derived initial loading for stale team IDs. +- [x] `shared/chat/conversation/team-hooks.tsx:373:10` - split team-name effect loading from manual reload loading state and keyed visible results by the requested team IDs. ## Batch 6: Common Adapter Components diff --git a/shared/chat/conversation/normal/container.tsx b/shared/chat/conversation/normal/container.tsx index 21a3a01bb650..093d381db3ed 100644 --- a/shared/chat/conversation/normal/container.tsx +++ b/shared/chat/conversation/normal/container.tsx @@ -9,27 +9,59 @@ import {FocusProvider, ScrollProvider} from './context' import {OrangeLineContext} from '../orange-line-context' import {ChatTeamProvider} from '../team-hooks' +type OrangeLineState = { + conversationIDKey: T.Chat.ConversationIDKey + mobileAppState: 'active' | 'background' | 'inactive' | 'unknown' + orangeLine: T.Chat.Ordinal +} + const useOrangeLine = () => { - const [orangeLine, setOrangeLine] = React.useState(T.Chat.numberToOrdinal(0)) const id = ConvoState.useChatContext(s => s.id) + const active = useShellState(s => s.active) + const mobileAppState = useShellState(s => s.mobileAppState) + const noOrangeLine = T.Chat.numberToOrdinal(0) + const [orangeLineState, setOrangeLineState] = React.useState(() => ({ + conversationIDKey: id, + mobileAppState, + orangeLine: noOrangeLine, + })) + let currentOrangeLineState = orangeLineState + if (orangeLineState.conversationIDKey !== id || orangeLineState.mobileAppState !== mobileAppState) { + currentOrangeLineState = { + conversationIDKey: id, + mobileAppState, + orangeLine: + orangeLineState.conversationIDKey === id && mobileAppState === 'active' + ? orangeLineState.orangeLine + : noOrangeLine, + } + setOrangeLineState(currentOrangeLineState) + } // Snapshot readMsgID during render (synchronous, before any effects like markThreadAsRead) // This ensures we capture the read position before the Go service processes mark-as-read const savedReadMsgID = React.useMemo(() => ConvoState.getConvoState(id).meta.readMsgID, [id]) - const loadOrangeLine = React.useEffectEvent((useSavedReadMsgID?: boolean) => { - const f = async () => { - const store = ConvoState.getConvoState(id) - const convID = store.getConvID() - const readMsgID = useSavedReadMsgID ? savedReadMsgID : store.meta.readMsgID - const unreadlineRes = await T.RPCChat.localGetUnreadlineRpcPromise({ - convID, - identifyBehavior: T.RPCGen.TLFIdentifyBehavior.chatGui, - readMsgID: readMsgID < 0 ? 0 : readMsgID, - }) - setOrangeLine(T.Chat.numberToOrdinal(unreadlineRes.unreadlineID ? unreadlineRes.unreadlineID : 0)) + const loadOrangeLine = React.useEffectEvent( + (conversationIDKey: T.Chat.ConversationIDKey, savedReadMsgID?: T.Chat.MessageID) => { + const f = async () => { + const store = ConvoState.getConvoState(conversationIDKey) + const convID = store.getConvID() + const readMsgID = savedReadMsgID ?? store.meta.readMsgID + const unreadlineRes = await T.RPCChat.localGetUnreadlineRpcPromise({ + convID, + identifyBehavior: T.RPCGen.TLFIdentifyBehavior.chatGui, + readMsgID: readMsgID < 0 ? 0 : readMsgID, + }) + const nextOrangeLine = T.Chat.numberToOrdinal( + unreadlineRes.unreadlineID ? unreadlineRes.unreadlineID : 0 + ) + setOrangeLineState(state => + state.conversationIDKey === conversationIDKey ? {...state, orangeLine: nextOrangeLine} : state + ) + } + C.ignorePromise(f()) } - C.ignorePromise(f()) - }) + ) const loaded = ConvoState.useChatContext(s => s.loaded) @@ -37,11 +69,10 @@ const useOrangeLine = () => { // Wait for loaded so the Go service has messages in its local cache // On desktop the component doesn't remount on conversation switch, so we depend on id React.useEffect(() => { - setOrangeLine(T.Chat.numberToOrdinal(0)) if (loaded) { - loadOrangeLine(true) + loadOrangeLine(id, savedReadMsgID) } - }, [id, loaded]) + }, [id, loaded, savedReadMsgID]) const {markedAsUnread, maxVisibleMsgID} = ConvoState.useChatContext( C.useShallow(s => { @@ -56,31 +87,23 @@ const useOrangeLine = () => { React.useEffect(() => { if (lastMarkedAsUnreadRef.current !== markedAsUnread) { lastMarkedAsUnreadRef.current = markedAsUnread - setOrangeLine(T.Chat.numberToOrdinal(markedAsUnread)) + setOrangeLineState(state => + state.conversationIDKey === id + ? {...state, orangeLine: T.Chat.numberToOrdinal(markedAsUnread)} + : state + ) } - }, [markedAsUnread]) + }, [id, markedAsUnread]) // just use the rpc for orange line if we're not active // if we are active we want to keep whatever state we had so it is maintained - const active = useShellState(s => s.active) React.useEffect(() => { if (!active) { - loadOrangeLine() + loadOrangeLine(id) } - }, [maxVisibleMsgID, active]) + }, [maxVisibleMsgID, active, id]) - // mobile backgrounded us - const mobileAppState = useShellState(s => s.mobileAppState) - const lastMobileAppStateRef = React.useRef(mobileAppState) - React.useEffect(() => { - if (mobileAppState !== lastMobileAppStateRef.current) { - lastMobileAppStateRef.current = mobileAppState - if (mobileAppState !== 'active') { - setOrangeLine(T.Chat.numberToOrdinal(0)) - } - } - }, [mobileAppState]) - return orangeLine + return currentOrangeLineState.orangeLine } const useShowManageChannels = () => { diff --git a/shared/chat/conversation/search.tsx b/shared/chat/conversation/search.tsx index 2df5fffce601..4f1a3c6ddb9e 100644 --- a/shared/chat/conversation/search.tsx +++ b/shared/chat/conversation/search.tsx @@ -11,18 +11,20 @@ import {useCurrentUserState} from '@/stores/current-user' import {useThreadSearchRoute} from './thread-search-route' type OwnProps = {style?: Styles.StylesCrossPlatform} +type CommonProps = OwnProps & { + conversationIDKey: T.Chat.ConversationIDKey + initialQuery: string +} type SearchState = { hits: Array status: T.Chat.ThreadSearchInfo['status'] } -const useCommon = (ownProps: OwnProps) => { - const {style} = ownProps - const initialQuery = useThreadSearchRoute()?.query ?? '' - const {conversationIDKey, loadMessagesCentered, toggleThreadSearch} = ConvoState.useChatContext( +const useCommon = (ownProps: CommonProps) => { + const {conversationIDKey, initialQuery, style} = ownProps + const {loadMessagesCentered, toggleThreadSearch} = ConvoState.useChatContext( C.useShallow(s => ({ - conversationIDKey: s.id, loadMessagesCentered: s.dispatch.loadMessagesCentered, toggleThreadSearch: s.dispatch.toggleThreadSearch, })) @@ -31,7 +33,10 @@ const useCommon = (ownProps: OwnProps) => { toggleThreadSearch() } - const [searchState, setSearchState] = React.useState({hits: [], status: 'initial'}) + const [searchState, setSearchState] = React.useState(() => ({ + hits: [], + status: initialQuery ? 'inprogress' : 'initial', + })) const {hits: messageHits, status} = searchState const numHits = messageHits.length const hits = messageHits.map(h => ({ @@ -40,8 +45,8 @@ const useCommon = (ownProps: OwnProps) => { timestamp: h.timestamp, })) const [selectedIndex, setSelectedIndex] = React.useState(0) - const [text, setText] = React.useState('') - const [lastSearch, setLastSearch] = React.useState('') + const [text, setText] = React.useState(initialQuery) + const [lastSearch, setLastSearch] = React.useState(initialQuery) const searchOrdinalRef = React.useRef(0) const hitsRef = React.useRef(messageHits) @@ -61,11 +66,7 @@ const useCommon = (ownProps: OwnProps) => { pendingReplaceHitsRef.current = undefined }) - const runThreadSearch = React.useEffectEvent((query: string) => { - const requestOrdinal = searchOrdinalRef.current + 1 - searchOrdinalRef.current = requestOrdinal - clearPendingFlush() - setSearchState({hits: [], status: query ? 'inprogress' : 'done'}) + const startThreadSearchRequest = React.useEffectEvent((query: string, requestOrdinal: number) => { if (!query) { return } @@ -197,6 +198,14 @@ const useCommon = (ownProps: OwnProps) => { C.ignorePromise(f()) }) + const runThreadSearch = (query: string) => { + const requestOrdinal = searchOrdinalRef.current + 1 + searchOrdinalRef.current = requestOrdinal + clearPendingFlush() + setSearchState({hits: [], status: query ? 'inprogress' : 'done'}) + startThreadSearchRequest(query, requestOrdinal) + } + const submitSearch = () => { setLastSearch(text) setSelectedIndex(0) @@ -248,24 +257,15 @@ const useCommon = (ownProps: OwnProps) => { const inProgress = status === 'inprogress' const hasResults = status === 'done' || numHits > 0 - React.useEffect(() => { - searchOrdinalRef.current += 1 - clearPendingFlush() - setSearchState({hits: [], status: 'initial'}) - setLastSearch('') - setSelectedIndex(0) - setText('') - }, [conversationIDKey]) - React.useEffect(() => { if (!initialQuery) { return } - setText(initialQuery) - setLastSearch(initialQuery) - setSelectedIndex(0) - runThreadSearch(initialQuery) - }, [conversationIDKey, initialQuery]) + const requestOrdinal = searchOrdinalRef.current + 1 + searchOrdinalRef.current = requestOrdinal + clearPendingFlush() + startThreadSearchRequest(initialQuery, requestOrdinal) + }, [initialQuery]) React.useEffect(() => { return () => { @@ -314,7 +314,20 @@ type SearchHit = { timestamp: number } +const useThreadSearchCommonProps = (p: OwnProps): CommonProps => { + const conversationIDKey = ConvoState.useChatContext(s => s.id) + const initialQuery = useThreadSearchRoute()?.query ?? '' + return {...p, conversationIDKey, initialQuery} +} + +const threadSearchKey = (p: CommonProps) => `${p.conversationIDKey}:${p.initialQuery}` + const ThreadSearchDesktop = function ThreadSearchDesktop(p: OwnProps) { + const commonProps = useThreadSearchCommonProps(p) + return +} + +const ThreadSearchDesktopInner = function ThreadSearchDesktopInner(p: CommonProps) { const props = useCommon(p) const {conversationIDKey, submitSearch, hits, selectResult, onEnter} = props const {onUp, onDown, onChangedText, inProgress, hasResults} = props @@ -436,6 +449,11 @@ const ThreadSearchDesktop = function ThreadSearchDesktop(p: OwnProps) { } const ThreadSearchMobile = function ThreadSearchMobile(p: OwnProps) { + const commonProps = useThreadSearchCommonProps(p) + return +} + +const ThreadSearchMobileInner = function ThreadSearchMobileInner(p: CommonProps) { const props = useCommon(p) const {numHits, onEnter, onUp, onDown, onChangedText, onToggleThreadSearch} = props const {inProgress, hasResults, selectedIndex, text, style, status} = props diff --git a/shared/chat/conversation/team-hooks.tsx b/shared/chat/conversation/team-hooks.tsx index 1fabc9519e4a..188964b7f5fb 100644 --- a/shared/chat/conversation/team-hooks.tsx +++ b/shared/chat/conversation/team-hooks.tsx @@ -29,6 +29,10 @@ type ChatTeamNamesState = { teamnames: ReadonlyMap } +type ChatTeamNamesStateInternal = ChatTeamNamesState & { + loadedTeamIDsKey?: string +} + type ChatTeamStateInternal = ChatTeamState & { loadedTeamID?: T.Teams.TeamID } @@ -83,7 +87,8 @@ const makeEmptyChatTeamMembersState = (): ChatTeamMembersStateInternal => ({ members: new Map(), }) -const makeEmptyChatTeamNamesState = (): ChatTeamNamesState => ({ +const makeEmptyChatTeamNamesState = (): ChatTeamNamesStateInternal => ({ + loadedTeamIDsKey: undefined, loading: false, teamnames: new Map(), }) @@ -241,23 +246,26 @@ const useChatTeamRaw = (teamID: T.Teams.TeamID, teamname?: string, enabled = tru const useChatTeamMembersRaw = (teamID: T.Teams.TeamID, enabled = true): ChatTeamMembers => { const validTeamID = loadableTeamID(teamID) - const [state, setState] = React.useState(() => ({ - ...makeEmptyChatTeamMembersState(), - loadedTeamID: validTeamID, - })) + const [state, setState] = React.useState(() => + makeEmptyChatTeamMembersState() + ) const requestVersionRef = React.useRef(0) const clearState = React.useCallback(() => { requestVersionRef.current++ setState({...makeEmptyChatTeamMembersState(), loadedTeamID: validTeamID}) }, [validTeamID]) + if ( + (!enabled || !validTeamID) && + (state.loadedTeamID !== undefined || state.loading || state.members.size) + ) { + setState(makeEmptyChatTeamMembersState()) + } - const reload = React.useCallback(async () => { + const loadMembers = React.useCallback(async () => { if (!enabled || !validTeamID) { - clearState() return } const requestVersion = ++requestVersionRef.current - setState(prev => ({...prev, loading: true})) try { const members = Teams.rpcDetailsToMemberInfos( (await T.RPCGen.teamsTeamGetMembersByIDRpcPromise({id: validTeamID})) ?? [] @@ -277,22 +285,33 @@ const useChatTeamMembersRaw = (teamID: T.Teams.TeamID, enabled = true): ChatTeam return } logger.warn(`Failed to reload chat team members for ${validTeamID}`, error) - setState(prev => ({...prev, loading: false})) + setState(prev => ({...prev, loadedTeamID: validTeamID, loading: false})) } - }, [clearState, enabled, validTeamID]) + }, [enabled, validTeamID]) + + const reload = React.useCallback(async () => { + if (!enabled || !validTeamID) { + clearState() + return + } + setState(prev => ({...prev, loadedTeamID: validTeamID, loading: true})) + await loadMembers() + }, [clearState, enabled, loadMembers, validTeamID]) const visibleState = - enabled && state.loadedTeamID !== validTeamID + !enabled || !validTeamID ? {...makeEmptyChatTeamMembersState(), loadedTeamID: validTeamID} - : state + : state.loadedTeamID !== validTeamID + ? {...makeEmptyChatTeamMembersState(), loadedTeamID: validTeamID, loading: true} + : state React.useEffect(() => { - void reload() - }, [reload]) + void loadMembers() + }, [loadMembers]) C.Router2.useSafeFocusEffect( React.useCallback(() => { - void reload() - }, [reload]) + void loadMembers() + }, [loadMembers]) ) useEngineActionListener('keybase.1.NotifyTeam.teamChangedByID', action => { if (enabled && action.payload.params.teamID === validTeamID) { @@ -321,20 +340,24 @@ const useChatTeamNamesRaw = (teamIDs: ReadonlyArray, enabled = t .sort() .join(',') const validTeamIDs = React.useMemo(() => parseTeamIDsKey(teamIDsKey), [teamIDsKey]) - const [state, setState] = React.useState(() => makeEmptyChatTeamNamesState()) + const [state, setState] = React.useState(() => makeEmptyChatTeamNamesState()) const requestVersionRef = React.useRef(0) const clearState = React.useCallback(() => { requestVersionRef.current++ setState(makeEmptyChatTeamNamesState()) }, []) + if ( + (!enabled || !teamIDsKey || !username) && + (state.loadedTeamIDsKey || state.loading || state.teamnames.size) + ) { + setState(makeEmptyChatTeamNamesState()) + } - const reload = React.useCallback(async () => { + const loadTeamNames = React.useCallback(async () => { if (!enabled || !teamIDsKey || !username) { - clearState() return } const requestVersion = ++requestVersionRef.current - setState(prev => ({loading: true, teamnames: new Map(prev.teamnames)})) try { const resolvedTeamnames = await Promise.all( validTeamIDs.map(async teamID => { @@ -357,6 +380,7 @@ const useChatTeamNamesRaw = (teamIDs: ReadonlyArray, enabled = t } }) setState({ + loadedTeamIDsKey: teamIDsKey, loading: false, teamnames, }) @@ -365,17 +389,41 @@ const useChatTeamNamesRaw = (teamIDs: ReadonlyArray, enabled = t return } logger.warn(`Failed to load chat team names for ${teamIDsKey}`, error) - setState(prev => ({loading: false, teamnames: new Map(prev.teamnames)})) + setState(prev => ({ + loadedTeamIDsKey: teamIDsKey, + loading: false, + teamnames: prev.loadedTeamIDsKey === teamIDsKey ? new Map(prev.teamnames) : new Map(), + })) + } + }, [enabled, teamIDsKey, username, validTeamIDs]) + + const reload = React.useCallback(async () => { + if (!enabled || !teamIDsKey || !username) { + clearState() + return } - }, [clearState, enabled, teamIDsKey, username, validTeamIDs]) + setState(prev => ({ + loadedTeamIDsKey: teamIDsKey, + loading: true, + teamnames: prev.loadedTeamIDsKey === teamIDsKey ? new Map(prev.teamnames) : new Map(), + })) + await loadTeamNames() + }, [clearState, enabled, loadTeamNames, teamIDsKey, username]) + + const visibleState = + !enabled || !teamIDsKey || !username + ? makeEmptyChatTeamNamesState() + : state.loadedTeamIDsKey !== teamIDsKey + ? {...makeEmptyChatTeamNamesState(), loadedTeamIDsKey: teamIDsKey, loading: true} + : state React.useEffect(() => { - void reload() - }, [reload]) + void loadTeamNames() + }, [loadTeamNames]) C.Router2.useSafeFocusEffect( React.useCallback(() => { - void reload() - }, [reload]) + void loadTeamNames() + }, [loadTeamNames]) ) useEngineActionListener('keybase.1.NotifyTeam.teamMetadataUpdate', () => { if (enabled && teamIDsKey) { @@ -393,7 +441,7 @@ const useChatTeamNamesRaw = (teamIDs: ReadonlyArray, enabled = t setState(prev => { const teamnames = new Map(prev.teamnames) teamnames.delete(action.payload.params.teamID) - return {loading: false, teamnames} + return {loadedTeamIDsKey: prev.loadedTeamIDsKey, loading: false, teamnames} }) } }) @@ -403,12 +451,12 @@ const useChatTeamNamesRaw = (teamIDs: ReadonlyArray, enabled = t setState(prev => { const teamnames = new Map(prev.teamnames) teamnames.delete(action.payload.params.teamID) - return {loading: false, teamnames} + return {loadedTeamIDsKey: prev.loadedTeamIDsKey, loading: false, teamnames} }) } }) - return {...state, reload} + return {...visibleState, reload} } type ChatTeamContextValue = { From e5674855dd6b21a9a25443cbd27e199860820b27 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Sat, 25 Apr 2026 09:10:23 -0400 Subject: [PATCH 18/27] WIP --- plans/use-effects-lint-todo.md | 18 ++-- shared/common-adapters/choice-list.native.tsx | 13 ++- shared/common-adapters/copy-text.tsx | 64 +++++------ shared/common-adapters/phone-input.tsx | 26 ++--- .../popup/floating-box/index.desktop.tsx | 24 +---- .../relative-floating-box.desktop.tsx | 64 +++++++---- shared/common-adapters/save-indicator.tsx | 42 +++++--- shared/common-adapters/toast.native.tsx | 102 ++++++++++-------- .../zoomable-image.desktop.tsx | 27 ++--- shared/wallets/index.tsx | 3 +- 10 files changed, 197 insertions(+), 186 deletions(-) diff --git a/plans/use-effects-lint-todo.md b/plans/use-effects-lint-todo.md index 3cdf589fda75..b880ce3d151b 100644 --- a/plans/use-effects-lint-todo.md +++ b/plans/use-effects-lint-todo.md @@ -55,15 +55,15 @@ Scope: only `react-hooks/set-state-in-effect` findings. Ignore `refs`, `immutabi ## Batch 6: Common Adapter Components -- [ ] `shared/common-adapters/choice-list.native.tsx:16:5` -- [ ] `shared/common-adapters/copy-text.tsx:97:11` -- [ ] `shared/common-adapters/phone-input.tsx:198:7` -- [ ] `shared/common-adapters/phone-input.tsx:388:7` -- [ ] `shared/common-adapters/popup/floating-box/index.desktop.tsx:18:5` -- [ ] `shared/common-adapters/popup/floating-box/relative-floating-box.desktop.tsx:284:7` -- [ ] `shared/common-adapters/save-indicator.tsx:35:9` -- [ ] `shared/common-adapters/toast.native.tsx:33:7` -- [ ] `shared/common-adapters/zoomable-image.desktop.tsx:46:7` +- [x] `shared/common-adapters/choice-list.native.tsx:16:5` - keyed the active press state by the current options array so option changes render with no active item without an effect reset. +- [x] `shared/common-adapters/copy-text.tsx:97:11` - moved copy-after-load into the copy request callback path and kept toast hiding as a timer effect. +- [x] `shared/common-adapters/phone-input.tsx:198:7` - derived the country picker selected value from the latest selected prop plus local picker edits instead of syncing it in an effect. +- [x] `shared/common-adapters/phone-input.tsx:388:7` - converted the late default-country initialization to a guarded render state adjustment. +- [x] `shared/common-adapters/popup/floating-box/index.desktop.tsx:18:5` - moved anchor measurement out of an effect and into the floating-box commit ref path. +- [x] `shared/common-adapters/popup/floating-box/relative-floating-box.desktop.tsx:284:7` - derived popup style from measured popup/anchor rects collected by the popup ref callback. +- [x] `shared/common-adapters/save-indicator.tsx:35:9` - moved saving-state transitions into a guarded render update and left only the saved-state timeout effect. +- [x] `shared/common-adapters/toast.native.tsx:33:7` - derived render visibility from the visible prop plus delayed hide state and kept animation/timer effects free of synchronous show updates. +- [x] `shared/common-adapters/zoomable-image.desktop.tsx:46:7` - set zoom toast visibility from the zoom click path and left the effect only to expire the toast timer. ## Batch 7: Desktop And Remote Surfaces diff --git a/shared/common-adapters/choice-list.native.tsx b/shared/common-adapters/choice-list.native.tsx index 49e14c99d29e..dde6b1453e89 100644 --- a/shared/common-adapters/choice-list.native.tsx +++ b/shared/common-adapters/choice-list.native.tsx @@ -9,12 +9,11 @@ import type {Props} from './choice-list' const Kb = {Box2, ClickableBox, IconAuto, Text} const ChoiceList = (props: Props) => { - const [activeIndex, setActiveIndex] = React.useState(undefined) - const {options} = props - React.useEffect(() => { - setActiveIndex(undefined) - }, [options]) + const [active, setActive] = React.useState<{index?: number; options: Props['options']}>(() => ({ + options, + })) + const activeIndex = active.options === options ? active.index : undefined return ( @@ -25,8 +24,8 @@ const ChoiceList = (props: Props) => { key={idx} underlayColor={Styles.globalColors.blueLighter2} onClick={op.onClick} - onPressIn={() => setActiveIndex(idx)} - onPressOut={() => setActiveIndex(undefined)} + onPressIn={() => setActive({index: idx, options})} + onPressOut={() => setActive({options})} > diff --git a/shared/common-adapters/copy-text.tsx b/shared/common-adapters/copy-text.tsx index 08abd9e0af53..15543ccbd9ae 100644 --- a/shared/common-adapters/copy-text.tsx +++ b/shared/common-adapters/copy-text.tsx @@ -5,7 +5,6 @@ import Button, {type ButtonProps} from './button' import Text from './text' import type {LineClampType, TextType} from './text.shared' import Toast from './toast' -import {useTimeout} from './use-timers' import * as Styles from '@/styles' import logger from '@/logger' import type {MeasureRef} from './measure-ref' @@ -32,22 +31,33 @@ type Props = { textType?: TextType placeholderText?: string shareSheet?: boolean // (mobile only) show share sheet instead of copying - loadText?: () => void + loadText?: (onLoaded?: (text: string) => void) => void } const CopyText = (props: Props) => { const {withReveal, text, loadText, onCopy, hideOnCopy} = props const [revealed, setRevealed] = React.useState(!props.withReveal) const [showingToast, setShowingToast] = React.useState(false) - const [requestedCopy, setRequestedCopy] = React.useState(false) const shareSheet = props.shareSheet && Styles.isMobile - const setShowingToastFalseLater = useTimeout(() => setShowingToast(false), 1500) - const [lastShowingToast, setLastShowingToast] = React.useState(showingToast) + const copyRequestIDRef = React.useRef(0) - if (lastShowingToast !== showingToast) { - setLastShowingToast(showingToast) - showingToast && setShowingToastFalseLater() - } + React.useEffect(() => { + return () => { + copyRequestIDRef.current += 1 + } + }, []) + + React.useEffect(() => { + if (!showingToast) { + return undefined + } + const id = setTimeout(() => { + setShowingToast(false) + }, 1500) + return () => { + clearTimeout(id) + } + }, [showingToast]) React.useEffect(() => { if (!withReveal && !text) { @@ -80,32 +90,19 @@ const CopyText = (props: Props) => { logger.warn('no text to copy and no loadText method provided') return } - setRequestedCopy(true) + const requestID = copyRequestIDRef.current + 1 + copyRequestIDRef.current = requestID + loadText(loadedText => { + if (copyRequestIDRef.current === requestID && loadedText) { + copyRequestIDRef.current = requestID + 1 + doCopy(loadedText) + } + }) } else { doCopy(text) } } - React.useEffect(() => { - if (requestedCopy && loadText) { - if (!text) { - loadText() - } else { - if (shareSheet) { - showShareActionSheet('', text, 'text/plain') - } else { - setShowingToast(true) - copyToClipboard(text) - } - onCopy?.() - if (hideOnCopy) { - setRevealed(false) - } - setRequestedCopy(false) - } - } - }, [requestedCopy, text, loadText, shareSheet, onCopy, hideOnCopy]) - const reveal = () => { if (!props.text && props.loadText) { // if we don't have text to copy we should load it @@ -146,7 +143,6 @@ const CopyText = (props: Props) => { selectable={true} center={true} style={Styles.collapseStyles([styles.text, props.disabled && styles.textDisabled])} - > {isRevealed && (props.text || props.placeholderText) ? props.text || props.placeholderText @@ -158,11 +154,7 @@ const CopyText = (props: Props) => {
)} {!props.disabled && ( - + }) { const {onHidden, onSelect, selected: _selected, visible, attachTo, ref} = p const [filter, setFilter] = React.useState('') - const [selected, setSelected] = React.useState(_selected) + const [selectedState, setSelectedState] = React.useState<{ + selected?: string + sourceSelected?: string + }>(() => ({selected: _selected, sourceSelected: _selected})) + const selected = selectedState.sourceSelected === _selected ? selectedState.selected : _selected const onSelectMenu = p.onSelect @@ -182,7 +186,7 @@ function CountrySelector(p: CountrySelectorProps & {ref?: React.Ref { - setSelected(p.selected) + setSelectedState({selected: p.selected, sourceSelected: p.selected}) onHidden() } @@ -194,10 +198,6 @@ function CountrySelector(p: CountrySelectorProps & {ref?: React.Ref { - setSelected(_selected) - }, [_selected]) - const desktopItems = menuItems(countryData(), filter, onSelectMenu) const mobileItems = pickerItems(countryData()) @@ -240,7 +240,7 @@ function CountrySelector(p: CountrySelectorProps & {ref?: React.Ref setSelectedState({selected, sourceSelected: _selected})} onHidden={onCancel} onCancel={onCancel} onDone={onDone} @@ -377,19 +377,15 @@ const PhoneInput = (p: Props) => { } }, [formatted, onChangeNumber, country]) - const lastDefaultCountryRef = React.useRef(defaultCountry) - - React.useEffect(() => { - if (lastDefaultCountryRef.current) { - return - } - lastDefaultCountryRef.current = defaultCountry + const [lastDefaultCountry, setLastDefaultCountry] = React.useState(defaultCountry) + if (!lastDefaultCountry && defaultCountry) { + setLastDefaultCountry(defaultCountry) if (!country && defaultCountry) { setCountry(defaultCountry) setFormatter(new AsYouTypeFormatter(defaultCountry)) setPrefix(getCallingCode(defaultCountry).slice(1)) } - }, [country, defaultCountry]) + } const isSmall = small ?? !Styles.isMobile diff --git a/shared/common-adapters/popup/floating-box/index.desktop.tsx b/shared/common-adapters/popup/floating-box/index.desktop.tsx index a54ce2747524..773e068c0121 100644 --- a/shared/common-adapters/popup/floating-box/index.desktop.tsx +++ b/shared/common-adapters/popup/floating-box/index.desktop.tsx @@ -1,6 +1,5 @@ import * as React from 'react' import type {Props} from '.' -import shallowEqual from '@/util/shallow-equal' import {RelativeFloatingBox} from './relative-floating-box.desktop' import noop from 'lodash/noop' @@ -8,33 +7,12 @@ const FloatingBox = (props: Props) => { const {attachTo, disableEscapeKey, position, positionFallbacks, children, offset} = props const {onHidden, remeasureHint, propagateOutsideClicks, containerStyle, matchDimension} = props - const cur = attachTo?.current - - const [targetRect, setTargetRect] = React.useState(cur?.getBoundingClientRect()) - - React.useEffect(() => { - const tr = cur?.getBoundingClientRect() - - setTargetRect(t => { - if (t === tr) { - return t - } - if (!t || !tr) { - return t || tr - } - if (shallowEqual(t, tr)) { - return t - } - return tr - }) - }, [cur]) - return ( position: Styles.Position positionFallbacks?: ReadonlyArray matchDimension?: boolean @@ -227,12 +228,46 @@ type ModalPositionRelativeProps = { offset?: number // offset in pixels from edge } +const hiddenStyle = {opacity: 0, pointerEvents: 'none'} as const + +type PopupState = { + node: HTMLDivElement + style: Styles.StylesCrossPlatform +} + export const RelativeFloatingBox = (props: ModalPositionRelativeProps) => { - const [popupNode, setPopupNode] = React.useState(null) + const [popupState, setPopupState] = React.useState() const downRef = React.useRef(undefined) - const [style, setStyle] = React.useState({opacity: 0, pointerEvents: 'none'}) - const {targetRect, children, propagateOutsideClicks, onClosePopup, style: _style} = props - const {position, matchDimension, positionFallbacks, disableEscapeKey, offset = 0} = props + const {attachTo, children, propagateOutsideClicks, onClosePopup, style: _style} = props + const {position, matchDimension, positionFallbacks, disableEscapeKey, offset = 0, remeasureHint} = props + const popupNode = popupState?.node + + const setPopupRef = React.useCallback( + (node: HTMLDivElement | null) => { + if (!node) { + return + } + const targetRect = attachTo?.current?.getBoundingClientRect() + const style = targetRect + ? Styles.collapseStyles([ + computePopupStyle( + position, + targetRect, + node.getBoundingClientRect(), + !!matchDimension, + positionFallbacks, + offset + ), + _style, + ]) + : hiddenStyle + setPopupState({ + node, + style, + }) + }, + [attachTo, matchDimension, offset, position, positionFallbacks, remeasureHint, _style] + ) React.useEffect(() => { const handleDown = (e: MouseEvent) => { @@ -268,27 +303,10 @@ export const RelativeFloatingBox = (props: ModalPositionRelativeProps) => { } }, [onClosePopup, popupNode, propagateOutsideClicks]) - React.useEffect(() => { - if (targetRect && popupNode) { - const s = Styles.collapseStyles([ - computePopupStyle( - position, - targetRect, - popupNode.getBoundingClientRect(), - !!matchDimension, - positionFallbacks, - offset - ), - _style, - ]) - setStyle(s) - } - }, [_style, matchDimension, position, positionFallbacks, popupNode, targetRect, offset]) - const modalRoot = document.getElementById('modal-root') return modalRoot ? ReactDOM.createPortal( -
+
{disableEscapeKey ? (
{children}
) : ( diff --git a/shared/common-adapters/save-indicator.tsx b/shared/common-adapters/save-indicator.tsx index 36604d905433..3afbfd1b9f89 100644 --- a/shared/common-adapters/save-indicator.tsx +++ b/shared/common-adapters/save-indicator.tsx @@ -14,6 +14,11 @@ const Kb = { type SaveState = 'init' | 'saving' | 'saved' +type IndicatorState = { + saving: boolean + state: SaveState +} + export type Props = { saving: boolean style?: Styles.StylesCrossPlatform @@ -25,28 +30,33 @@ const defaultStyle = { const SaveIndicator = (props: Props) => { const {saving, style} = props - const [state, setState] = React.useState('init') - const lastSavingRef = React.useRef(saving) + const [indicatorState, setIndicatorState] = React.useState(() => ({ + saving, + state: 'init', + })) - React.useEffect(() => { - let id: ReturnType | undefined - if (lastSavingRef.current !== saving) { - if (saving) { - setState('saving') - } else { - setState('saved') - id = setTimeout(() => { - setState('init') - }, 1000) - } + let currentIndicatorState = indicatorState + if (currentIndicatorState.saving !== saving) { + currentIndicatorState = { + saving, + state: saving ? 'saving' : 'saved', + } + setIndicatorState(currentIndicatorState) + } + const {state} = currentIndicatorState - lastSavingRef.current = saving + React.useEffect(() => { + if (state !== 'saved') { + return undefined } + const id = setTimeout(() => { + setIndicatorState(state => (state.state === 'saved' ? {...state, state: 'init'} : state)) + }, 1000) return () => { - if (id !== undefined) clearTimeout(id) + clearTimeout(id) } - }, [saving]) + }, [state]) let content: React.ReactNode = null switch (state) { diff --git a/shared/common-adapters/toast.native.tsx b/shared/common-adapters/toast.native.tsx index 26deeb3178f0..363a45888856 100644 --- a/shared/common-adapters/toast.native.tsx +++ b/shared/common-adapters/toast.native.tsx @@ -4,11 +4,9 @@ import * as Styles from '@/styles' import {Box2} from './box' import {KeyboardAvoidingView2} from './keyboard-avoiding-view' import Popup from './popup' -import {useTimeout} from './use-timers' import {Animated as NativeAnimated, Easing as NativeEasing} from 'react-native' import type {Props} from './toast' import {colors, darkColors} from '@/styles/colors' -import noop from 'lodash/noop' import {useColorScheme} from 'react-native' const Kb = { @@ -19,53 +17,71 @@ const Kb = { const Toast = (props: Props) => { const {visible} = props - const [shouldRender, setShouldRender] = React.useState(false) - const opacityRef = React.useRef(new NativeAnimated.Value(0)) - const [opacity, setOpacity] = React.useState(undefined) - React.useEffect(() => { - setOpacity(opacityRef.current) - }, []) - const setShouldRenderFalseLater = useTimeout(() => { - setShouldRender(false) - }, 1000) + const [opacity] = React.useState(() => new NativeAnimated.Value(0)) + const [renderState, setRenderState] = React.useState(() => ({ + dismissedOnBlur: false, + shouldRender: visible, + visible, + })) + + let currentRenderState = renderState + if (currentRenderState.visible !== visible) { + currentRenderState = { + dismissedOnBlur: false, + shouldRender: visible || currentRenderState.shouldRender, + visible, + } + setRenderState(currentRenderState) + } + const {shouldRender} = currentRenderState + React.useEffect(() => { - if (visible) { - setShouldRender(true) - return () => { - opacity && - NativeAnimated.timing(opacity, { - duration: 200, - easing: NativeEasing.linear, - toValue: 0, - useNativeDriver: false, - }).start() - setShouldRenderFalseLater() - } + if (!shouldRender) { + return undefined + } + const animation = NativeAnimated.timing(opacity, { + duration: 200, + easing: NativeEasing.linear, + toValue: visible ? 1 : 0, + useNativeDriver: false, + }) + animation.start() + return () => { + animation.stop() } - return noop - }, [visible, setShouldRenderFalseLater, opacity]) + }, [opacity, shouldRender, visible]) + React.useEffect(() => { - if (shouldRender && opacity) { - const animation = NativeAnimated.timing(opacity, { - duration: 200, - easing: NativeEasing.linear, - toValue: 1, - useNativeDriver: false, - }) - animation.start() - return () => { - animation.stop() - } + if (visible || !shouldRender) { + return undefined + } + const id = setTimeout(() => { + setRenderState(state => + state.visible || !state.shouldRender ? state : {...state, shouldRender: false} + ) + }, 1000) + return () => { + clearTimeout(id) } - return noop - }, [shouldRender, opacity]) + }, [shouldRender, visible]) - // since this uses portals we need to hide if we're hidden else we can get stuck showing if our render is frozen - C.Router2.useSafeFocusEffect(() => { + const onSafeFocus = React.useCallback(() => { + setRenderState(state => + state.dismissedOnBlur + ? {...state, dismissedOnBlur: false, shouldRender: state.visible || state.shouldRender} + : state + ) return () => { - setShouldRender(false) + setRenderState(state => + state.shouldRender || !state.dismissedOnBlur + ? {...state, dismissedOnBlur: true, shouldRender: false} + : state + ) } - }) + }, []) + + // since this uses portals we need to hide if we're hidden else we can get stuck showing if our render is frozen + C.Router2.useSafeFocusEffect(onSafeFocus) const isDarkMode = useColorScheme() === 'dark' @@ -82,7 +98,7 @@ const Toast = (props: Props) => { backgroundColor: isDarkMode ? darkColors.black : colors.black, }, props.containerStyle, - {opacity: (opacity as number | undefined) ?? 0}, + {opacity}, ])} > {props.children} diff --git a/shared/common-adapters/zoomable-image.desktop.tsx b/shared/common-adapters/zoomable-image.desktop.tsx index 25de1a95f41f..22283fb577e4 100644 --- a/shared/common-adapters/zoomable-image.desktop.tsx +++ b/shared/common-adapters/zoomable-image.desktop.tsx @@ -34,25 +34,26 @@ function ZoomableImage(p: Props) { const isZoomedRef = React.useRef(isZoomed) const toggleZoom = () => { - isZoomedRef.current = !isZoomed + const nextIsZoomed = !isZoomed + isZoomedRef.current = nextIsZoomed setIsZoomed(s => !s) + setShowToast(nextIsZoomed) // hide until we handle mouse move imgRef.current?.classList.remove('fade-anim-enter-active') - onIsZoomed?.(!isZoomed) + onIsZoomed?.(nextIsZoomed) } React.useEffect(() => { - if (isZoomed) { - setShowToast(true) - const id = setTimeout(() => { - setShowToast(false) - }, 3000) - return () => { - setShowToast(false) - clearTimeout(id) - } - } else return undefined - }, [isZoomed]) + if (!showToast) { + return undefined + } + const id = setTimeout(() => { + setShowToast(false) + }, 3000) + return () => { + clearTimeout(id) + } + }, [showToast]) const handleMouseMove = (e: React.MouseEvent) => { if (!containerRef.current || !imgRef.current) return diff --git a/shared/wallets/index.tsx b/shared/wallets/index.tsx index 0f8126ffbf01..ea7eba2f7268 100644 --- a/shared/wallets/index.tsx +++ b/shared/wallets/index.tsx @@ -19,13 +19,14 @@ const Row = (p: {account: Account}) => { setSK('') setErr('') } - const onReveal = () => { + const onReveal = (onLoaded?: (text: string) => void) => { setErr('') setSK('') getSecretKey( [{accountID}], r => { setSK(r) + onLoaded?.(r) }, e => { setErr(e.desc) From b06f12a190237851b3f33aaacd8c4c0f84bd3287 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Sat, 25 Apr 2026 09:28:20 -0400 Subject: [PATCH 19/27] WIP --- plans/use-effects-lint-todo.md | 122 +++++++-------- shared/app/global-errors.tsx | 11 +- .../remote/remote-component.desktop.tsx | 23 ++- shared/devices/device-revoke.tsx | 8 +- shared/fs/browser/rows/editing.tsx | 14 +- shared/fs/common/hooks.tsx | 2 - .../fs/common/use-files-tab-upload-icon.tsx | 3 +- shared/fs/footer/upload.desktop.tsx | 33 ++-- shared/fs/footer/use-upload-countdown.tsx | 122 +++++++-------- shared/git/index.tsx | 38 +++-- shared/incoming-share/index.tsx | 15 +- shared/login/relogin/container.tsx | 28 ++-- shared/menubar/remote-proxy.desktop.tsx | 25 ++- shared/pinentry/remote-proxy.desktop.tsx | 11 +- shared/profile/add-to-team.tsx | 25 ++- shared/profile/generic/proofs-list.tsx | 41 +++-- shared/profile/use-proof-suggestions.tsx | 37 ++++- shared/provision/code-page/container.tsx | 10 +- shared/router-v2/account-switcher/index.tsx | 15 +- shared/settings/account/index.tsx | 76 ++++++--- shared/settings/chat.tsx | 14 +- shared/settings/files/hooks.tsx | 14 +- shared/settings/proxy.tsx | 69 ++++---- shared/signup/device-name.tsx | 12 +- shared/teams/add-members-wizard/confirm.tsx | 24 +-- shared/teams/channel/create-channels.tsx | 10 +- shared/teams/channel/header.tsx | 21 ++- shared/teams/common/enable-contacts.tsx | 16 +- shared/teams/common/use-contacts.native.tsx | 65 ++++---- shared/teams/emojis/add-alias.tsx | 89 +++++++---- shared/teams/external-team.tsx | 31 ++-- shared/teams/join-team/container.tsx | 11 +- shared/teams/join-team/join-from-invite.tsx | 16 +- .../teams/new-team/wizard/new-team-info.tsx | 38 ++--- shared/teams/role-picker.tsx | 14 +- shared/teams/team/index.tsx | 33 +++- shared/teams/team/member/index.new.tsx | 72 ++++++--- shared/teams/team/rows/index.tsx | 26 +-- .../team/settings-tab/default-channels.tsx | 13 +- shared/teams/team/settings-tab/index.tsx | 8 +- .../team/settings-tab/retention/index.tsx | 148 ++++++++---------- shared/teams/team/team-info.tsx | 34 ++-- shared/teams/use-cached-resource.tsx | 95 ++++++++--- shared/util/featured-bots.tsx | 59 ++++--- shared/util/phone-numbers/index.tsx | 4 - .../use-intersection-observer.desktop.tsx | 21 +-- shared/wallets/really-remove-account.tsx | 12 +- 47 files changed, 939 insertions(+), 689 deletions(-) diff --git a/plans/use-effects-lint-todo.md b/plans/use-effects-lint-todo.md index b880ce3d151b..61c565817510 100644 --- a/plans/use-effects-lint-todo.md +++ b/plans/use-effects-lint-todo.md @@ -67,85 +67,85 @@ Scope: only `react-hooks/set-state-in-effect` findings. Ignore `refs`, `immutabi ## Batch 7: Desktop And Remote Surfaces -- [ ] `shared/desktop/remote/remote-component.desktop.tsx:41:5` -- [ ] `shared/menubar/remote-proxy.desktop.tsx:281:7` -- [ ] `shared/pinentry/remote-proxy.desktop.tsx:78:7` +- [x] `shared/desktop/remote/remote-component.desktop.tsx:41:5` - keyed received remote props by component/param and used a guarded render reset instead of clearing props in the subscription effect. +- [x] `shared/menubar/remote-proxy.desktop.tsx:281:7` - moved logout/user-switch TLF clearing into guarded render state while the effect only invalidates pending loads and starts enabled refreshes. +- [x] `shared/pinentry/remote-proxy.desktop.tsx:78:7` - moved popup hiding on logout into a guarded render reset and left the effect to clear remote action handlers only. ## Batch 8: Files, Devices, Git, Incoming Share, Wallets -- [ ] `shared/devices/device-revoke.tsx:144:5` -- [ ] `shared/fs/browser/rows/editing.tsx:27:5` -- [ ] `shared/fs/common/hooks.tsx:884:7` -- [ ] `shared/fs/common/hooks.tsx:1040:7` -- [ ] `shared/fs/common/use-files-tab-upload-icon.tsx:63:7` -- [ ] `shared/fs/footer/upload.desktop.tsx:18:7` -- [ ] `shared/fs/footer/use-upload-countdown.tsx:72:11` -- [ ] `shared/git/index.tsx:141:5` -- [ ] `shared/incoming-share/index.tsx:309:5` -- [ ] `shared/wallets/really-remove-account.tsx:33:5` +- [x] `shared/devices/device-revoke.tsx:144:5` - derived the visible device from props or a matching loaded device instead of mirroring `ownProps.device`. +- [x] `shared/fs/browser/rows/editing.tsx:27:5` - removed filename mirror state and writes edits directly through `editSession.setEditName`. +- [x] `shared/fs/common/hooks.tsx:884:7` - kept known-path version invalidation but derives known path info instead of synchronously resetting cache state. +- [x] `shared/fs/common/hooks.tsx:1040:7` - kept non-file version invalidation and derives empty file context for non-file/stale keys. +- [x] `shared/fs/common/use-files-tab-upload-icon.tsx:63:7` - invalidates pending badge loads on disconnect and derives the disconnected icon as `undefined`. +- [x] `shared/fs/footer/upload.desktop.tsx:18:7` - derives upload draw state from `showing` plus delayed hide completion, leaving the effect to own the hide timer. +- [x] `shared/fs/footer/use-upload-countdown.tsx:72:11` - moved countdown state transitions into a guarded render state machine and left the effect to own the interval. +- [x] `shared/git/index.tsx:141:5` - replaced route-param expansion effect/ref with guarded route-key expansion state. +- [x] `shared/incoming-share/index.tsx:309:5` - derives Android share items from `androidShare` instead of syncing local state. +- [x] `shared/wallets/really-remove-account.tsx:33:5` - keys the loaded secret key by `accountID` and protects stale async callbacks instead of clearing in the effect. ## Batch 9: Login, Profile, Provision -- [ ] `shared/login/relogin/container.tsx:68:5` -- [ ] `shared/login/relogin/container.tsx:73:7` -- [ ] `shared/profile/add-to-team.tsx:181:5` -- [ ] `shared/profile/generic/proofs-list.tsx:680:5` -- [ ] `shared/profile/generic/proofs-list.tsx:684:5` -- [ ] `shared/profile/generic/proofs-list.tsx:761:5` -- [ ] `shared/profile/use-proof-suggestions.tsx:100:5` -- [ ] `shared/provision/code-page/container.tsx:71:5` +- [x] `shared/login/relogin/container.tsx:68:5` - replaced default-user sync with a guarded reset keyed by the selected default username. +- [x] `shared/login/relogin/container.tsx:73:7` - replaced the need-password error latch effect with a guarded same-component state update. +- [x] `shared/profile/add-to-team.tsx:181:5` - keyed the inner add-to-team component by username so username changes reset local form state before loading. +- [x] `shared/profile/generic/proofs-list.tsx:680:5` - replaced initial-username sync with guarded username state. +- [x] `shared/profile/generic/proofs-list.tsx:684:5` - replaced error prop sync with guarded error state while preserving submit-cleared local errors. +- [x] `shared/profile/generic/proofs-list.tsx:761:5` - replaced generic step username sync with guarded username state. +- [x] `shared/profile/use-proof-suggestions.tsx:100:5` - keys proof-suggestion results by enabled/load state and hides disabled or stale results without a clearing effect. +- [x] `shared/provision/code-page/container.tsx:71:5` - replaced default-tab sync with guarded tab state. ## Batch 10: Settings And Signup -- [ ] `shared/router-v2/account-switcher/index.tsx:142:7` -- [ ] `shared/settings/account/index.tsx:208:5` -- [ ] `shared/settings/account/index.tsx:215:5` -- [ ] `shared/settings/account/index.tsx:222:5` -- [ ] `shared/settings/account/index.tsx:231:7` -- [ ] `shared/settings/chat.tsx:199:7` -- [ ] `shared/settings/files/hooks.tsx:38:21` -- [ ] `shared/settings/proxy.tsx:123:9` -- [ ] `shared/signup/device-name.tsx:139:7` +- [x] `shared/router-v2/account-switcher/index.tsx:142:7` - resets clicked row state with a guarded waiting-key adjustment. +- [x] `shared/settings/account/index.tsx:208:5` - consumes added-email route banner state during guarded render while the effect only clears route params. +- [x] `shared/settings/account/index.tsx:215:5` - consumes added-phone route banner state during guarded render while the effect only clears route params. +- [x] `shared/settings/account/index.tsx:222:5` - clears account banner state with a guarded focus-keyed render adjustment. +- [x] `shared/settings/account/index.tsx:231:7` - derives invalid or verified added-email banner clearing during render. +- [x] `shared/settings/chat.tsx:199:7` - seeds selected team notification settings with a guarded render update. +- [x] `shared/settings/files/hooks.tsx:38:21` - keeps file-settings loading state for explicit refreshes while the initial effect starts the request without a synchronous loading update. +- [x] `shared/settings/proxy.tsx:123:9` - keys proxy form state to loaded `proxyData` via guarded render adjustment. +- [x] `shared/signup/device-name.tsx:139:7` - sanitizes the device name in the initializer and input handler instead of an effect. ## Batch 11: Teams Entry Forms And Permissions -- [ ] `shared/teams/add-members-wizard/confirm.tsx:49:5` -- [ ] `shared/teams/add-members-wizard/confirm.tsx:301:5` -- [ ] `shared/teams/channel/create-channels.tsx:21:5` -- [ ] `shared/teams/channel/header.tsx:15:5` -- [ ] `shared/teams/common/enable-contacts.tsx:16:5` -- [ ] `shared/teams/common/use-contacts.native.tsx:93:7` -- [ ] `shared/teams/common/use-contacts.native.tsx:123:7` -- [ ] `shared/teams/emojis/add-alias.tsx:47:26` +- [x] `shared/teams/add-members-wizard/confirm.tsx:49:5` - replaced wizard mirror state with guarded render adjustment keyed by the incoming wizard. +- [x] `shared/teams/add-members-wizard/confirm.tsx:301:5` - removed redundant role mirror state and derives from the wizard role. +- [x] `shared/teams/channel/create-channels.tsx:21:5` - keyed an inner component by `teamID` for identity resets. +- [x] `shared/teams/channel/header.tsx:15:5` - tags recent-joins results by `conversationIDKey` with stale callback cleanup. +- [x] `shared/teams/common/enable-contacts.tsx:16:5` - derives popup visibility from `noAccess` plus local dismissal state. +- [x] `shared/teams/common/use-contacts.native.tsx:93:7` - tags contact load state by permission/region key and derives visible loading/error state. +- [x] `shared/teams/common/use-contacts.native.tsx:123:7` - derives permanent no-access state from permission status instead of writing local state. +- [x] `shared/teams/emojis/add-alias.tsx:47:26` - seeds and guards alias selection from `defaultSelected` while keeping focus as the only effect. ## Batch 12: Teams Loading And Navigation -- [ ] `shared/teams/external-team.tsx:22:5` -- [ ] `shared/teams/join-team/container.tsx:45:5` -- [ ] `shared/teams/join-team/join-from-invite.tsx:45:5` -- [ ] `shared/teams/new-team/wizard/new-team-info.tsx:65:7` -- [ ] `shared/teams/role-picker.tsx:270:5` -- [ ] `shared/teams/team/index.tsx:103:5` -- [ ] `shared/teams/team/member/index.new.tsx:109:5` -- [ ] `shared/teams/team/rows/index.tsx:294:5` -- [ ] `shared/teams/team/rows/index.tsx:365:7` +- [x] `shared/teams/external-team.tsx:22:5` - tags team-info results by teamname and request ID, deriving waiting from the matching result. +- [x] `shared/teams/join-team/container.tsx:45:5` - keyed an inner join-team component by `initialTeamname`. +- [x] `shared/teams/join-team/join-from-invite.tsx:45:5` - keyed an inner invite component by invite identity. +- [x] `shared/teams/new-team/wizard/new-team-info.tsx:65:7` - tags debounced team-name validation results by teamname and derives stale or too-short names as available. +- [x] `shared/teams/role-picker.tsx:270:5` - uses a guarded render reset for `presetRole`. +- [x] `shared/teams/team/index.tsx:103:5` - scopes team-local collapsed/filter state by `teamID`. +- [x] `shared/teams/team/member/index.new.tsx:109:5` - tags team-tree membership state by team/user and splits explicit reload from initial load. +- [x] `shared/teams/team/rows/index.tsx:294:5` - tags general-conversation lookup results by `teamID` and preserves request stale guards. +- [x] `shared/teams/team/rows/index.tsx:365:7` - removes trigger mirror state and loads emoji directly from the trigger effect. ## Batch 13: Teams Settings And Cached Resource -- [ ] `shared/teams/team/settings-tab/default-channels.tsx:67:19` -- [ ] `shared/teams/team/settings-tab/index.tsx:439:5` -- [ ] `shared/teams/team/settings-tab/retention/index.tsx:63:7` -- [ ] `shared/teams/team/settings-tab/retention/index.tsx:105:11` -- [ ] `shared/teams/team/settings-tab/retention/index.tsx:118:9` -- [ ] `shared/teams/team/settings-tab/retention/index.tsx:475:10` -- [ ] `shared/teams/team/team-info.tsx:75:5` -- [ ] `shared/teams/team/team-info.tsx:79:5` -- [ ] `shared/teams/use-cached-resource.tsx:146:5` +- [x] `shared/teams/team/settings-tab/default-channels.tsx:67:19` - starts the initial default-channel load without a synchronous waiting update while preserving request/team guards. +- [x] `shared/teams/team/settings-tab/index.tsx:439:5` - replaces reset key state/effect with a derived settings key. +- [x] `shared/teams/team/settings-tab/retention/index.tsx:63:7` - removes modal-open mirror state and reset effect. +- [x] `shared/teams/team/settings-tab/retention/index.tsx:105:11` - moves retention warning/save logic into the menu selection handler. +- [x] `shared/teams/team/settings-tab/retention/index.tsx:118:9` - derives saving state from pending policy instead of policy-change effect writes. +- [x] `shared/teams/team/settings-tab/retention/index.tsx:475:10` - inlines initial retention loading as a cancellable async effect with request-version stale protection. +- [x] `shared/teams/team/team-info.tsx:75:5` - tags draft team name by source name and derives resets when the source changes. +- [x] `shared/teams/team/team-info.tsx:79:5` - tags draft description by source description and derives resets when the source changes. +- [x] `shared/teams/use-cached-resource.tsx:146:5` - derives visible cache state for key/cache/enabled mismatches instead of synchronously resetting from the prop-change effect. ## Batch 14: Utilities And App Global Error -- [ ] `shared/app/global-errors.tsx:76:5` -- [ ] `shared/util/featured-bots.tsx:41:7` -- [ ] `shared/util/featured-bots.tsx:91:7` -- [ ] `shared/util/phone-numbers/index.tsx:236:7` -- [ ] `shared/util/use-intersection-observer.desktop.tsx:44:5` +- [x] `shared/app/global-errors.tsx:76:5` - derives size from the current error plus expanded-error state and clears the expanded marker when the error is gone. +- [x] `shared/util/featured-bots.tsx:41:7` - keys featured-bot results by username with cancellation instead of clearing on empty username. +- [x] `shared/util/featured-bots.tsx:91:7` - drives featured-bot page loads from pending-page state and settles async results with cancellation guards. +- [x] `shared/util/phone-numbers/index.tsx:236:7` - initializes from the cached default country and lets the async loader update without a synchronous cache set in the effect. +- [x] `shared/util/use-intersection-observer.desktop.tsx:44:5` - creates and subscribes the observer directly in the layout effect instead of mirroring observer state. diff --git a/shared/app/global-errors.tsx b/shared/app/global-errors.tsx index 4b35e8b78f67..7c18969de0d1 100644 --- a/shared/app/global-errors.tsx +++ b/shared/app/global-errors.tsx @@ -43,8 +43,12 @@ const useData = () => { const [cachedSummary, setSummary] = React.useState(summaryForError(error)) const [cachedDetails, setDetails] = React.useState(detailsForError(error)) - const [size, setSize] = React.useState('Closed') + const [expandedError, setExpandedError] = React.useState() const countdownTimerRef = React.useRef>(undefined) + if (!error && expandedError) { + setExpandedError(undefined) + } + const size: Size = error ? (expandedError === error ? 'Big' : 'Small') : 'Closed' const clearCountdown = () => { countdownTimerRef.current && clearTimeout(countdownTimerRef.current) @@ -52,7 +56,9 @@ const useData = () => { } const onExpandClick = () => { - setSize('Big') + if (error) { + setExpandedError(error) + } if (!C.isMobile) { clearCountdown() } @@ -73,7 +79,6 @@ const useData = () => { error ? 0 : 7000 ) // if it's set, do it immediately, if it's cleared set it in a bit const newError = !!error - setSize(newError ? 'Small' : 'Closed') if (!C.isMobile) { if (countdownTimerRef.current) clearTimeout(countdownTimerRef.current) countdownTimerRef.current = undefined diff --git a/shared/desktop/remote/remote-component.desktop.tsx b/shared/desktop/remote/remote-component.desktop.tsx index 11d8b0d8f3e4..b1395a554c5e 100644 --- a/shared/desktop/remote/remote-component.desktop.tsx +++ b/shared/desktop/remote/remote-component.desktop.tsx @@ -16,6 +16,12 @@ type UseRemotePropsReceiverOptions = { showOnProps?: boolean } +type RemotePropsReceiverState

= { + component: RemoteComponentName + param: string + value: P | null +} + export const getRemoteComponentParam = () => new URLSearchParams(window.location.search).get('param') ?? '' export const useRemoteDarkModeSync = (darkMode: boolean) => { @@ -33,16 +39,27 @@ export const RemoteDarkModeSync = (p: {children: React.ReactNode; darkMode: bool export const useRemotePropsReceiver = (options: UseRemotePropsReceiverOptions) => { const {component, param, showOnProps = true} = options - const [value, setValue] = React.useState

(null) + const [propsState, setPropsState] = React.useState>(() => ({ + component, + param, + value: null, + })) + const currentPropsState = + propsState.component === component && propsState.param === param + ? propsState + : {component, param, value: null} + if (currentPropsState !== propsState) { + setPropsState(currentPropsState) + } + const value = currentPropsState.value const hasShownWindow = React.useRef(false) React.useEffect(() => { hasShownWindow.current = false - setValue(null) const unsubscribe = ipcRendererOn?.('KBprops', (_event: unknown, raw: unknown) => { try { - setValue(JSON.parse(raw as string) as P) + setPropsState({component, param, value: JSON.parse(raw as string) as P}) } catch (error) { logger.error('remote props parse failed', component, param, error) } diff --git a/shared/devices/device-revoke.tsx b/shared/devices/device-revoke.tsx index c083475b491c..d399001202ea 100644 --- a/shared/devices/device-revoke.tsx +++ b/shared/devices/device-revoke.tsx @@ -134,16 +134,12 @@ const DeviceRevoke = (ownProps: OwnProps) => { const loadDeviceHistory = C.useRPC(T.RPCGen.deviceDeviceHistoryListRpcPromise) const navigateUp = C.Router2.navigateUp const selectedDeviceID = ownProps.device?.deviceID ?? ownProps.deviceID ?? T.Devices.stringToDeviceID('') - const [loadedDevice, setLoadedDevice] = React.useState(ownProps.device) - const device = ownProps.device ?? loadedDevice + const [loadedDevice, setLoadedDevice] = React.useState() + const device = ownProps.device ?? (loadedDevice?.deviceID === selectedDeviceID ? loadedDevice : undefined) const [endangeredTLFs, setEndangeredTLFs] = React.useState(new Array()) const waiting = C.Waiting.useAnyWaiting(C.waitingKeyDevices) const onCancel = navigateUp - React.useEffect(() => { - setLoadedDevice(ownProps.device) - }, [ownProps.device]) - C.useOnMountOnce(() => { if (device) { return diff --git a/shared/fs/browser/rows/editing.tsx b/shared/fs/browser/rows/editing.tsx index c3ae60339f9f..8552104f67e9 100644 --- a/shared/fs/browser/rows/editing.tsx +++ b/shared/fs/browser/rows/editing.tsx @@ -10,22 +10,12 @@ type Props = { function Editing({editSession}: Props) { const {commitEdit, discardEdit, edit} = editSession - const [filename, setFilename] = React.useState(edit.name) - const setEditName = React.useEffectEvent((nextName: string) => { - editSession.setEditName(nextName) - }) const onCancel = () => { discardEdit() } const onSubmit = () => { commitEdit() } - React.useEffect(() => { - setEditName(filename) - }, [filename, editSession.editID]) - React.useEffect(() => { - setFilename(edit.name) - }, [edit.name, editSession.editID]) const onKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Escape') onCancel() } @@ -48,12 +38,12 @@ function Editing({editSession}: Props) { body={ setFilename(name)} + onChangeText={editSession.setEditName} autoFocus={true} onKeyDown={onKeyDown} hideBorder={true} diff --git a/shared/fs/common/hooks.tsx b/shared/fs/common/hooks.tsx index a26bf9fa5f10..2bf043c6b99a 100644 --- a/shared/fs/common/hooks.tsx +++ b/shared/fs/common/hooks.tsx @@ -881,7 +881,6 @@ export const useFsPathInfo = (path: T.FS.Path, knownPathInfo = FS.emptyPathInfo) React.useEffect(() => { if (alreadyKnown) { pathInfoVersionRef.current += 1 - setPathInfoState({path, pathInfo: knownPathInfo}) } }, [alreadyKnown, knownPathInfo, path]) useFsLoadOnMountAndFocus({ @@ -1037,7 +1036,6 @@ export const useFsFileContext = ( React.useEffect(() => { if (pathItem.type !== T.FS.PathType.File) { fileContextVersionRef.current += 1 - setFileContextState({fileContext: FS.emptyFileContext, reloadKey}) } }, [pathItem.type, reloadKey]) useFsLoadOnMountAndFocus({ diff --git a/shared/fs/common/use-files-tab-upload-icon.tsx b/shared/fs/common/use-files-tab-upload-icon.tsx index 680df898d465..66014cd1c6f0 100644 --- a/shared/fs/common/use-files-tab-upload-icon.tsx +++ b/shared/fs/common/use-files-tab-upload-icon.tsx @@ -60,7 +60,6 @@ export const useFilesTabUploadIcon = () => { React.useEffect(() => { if (!connected) { generationRef.current++ - setUploadIcon(undefined) return } loadUploadIcon() @@ -108,5 +107,5 @@ export const useFilesTabUploadIcon = () => { connected ) - return uploadIcon + return connected ? uploadIcon : undefined } diff --git a/shared/fs/footer/upload.desktop.tsx b/shared/fs/footer/upload.desktop.tsx index b1e77ee7fcfb..58313ebbe520 100644 --- a/shared/fs/footer/upload.desktop.tsx +++ b/shared/fs/footer/upload.desktop.tsx @@ -6,26 +6,37 @@ import capitalize from 'lodash/capitalize' import './upload.css' type DrawState = 'showing' | 'hiding' | 'hidden' +type AnimationState = { + hideComplete: boolean + lastShowing: boolean +} + const Upload = (props: UploadProps) => { const {smallMode, showing, files, fileName, totalSyncingBytes, timeLeft, debugToggleShow} = props - const [drawState, setDrawState] = React.useState(showing ? 'showing' : 'hidden') + const [animationState, setAnimationState] = React.useState(() => ({ + hideComplete: !showing, + lastShowing: showing, + })) + let hideComplete = animationState.hideComplete + if (animationState.lastShowing !== showing) { + hideComplete = false + setAnimationState({hideComplete, lastShowing: showing}) + } + const drawState: DrawState = showing ? 'showing' : hideComplete ? 'hidden' : 'hiding' const height = 40 React.useEffect(() => { - let id: undefined | ReturnType - if (showing) { - setDrawState('showing') - } else { - setDrawState('hiding') - id = setTimeout(() => { - setDrawState('hidden') - }, 300) + if (showing || hideComplete) { + return } + const id = setTimeout(() => { + setAnimationState(s => (s.lastShowing === showing ? {...s, hideComplete: true} : s)) + }, 300) return () => { - id && clearTimeout(id) + clearTimeout(id) } - }, [showing]) + }, [hideComplete, showing]) // this is due to the fact that the parent container has a marginTop of -13 on darwin const offset = smallMode && C.isDarwin ? 13 : 0 diff --git a/shared/fs/footer/use-upload-countdown.tsx b/shared/fs/footer/use-upload-countdown.tsx index 40e5f569c962..e58029398c60 100644 --- a/shared/fs/footer/use-upload-countdown.tsx +++ b/shared/fs/footer/use-upload-countdown.tsx @@ -30,87 +30,73 @@ enum Mode { const tickInterval = 1000 const initialGlueTTL = 2 +type UploadCountdownState = { + glueTTL: number + inputKey: string + mode: Mode +} + +const makeInputKey = (isOnline: boolean, files: number, totalSyncingBytes: number, endEstimate: number, now: number) => + `${isOnline}:${files}:${totalSyncingBytes}:${endEstimate}:${now}` + +const updateCountdownState = ( + state: UploadCountdownState, + isUploading: boolean, + displayDuration: number, + inputKey: string +): UploadCountdownState => { + if (state.inputKey === inputKey) { + return state + } + switch (state.mode) { + case Mode.Hidden: + return isUploading ? {glueTTL: initialGlueTTL, inputKey, mode: Mode.CountDown} : {...state, inputKey} + case Mode.CountDown: + return isUploading + ? {...state, inputKey} + : {glueTTL: state.glueTTL, inputKey, mode: state.glueTTL > 0 ? Mode.Sticky : Mode.Hidden} + case Mode.Sticky: + if (isUploading) { + return {glueTTL: initialGlueTTL, inputKey, mode: Mode.CountDown} + } + if (displayDuration !== 0) { + return {...state, inputKey} + } + const glueTTL = Math.max(0, state.glueTTL - 1) + return glueTTL > 0 ? {glueTTL, inputKey, mode: Mode.Sticky} : {glueTTL, inputKey, mode: Mode.Hidden} + } +} + export const useUploadCountdown = (p: UploadCountdownHOCProps) => { const {endEstimate, files, fileName, isOnline, totalSyncingBytes, debugToggleShow, smallMode} = p - const tickerID = React.useRef>(undefined) - const [displayDuration, setDisplayDuration] = React.useState(0) - const [glueTTL, setGlueTTL] = React.useState(0) - const [mode, setMode] = React.useState(Mode.Hidden) const [now, setNow] = React.useState(() => Date.now()) + const displayDuration = endEstimate ? endEstimate - now : 0 + const isUploading = isOnline && (!!files || !!totalSyncingBytes) + const inputKey = makeInputKey(isOnline, files, totalSyncingBytes, endEstimate || 0, now) + const [countdownState, setCountdownState] = React.useState(() => + updateCountdownState({glueTTL: 0, inputKey: '', mode: Mode.Hidden}, isUploading, displayDuration, inputKey) + ) + const visibleCountdownState = updateCountdownState(countdownState, isUploading, displayDuration, inputKey) + if (visibleCountdownState !== countdownState) { + setCountdownState(visibleCountdownState) + } React.useEffect(() => { - return () => { - if (tickerID.current) { - clearInterval(tickerID.current) - tickerID.current = undefined - } - } - }, []) - - React.useEffect(() => { - const startTicker = () => { - if (tickerID.current) { - return - } - tickerID.current = setInterval(() => setNow(Date.now()), tickInterval) - } - const stopTicker = () => { - if (!tickerID.current) { - return - } - clearInterval(tickerID.current) - tickerID.current = undefined + if (visibleCountdownState.mode === Mode.Hidden) { + return } - - const isUploading = isOnline && (!!files || !!totalSyncingBytes) - const newDisplayDuration = endEstimate ? endEstimate - now : 0 - switch (mode) { - case Mode.Hidden: - if (isUploading) { - startTicker() - setDisplayDuration(newDisplayDuration) - setGlueTTL(initialGlueTTL) - setMode(Mode.CountDown) - } else { - stopTicker() - } - return - case Mode.CountDown: - if (isUploading) { - setDisplayDuration(newDisplayDuration) - } else { - setDisplayDuration(newDisplayDuration) - setMode(glueTTL > 0 ? Mode.Sticky : Mode.Hidden) - } - return - case Mode.Sticky: - if (isUploading) { - setDisplayDuration(newDisplayDuration) - setGlueTTL(initialGlueTTL) - setMode(Mode.CountDown) - } else { - setDisplayDuration(newDisplayDuration) - if (newDisplayDuration === 0) { - const newGlueTTL = Math.max(0, glueTTL - 1) - if (newGlueTTL > 0) { - setGlueTTL(newGlueTTL) - } else { - setMode(Mode.Hidden) - } - } - } - return - default: - return + const tickerID = setInterval(() => setNow(Date.now()), tickInterval) + return () => { + clearInterval(tickerID) } - }, [isOnline, files, totalSyncingBytes, endEstimate, glueTTL, mode, now]) + }, [visibleCountdownState.mode]) return { debugToggleShow, fileName, files, - showing: mode !== Mode.Hidden, + showing: visibleCountdownState.mode !== Mode.Hidden, smallMode, timeLeft: formatDuration(displayDuration), totalSyncingBytes, diff --git a/shared/git/index.tsx b/shared/git/index.tsx index e329b823eea1..f465dda6e22b 100644 --- a/shared/git/index.tsx +++ b/shared/git/index.tsx @@ -87,13 +87,17 @@ const getRepos = (git: T.Immutable>) => {personals: [], teams: []} ) +type ExpandedState = { + appliedRouteKey: string + expandedSet: Set +} + const Container = (ownProps: OwnProps) => { const loading = C.Waiting.useAnyWaiting(C.waitingKeyGitLoading) const loadGit = C.useRPC(T.RPCGen.gitGetAllGitMetadataRpcPromise) const clearGitBadges = C.useRPC(T.RPCGen.gregorDismissCategoryRpcPromise) const [error, setError] = React.useState() const [idToInfo, setIDToInfo] = React.useState(new Map()) - const expandedRouteApplied = React.useRef(false) const isNew = useConfigState(s => s.badgeState?.newGitRepoGlobalUniqueIDs) const {badged} = useLocalBadging(new Set(isNew ?? []), () => { clearGitBadges( @@ -127,23 +131,29 @@ const Container = (ownProps: OwnProps) => { load() }) - const [expandedSet, setExpandedSet] = React.useState(new Set()) - - React.useEffect(() => { - if (expandedRouteApplied.current) { - return - } + const [expandedState, setExpandedState] = React.useState(() => ({ + appliedRouteKey: '', + expandedSet: new Set(), + })) + const expandedRouteKey = + ownProps.expandedRepoID && ownProps.expandedTeamname + ? `${ownProps.expandedTeamname}:${ownProps.expandedRepoID}` + : '' + let expandedSet = expandedState.expandedSet + if (expandedRouteKey && expandedState.appliedRouteKey !== expandedRouteKey) { const expanded = findExpandedRepoID(idToInfo, ownProps.expandedRepoID, ownProps.expandedTeamname) - if (!expanded) { - return + if (expanded) { + expandedSet = new Set([expanded]) + setExpandedState({appliedRouteKey: expandedRouteKey, expandedSet}) } - expandedRouteApplied.current = true - setExpandedSet(new Set([expanded])) - }, [idToInfo, ownProps.expandedRepoID, ownProps.expandedTeamname]) + } const toggleExpand = (id: string) => { - expandedSet.has(id) ? expandedSet.delete(id) : expandedSet.add(id) - setExpandedSet(new Set(expandedSet)) + setExpandedState(state => { + const nextExpandedSet = new Set(state.expandedSet) + nextExpandedSet.has(id) ? nextExpandedSet.delete(id) : nextExpandedSet.add(id) + return {...state, expandedSet: nextExpandedSet} + }) } const makePopup = (p: Kb.Popup2Parms) => { diff --git a/shared/incoming-share/index.tsx b/shared/incoming-share/index.tsx index d8c1f66f5e61..e46265e66ee9 100644 --- a/shared/incoming-share/index.tsx +++ b/shared/incoming-share/index.tsx @@ -297,19 +297,14 @@ const useIncomingShareItems = () => { // Android const androidShare = useConfigState(s => s.androidShare) - React.useEffect(() => { - if (!C.isAndroid || !androidShare) { - return - } - - const items = - androidShare.type === T.RPCGen.IncomingShareType.file + const androidShareItems = + C.isAndroid && androidShare + ? androidShare.type === T.RPCGen.IncomingShareType.file ? androidShare.urls.map(u => ({originalPath: u, type: T.RPCGen.IncomingShareType.file})) : [{content: androidShare.text, type: T.RPCGen.IncomingShareType.text}] - setIncomingShareItems(items) - }, [androidShare, setIncomingShareItems]) + : undefined - return {incomingShareError, incomingShareItems} + return {incomingShareError, incomingShareItems: androidShareItems ?? incomingShareItems} } type IncomingShareMainProps = { diff --git a/shared/login/relogin/container.tsx b/shared/login/relogin/container.tsx index 724adcb084ca..4a9644f927ca 100644 --- a/shared/login/relogin/container.tsx +++ b/shared/login/relogin/container.tsx @@ -29,7 +29,10 @@ const ReloginContainer = () => { const users = sortBy(_users, 'username') const [password, setPassword] = React.useState('') - const [selectedUser, setSelectedUser] = React.useState(pselectedUser) + const [selectedUserState, setSelectedUserState] = React.useState({ + defaultUsername: pselectedUser, + username: pselectedUser, + }) const [showTyping, setShowTyping] = React.useState(false) const setLoginError = useConfigState(s => s.dispatch.setLoginError) @@ -51,6 +54,19 @@ const ReloginContainer = () => { const [gotNeedPasswordError, setGotNeedPasswordError] = React.useState(false) + if (selectedUserState.defaultUsername !== pselectedUser) { + setSelectedUserState({defaultUsername: pselectedUser, username: pselectedUser}) + } + + const selectedUser = + selectedUserState.defaultUsername === pselectedUser ? selectedUserState.username : pselectedUser + const setSelectedUser = (username: string) => + setSelectedUserState(state => ({...state, username})) + + if (!gotNeedPasswordError && error === needPasswordError) { + setGotNeedPasswordError(true) + } + const onSubmit = () => { onLogin(selectedUser, password) } @@ -64,16 +80,6 @@ const ReloginContainer = () => { } } - React.useEffect(() => { - setSelectedUser(pselectedUser) - }, [pselectedUser, setSelectedUser]) - - React.useEffect(() => { - if (error === needPasswordError) { - setGotNeedPasswordError(true) - } - }, [error, setGotNeedPasswordError]) - return ( = [] const emptyTlfUpdates: T.FS.UserTlfUpdates = [] +type TlfUpdateState = { + shouldClear: boolean + tlfUpdates: T.FS.UserTlfUpdates +} const pathFromFolderRPC = (folder: T.RPCGen.Folder): T.FS.Path => { const visibility = T.FS.getVisibilityFromRPCFolderType(folder.folderType) @@ -243,7 +247,18 @@ function useMenubarTlfUpdates( kbfsDaemonRpcStatus: T.FS.KbfsDaemonRpcStatus, menuWindowShownCount: number ) { - const [tlfUpdates, setTlfUpdates] = React.useState(emptyTlfUpdates) + const shouldClearTlfUpdates = !loggedIn || userSwitching + const [tlfUpdateState, setTlfUpdateState] = React.useState(() => ({ + shouldClear: shouldClearTlfUpdates, + tlfUpdates: emptyTlfUpdates, + })) + const currentTlfUpdateState = + tlfUpdateState.shouldClear === shouldClearTlfUpdates + ? tlfUpdateState + : {shouldClear: shouldClearTlfUpdates, tlfUpdates: emptyTlfUpdates} + if (currentTlfUpdateState !== tlfUpdateState) { + setTlfUpdateState(currentTlfUpdateState) + } const generationRef = React.useRef(0) const enabled = loggedIn && @@ -265,7 +280,10 @@ function useMenubarTlfUpdates( if (generation !== generationRef.current || !enabledRef.current) { return } - setTlfUpdates(userTlfHistoryRPCToState(writerEdits || [])) + setTlfUpdateState({ + shouldClear: false, + tlfUpdates: userTlfHistoryRPCToState(writerEdits || []), + }) } catch (error) { if (generation === generationRef.current && enabledRef.current) { errorToActionOrThrow(error) @@ -278,7 +296,6 @@ function useMenubarTlfUpdates( React.useEffect(() => { if (!loggedIn || userSwitching) { generationRef.current++ - setTlfUpdates(emptyTlfUpdates) return } if (!enabled) { @@ -287,7 +304,7 @@ function useMenubarTlfUpdates( loadUserFileEdits() }, [enabled, loadUserFileEdits, loggedIn, userSwitching]) - return tlfUpdates + return currentTlfUpdateState.tlfUpdates } function useMenubarRemoteProps(): Props { diff --git a/shared/pinentry/remote-proxy.desktop.tsx b/shared/pinentry/remote-proxy.desktop.tsx index 4ca9d38bc42c..9ce692699d75 100644 --- a/shared/pinentry/remote-proxy.desktop.tsx +++ b/shared/pinentry/remote-proxy.desktop.tsx @@ -75,9 +75,9 @@ const PinentryProxy = () => { React.useEffect(() => { if (!loggedIn) { - clearPopup() + handlersRef.current = {} } - }, [clearPopup, loggedIn]) + }, [loggedIn]) useEngineActionListener('keybase.1.secretUi.getPassphrase', action => { const {response, params} = action.payload @@ -110,7 +110,12 @@ const PinentryProxy = () => { }) }) - const {cancelLabel, prompt, retryLabel, showTyping, submitLabel, type, windowTitle} = popupState + const currentPopupState = + !loggedIn && popupState.type !== T.RPCGen.PassphraseType.none ? initialPopupState() : popupState + if (currentPopupState !== popupState) { + setPopupState(currentPopupState) + } + const {cancelLabel, prompt, retryLabel, showTyping, submitLabel, type, windowTitle} = currentPopupState const show = type !== T.RPCGen.PassphraseType.none const darkMode = useColorScheme() === 'dark' if (show) { diff --git a/shared/profile/add-to-team.tsx b/shared/profile/add-to-team.tsx index 63329f477e36..bbb671d1ec8b 100644 --- a/shared/profile/add-to-team.tsx +++ b/shared/profile/add-to-team.tsx @@ -53,6 +53,11 @@ const makeAddUserToTeamsResult = ( type OwnProps = {username: string} const Container = (ownProps: OwnProps) => { + const {username} = ownProps + return +} + +const AddToTeam = (ownProps: OwnProps) => { const {username: them} = ownProps const {teams} = useTeamsList() const teamNameToID = React.useMemo(() => new Map(teams.map(team => [team.teamname, team.id] as const)), [teams]) @@ -80,13 +85,6 @@ const Container = (ownProps: OwnProps) => { const ownerDisabledReason = getOwnerDisabledReason(selectedTeams, teamNameToRole) - React.useEffect(() => { - return () => { - teamListRequestID.current += 1 - submitRequestID.current += 1 - } - }, []) - const loadTeamList = React.useEffectEvent(() => { const requestID = teamListRequestID.current + 1 teamListRequestID.current = requestID @@ -178,15 +176,12 @@ const Container = (ownProps: OwnProps) => { ) React.useEffect(() => { - setAddUserToTeamsResults('') - setAddUserToTeamsState('notStarted') - setSelectedTeams(new Set()) - setRolePickerOpen(false) - setSelectedRole('writer') - setSendNotification(true) - setTeamProfileAddList([]) loadTeamList() - }, [them]) + return () => { + teamListRequestID.current += 1 + submitRequestID.current += 1 + } + }, []) const onBack = () => { navigateUp() diff --git a/shared/profile/generic/proofs-list.tsx b/shared/profile/generic/proofs-list.tsx index ad8d2d7bc9e1..1cc233d0fceb 100644 --- a/shared/profile/generic/proofs-list.tsx +++ b/shared/profile/generic/proofs-list.tsx @@ -672,17 +672,26 @@ const EnterUsername = ({ platform: T.More.PlatformsExpandedType username: string }) => { - const [username, setUsername] = React.useState(initialUsername) - const [errorText, setErrorText] = React.useState(error === 'Input canceled' ? '' : error) - - React.useEffect(() => { - setUsername(initialUsername) - }, [initialUsername]) + const [usernameState, setUsernameState] = React.useState({ + initialUsername, + username: initialUsername, + }) + const normalizedError = error === 'Input canceled' ? '' : error + const [errorState, setErrorState] = React.useState({error, errorText: normalizedError}) + + if (usernameState.initialUsername !== initialUsername) { + setUsernameState({initialUsername, username: initialUsername}) + } - React.useEffect(() => { - setErrorText(error === 'Input canceled' ? '' : error) - }, [error]) + if (errorState.error !== error) { + setErrorState({error, errorText: normalizedError}) + } + const username = + usernameState.initialUsername === initialUsername ? usernameState.username : initialUsername + const setUsername = (username: string) => setUsernameState(state => ({...state, username})) + const errorText = errorState.error === error ? errorState.errorText : normalizedError + const setErrorText = (errorText: string) => setErrorState(state => ({...state, errorText})) const canSubmit = !!username.length const submit = () => { if (!canSubmit) { @@ -755,11 +764,17 @@ const GenericEnterUsername = ({ onSubmit: (username: string) => void step: GenericEnterUsernameStep }) => { - const [username, setUsername] = React.useState(step.username) - React.useEffect(() => { - setUsername(step.username) - }, [step.username]) + const [usernameState, setUsernameState] = React.useState({ + stepUsername: step.username, + username: step.username, + }) + + if (usernameState.stepUsername !== step.username) { + setUsernameState({stepUsername: step.username, username: step.username}) + } + const username = usernameState.stepUsername === step.username ? usernameState.username : step.username + const setUsername = (username: string) => setUsernameState(state => ({...state, username})) const unreachable = !!step.proofUrl return ( diff --git a/shared/profile/use-proof-suggestions.tsx b/shared/profile/use-proof-suggestions.tsx index 4ae4336a4153..88796b932cb0 100644 --- a/shared/profile/use-proof-suggestions.tsx +++ b/shared/profile/use-proof-suggestions.tsx @@ -9,6 +9,12 @@ import {RPCError} from '@/util/errors' const emptyProofSuggestions: ReadonlyArray = [] +type ProofSuggestionsState = { + enabled: boolean + loadKey: number + suggestions: ReadonlyArray +} + const rpcRowColorToColor = (color: T.RPCGen.Identify3RowColor): T.Tracker.AssertionColor => { switch (color) { case T.RPCGen.Identify3RowColor.blue: @@ -58,14 +64,22 @@ const rpcSuggestionToAssertion = (suggestion: T.RPCGen.ProofSuggestion): T.Track export const useProofSuggestions = (enabled = true) => { const uid = useCurrentUserState(s => s.uid) - const [proofSuggestions, setProofSuggestions] = - React.useState>(emptyProofSuggestions) + const [proofSuggestionsState, setProofSuggestionsState] = React.useState({ + enabled, + loadKey: 0, + suggestions: emptyProofSuggestions, + }) const requestVersionRef = React.useRef(0) + const loadKey = + proofSuggestionsState.enabled === enabled + ? proofSuggestionsState.loadKey + : proofSuggestionsState.loadKey + 1 + const proofSuggestions = + proofSuggestionsState.enabled === enabled ? proofSuggestionsState.suggestions : emptyProofSuggestions const reload = React.useCallback(() => { if (!enabled) { requestVersionRef.current += 1 - setProofSuggestions(emptyProofSuggestions) return } @@ -81,7 +95,12 @@ export const useProofSuggestions = (enabled = true) => { if (requestVersionRef.current !== version) { return } - setProofSuggestions(suggestions?.map(rpcSuggestionToAssertion) ?? emptyProofSuggestions) + const nextSuggestions = suggestions?.map(rpcSuggestionToAssertion) ?? emptyProofSuggestions + setProofSuggestionsState(state => + state.enabled === enabled && state.loadKey === loadKey + ? {...state, suggestions: nextSuggestions} + : state + ) } catch (error) { if (!(error instanceof RPCError)) { return @@ -94,7 +113,7 @@ export const useProofSuggestions = (enabled = true) => { } ignorePromise(load()) - }, [enabled]) + }, [enabled, loadKey]) React.useEffect(() => { reload() @@ -107,6 +126,14 @@ export const useProofSuggestions = (enabled = true) => { reload() }) + if (proofSuggestionsState.enabled !== enabled) { + setProofSuggestionsState(state => + state.enabled === enabled + ? state + : {enabled, loadKey: state.loadKey + 1, suggestions: emptyProofSuggestions} + ) + } + return { proofSuggestions, reload, diff --git a/shared/provision/code-page/container.tsx b/shared/provision/code-page/container.tsx index efe3e82aebe3..8608a04ec207 100644 --- a/shared/provision/code-page/container.tsx +++ b/shared/provision/code-page/container.tsx @@ -65,12 +65,14 @@ const CodePageContainer = () => { } })() - const [tab, setTab] = React.useState(defaultTab) + const [tabState, setTabState] = React.useState({defaultTab, tab: defaultTab}) - React.useEffect(() => { - setTab(defaultTab) - }, [defaultTab]) + if (tabState.defaultTab !== defaultTab) { + setTabState({defaultTab, tab: defaultTab}) + } + const tab = tabState.defaultTab === defaultTab ? tabState.tab : defaultTab + const setTab = (tab: Tab) => setTabState(state => ({...state, tab})) const tabBackground = () => (tab === 'QR' ? Kb.Styles.globalColors.blueLight : Kb.Styles.globalColors.green) const buttonType = () => (tab === 'QR' ? 'Default' as const : 'Success' as const) const buttonLabelStyle = () => (tab === 'QR' ? styles.primaryOnBlueLabel : styles.primaryOnGreenLabel) diff --git a/shared/router-v2/account-switcher/index.tsx b/shared/router-v2/account-switcher/index.tsx index e5a114a66c3d..320ca25895c3 100644 --- a/shared/router-v2/account-switcher/index.tsx +++ b/shared/router-v2/account-switcher/index.tsx @@ -136,17 +136,18 @@ type AccountRowProps = { } const AccountRow = (props: AccountRowProps) => { const {waiting} = props - const [clicked, setClicked] = React.useState(false) - React.useEffect(() => { - if (!waiting) { - setClicked(false) - } - }, [setClicked, waiting]) + const [{clicked, wasWaiting}, setClickedState] = React.useState(() => ({ + clicked: false, + wasWaiting: waiting, + })) + if (wasWaiting !== waiting) { + setClickedState({clicked: waiting ? clicked : false, wasWaiting: waiting}) + } const onClick = waiting ? undefined : () => { - setClicked(true) + setClickedState({clicked: true, wasWaiting: waiting}) props.onSelectAccount(props.entry.account.username) } return ( diff --git a/shared/settings/account/index.tsx b/shared/settings/account/index.tsx index 1979132d9172..f245d14bb75e 100644 --- a/shared/settings/account/index.tsx +++ b/shared/settings/account/index.tsx @@ -172,6 +172,14 @@ const DeleteAccount = () => { type Props = {route: {params?: SettingsAccountRouteParams}} +type AddedBannerState = { + email: string + isFocused: boolean + phone: boolean + routeEmail: string | undefined + routePhone: boolean +} + const AccountSettings = ({route}: Props) => { const addedEmailFromRoute = route.params?.addedEmailBannerEmail const addedPhoneFromRoute = !!route.params?.addedPhoneBanner @@ -187,8 +195,13 @@ const AccountSettings = ({route}: Props) => { const phones = useSettingsPhoneState(s => s.phones) const setGlobalError = useConfigState(s => s.dispatch.setGlobalError) const deletePhoneNumber = C.useRPC(T.RPCGen.phoneNumbersDeletePhoneNumberRpcPromise) - const [addedEmail, setAddedEmail] = React.useState(addedEmailFromRoute ?? '') - const [addedPhone, setAddedPhone] = React.useState(addedPhoneFromRoute) + const [addedBannerState, setAddedBannerState] = React.useState(() => ({ + email: addedEmailFromRoute ?? '', + isFocused, + phone: addedPhoneFromRoute, + routeEmail: addedEmailFromRoute, + routePhone: addedPhoneFromRoute, + })) const {randomPW, reload: reloadRandomPW} = useRandomPWState() const {navigateAppend, switchTab} = C.Router2 const _onClearSupersededPhoneNumber = (phone: string) => { @@ -201,38 +214,53 @@ const AccountSettings = ({route}: Props) => { } ) } + let nextAddedBannerState = addedBannerState + if (nextAddedBannerState.routeEmail !== addedEmailFromRoute) { + nextAddedBannerState = { + ...nextAddedBannerState, + email: addedEmailFromRoute ?? nextAddedBannerState.email, + routeEmail: addedEmailFromRoute, + } + } + if (nextAddedBannerState.routePhone !== addedPhoneFromRoute) { + nextAddedBannerState = { + ...nextAddedBannerState, + phone: addedPhoneFromRoute ? true : nextAddedBannerState.phone, + routePhone: addedPhoneFromRoute, + } + } + if (nextAddedBannerState.isFocused !== isFocused) { + nextAddedBannerState = { + ...nextAddedBannerState, + email: isFocused ? nextAddedBannerState.email : '', + isFocused, + phone: isFocused ? nextAddedBannerState.phone : false, + } + } + const addedEmailRow = nextAddedBannerState.email ? emails.get(nextAddedBannerState.email) : undefined + if (nextAddedBannerState.email && (!addedEmailRow || addedEmailRow.isVerified)) { + nextAddedBannerState = {...nextAddedBannerState, email: ''} + } + if (nextAddedBannerState !== addedBannerState) { + setAddedBannerState(nextAddedBannerState) + } + const addedEmail = nextAddedBannerState.email + const addedPhone = nextAddedBannerState.phone React.useEffect(() => { if (!addedEmailFromRoute) { return } - setAddedEmail(addedEmailFromRoute) navigation.setParams({addedEmailBannerEmail: undefined}) }, [addedEmailFromRoute, navigation]) React.useEffect(() => { if (!addedPhoneFromRoute) { return } - setAddedPhone(true) navigation.setParams({addedPhoneBanner: undefined}) }, [addedPhoneFromRoute, navigation]) - React.useEffect(() => { - if (isFocused) { - return - } - setAddedEmail('') - setAddedPhone(false) - }, [isFocused]) - React.useEffect(() => { - if (!addedEmail) { - return - } - const addedEmailRow = emails.get(addedEmail) - if (!addedEmailRow || addedEmailRow.isVerified) { - setAddedEmail('') - } - }, [addedEmail, emails]) - const onClearAddedEmail = () => setAddedEmail('') - const onClearAddedPhone = () => setAddedPhone(false) + const onEmailVerificationSuccess = (email: string) => setAddedBannerState(s => ({...s, email})) + const onClearAddedEmail = () => setAddedBannerState(s => ({...s, email: ''})) + const onClearAddedPhone = () => setAddedBannerState(s => ({...s, phone: false})) const onReload = () => { loadSettings() reloadRandomPW() @@ -240,7 +268,7 @@ const AccountSettings = ({route}: Props) => { const onStartPhoneConversation = () => { switchTab(C.Tabs.chatTab) navigateAppend({name: 'chatNewChat', params: {namespace: 'chat'}}) - setAddedPhone(false) + setAddedBannerState(s => ({...s, phone: false})) } const _supersededPhoneNumber = phones && [...phones.values()].find(p => p.superseded) const supersededKey = _supersededPhoneNumber?.e164 @@ -288,7 +316,7 @@ const AccountSettings = ({route}: Props) => { )} - + diff --git a/shared/settings/chat.tsx b/shared/settings/chat.tsx index 73b37bdf39d5..550b8c52cead 100644 --- a/shared/settings/chat.tsx +++ b/shared/settings/chat.tsx @@ -190,15 +190,11 @@ const Security = ({allowEdit, groups, refresh, toggle}: NotificationSettingsStat lastContactSettingsTeamsEnabled.current = _contactSettingsTeamsEnabled }, [_contactSettingsTeamsEnabled, contactSettingsTeamsEnabled]) - React.useEffect(() => { - // Create an initial copy of teams data into state, so it can be mutated there. - if ( - Object.keys(_contactSettingsSelectedTeams).length > 0 && - Object.keys(contactSettingsSelectedTeams).length === 0 - ) { - setContactSettingsSelectedTeams(_contactSettingsSelectedTeams) - } - }, [_contactSettingsSelectedTeams, contactSettingsSelectedTeams]) + const hasInitialSelectedTeams = Object.keys(_contactSettingsSelectedTeams).length > 0 + const hasLocalSelectedTeams = Object.keys(contactSettingsSelectedTeams).length > 0 + if (hasInitialSelectedTeams && !hasLocalSelectedTeams) { + setContactSettingsSelectedTeams(_contactSettingsSelectedTeams) + } React.useEffect(() => { loadSettings() diff --git a/shared/settings/files/hooks.tsx b/shared/settings/files/hooks.tsx index ee3a52e10932..3b76fac69301 100644 --- a/shared/settings/files/hooks.tsx +++ b/shared/settings/files/hooks.tsx @@ -17,8 +17,10 @@ const useFiles = () => { spaceAvailableNotificationThreshold: 0, syncOnCellular: false, })) - const loadSettings = React.useEffectEvent(async () => { - setSettings(s => ({...s, isLoading: true})) + const loadSettings = React.useEffectEvent(async (showLoading: boolean) => { + if (showLoading) { + setSettings(s => ({...s, isLoading: true})) + } try { const next = await T.RPCGen.SimpleFSSimpleFSSettingsRpcPromise() setSettings({ @@ -35,13 +37,13 @@ const useFiles = () => { }) React.useEffect(() => { - C.ignorePromise(loadSettings()) + C.ignorePromise(loadSettings(false)) }, []) useEngineActionListener('keybase.1.NotifyFS.FSSubscriptionNotify', action => { const {clientID, topic} = action.payload.params if (clientID === fsClientID && topic === T.RPCGen.SubscriptionTopic.settings) { - C.ignorePromise(loadSettings()) + C.ignorePromise(loadSettings(true)) } }) @@ -50,7 +52,7 @@ const useFiles = () => { setSettings(s => ({...s, isLoading: true})) try { await T.RPCGen.SimpleFSSimpleFSSetNotificationThresholdRpcPromise({threshold}) - await loadSettings() + await loadSettings(true) refreshGlobalSettings() } catch { setSettings(s => ({...s, isLoading: false})) @@ -65,7 +67,7 @@ const useFiles = () => { {syncOnCellular}, C.waitingKeyFSSetSyncOnCellular ) - await loadSettings() + await loadSettings(true) refreshGlobalSettings() } catch {} } diff --git a/shared/settings/proxy.tsx b/shared/settings/proxy.tsx index ad81be7a7d96..4a088b861c10 100644 --- a/shared/settings/proxy.tsx +++ b/shared/settings/proxy.tsx @@ -63,6 +63,23 @@ const proxyTypeToDisplayName = { noProxy: 'No proxy', socks: 'SOCKS5', } +type ProxyType = (typeof proxyTypeList)[number] +type ProxyFormState = { + address: string + port: string + proxyData?: T.RPCGen.ProxyData + proxyType: ProxyType +} + +const proxyDataToFormState = (proxyData: T.RPCGen.ProxyData): ProxyFormState => { + const addressPort = proxyData.addressWithPort.split(':') + return { + address: addressPort.slice(0, addressPort.length - 1).join(':'), + port: addressPort.length >= 2 ? (addressPort.at(-1) ?? '') : '8080', + proxyData, + proxyType: T.RPCGen.ProxyType[proxyData.proxyType] as ProxyType, + } +} type Props = { allowTlsMitmToggle?: boolean @@ -88,43 +105,27 @@ type Props = { const ProxySettingsComponent = (props: Props) => { const {loadProxyData, proxyData, setProxyData} = props - const [address, setAddress] = React.useState('') - const [port, setPort] = React.useState('') - const [proxyType, setProxyType] = React.useState<'noProxy' | 'httpConnect' | 'socks'>('noProxy') - - const applyProxyData = React.useCallback((proxyData_: T.RPCGen.ProxyData) => { - const addressPort = proxyData_.addressWithPort.split(':') - const newAddress = addressPort.slice(0, addressPort.length - 1).join(':') - const newPort = addressPort.length >= 2 ? (addressPort.at(-1) ?? '') : '8080' - const newProxyType = T.RPCGen.ProxyType[proxyData_.proxyType] as typeof proxyType - - setAddress(newAddress) - setPort(newPort) - setProxyType(newProxyType) - }, []) + const [proxyForm, setProxyForm] = React.useState(() => + proxyData ? proxyDataToFormState(proxyData) : {address: '', port: '', proxyType: 'noProxy'} + ) + let nextProxyForm = proxyForm + if (proxyData && nextProxyForm.proxyData !== proxyData) { + nextProxyForm = proxyDataToFormState(proxyData) + setProxyForm(nextProxyForm) + } + const {address, port, proxyType} = nextProxyForm React.useEffect(() => { loadProxyData( [undefined], result => { setProxyData(result) - applyProxyData(result) }, error => { logger.warn('Error loading proxy data', error) } ) - }, [applyProxyData, loadProxyData, setProxyData]) - - const lastProxyDataRef = React.useRef(proxyData) - React.useEffect(() => { - if (lastProxyDataRef.current !== proxyData) { - if (proxyData) { - applyProxyData(proxyData) - } - } - lastProxyDataRef.current = proxyData - }, [applyProxyData, proxyData]) + }, [loadProxyData, setProxyData]) const certPinning = (): boolean => { if (props.allowTlsMitmToggle === undefined) { @@ -159,8 +160,8 @@ const ProxySettingsComponent = (props: Props) => { ) } - const proxyTypeSelected = (newProxyType: typeof proxyType) => { - setProxyType(newProxyType) + const proxyTypeSelected = (newProxyType: ProxyType) => { + setProxyForm(s => ({...s, proxyType: newProxyType})) if (newProxyType === 'noProxy') { saveProxySettings(newProxyType) } @@ -209,9 +210,17 @@ const ProxySettingsComponent = (props: Props) => { {proxyType === 'noProxy' ? null : ( <> Proxy Address - + setProxyForm(s => ({...s, address}))} + value={address} + /> Proxy Port - + setProxyForm(s => ({...s, port}))} + value={port} + /> )} { } const EnterDevicename = (props: EnterDevicenameProps) => { - const [deviceName, setDeviceName] = React.useState(props.initialDevicename || '') + const [deviceName, setDeviceName] = React.useState(() => + makeCleanDeviceName(props.initialDevicename || '') + ) const [readyToShowError, setReadyToShowError] = React.useState(false) const _setReadyToShowError = C.useDebouncedCallback((ready: boolean) => { setReadyToShowError(ready) @@ -128,18 +130,12 @@ const EnterDevicename = (props: EnterDevicenameProps) => { Provision.badDeviceRE.test(cleanDeviceName) const showDisabled = disabled && !!cleanDeviceName && readyToShowError const _setDeviceName = (deviceName: string) => { - setDeviceName(deviceName) + setDeviceName(makeCleanDeviceName(deviceName)) setReadyToShowError(false) _setReadyToShowError(true) } const onContinue = () => (disabled || props.waiting ? {} : props.onContinue(cleanDeviceName)) - React.useEffect(() => { - if (cleanDeviceName !== deviceName) { - setDeviceName(cleanDeviceName) - } - }, [deviceName, cleanDeviceName]) - return ( { const navigation = useNavigation>() - const [wizard, setWizard] = React.useState(initialWizard) - React.useEffect(() => { - setWizard(initialWizard) - }, [initialWizard]) + const [wizardState, setWizardState] = React.useState(() => ({ + initialWizard, + wizard: initialWizard, + })) + let wizard = wizardState.wizard + if (wizardState.initialWizard !== initialWizard) { + wizard = initialWizard + setWizardState({initialWizard, wizard: initialWizard}) + } const {teamID, addingMembers, addToChannels, membersAlreadyInTeam} = wizard const fromNewTeamWizard = teamID === T.Teams.newTeamWizardTeamID const newTeamWizard = wizard.newTeamWizard @@ -55,10 +60,10 @@ const AddMembersConfirm = ({wizard: initialWizard}: Props) => { const isInTeam = teamMeta.role !== 'none' const updateWizard = React.useCallback( (nextWizard: AddMembersWizard) => { - setWizard(nextWizard) + setWizardState({initialWizard, wizard: nextWizard}) navigation.setParams({wizard: nextWizard}) }, - [navigation] + [initialWizard, navigation] ) const isSubteam = fromNewTeamWizard ? newTeamWizard?.teamType === 'subteam' : teamMeta.teamname.includes('.') const isBigTeam = Chat.useChatState(s => (fromNewTeamWizard ? false : getIsBigTeam(s.inboxLayout, teamID))) @@ -296,12 +301,7 @@ type RoleSelectorProps = { const RoleSelector = ({disabledRoles, memberCount, updateWizard, wizard}: RoleSelectorProps) => { const [showingMenu, setShowingMenu] = React.useState(false) const storeRole = wizard.role - const [role, setRole] = React.useState(storeRole) - React.useEffect(() => { - setRole(storeRole) - }, [storeRole]) const onConfirmRole = (newRole: RoleType) => { - setRole(newRole) setShowingMenu(false) updateWizard(setWizardRole(wizard, newRole)) } @@ -311,7 +311,7 @@ const RoleSelector = ({disabledRoles, memberCount, updateWizard, wizard}: RoleSe open={showingMenu} presetRole={storeRole} - onCancel={storeRole === role ? () => setShowingMenu(false) : undefined} + onCancel={() => setShowingMenu(false)} onConfirm={onConfirmRole} includeSetIndividually={!Kb.Styles.isPhone && (memberCount > 1 || storeRole === 'setIndividually')} disabledRoles={disabledRoles} diff --git a/shared/teams/channel/create-channels.tsx b/shared/teams/channel/create-channels.tsx index 212c85a01d2f..5492309022b8 100644 --- a/shared/teams/channel/create-channels.tsx +++ b/shared/teams/channel/create-channels.tsx @@ -9,6 +9,10 @@ import {useLoadedTeam} from '../team/use-loaded-team' type Props = {teamID: T.Teams.TeamID} const CreateChannels = (props: Props) => { + return +} + +const CreateChannelsInner = (props: Props) => { const teamID = props.teamID const { teamMeta: {teamname}, @@ -17,12 +21,6 @@ const CreateChannels = (props: Props) => { const [error, setError] = React.useState('') const [success, setSuccess] = React.useState(false) - React.useEffect(() => { - setError('') - setSuccess(false) - setWaiting(false) - }, [teamID]) - const banners = error ? ( {error} diff --git a/shared/teams/channel/header.tsx b/shared/teams/channel/header.tsx index 65bb355e46fb..59758f5fd830 100644 --- a/shared/teams/channel/header.tsx +++ b/shared/teams/channel/header.tsx @@ -9,17 +9,28 @@ import {useLoadedTeam} from '../team/use-loaded-team' import {useSafeNavigation} from '@/util/safe-navigation' const useRecentJoins = (conversationIDKey: T.Chat.ConversationIDKey) => { - const [recentJoins, setRecentJoins] = React.useState(undefined) + const [loadedRecentJoins, setLoadedRecentJoins] = React.useState< + {conversationIDKey: T.Chat.ConversationIDKey; recentJoins: number} | undefined + >(undefined) const getRecentJoinsRPC = C.useRPC(T.RPCChat.localGetRecentJoinsLocalRpcPromise) React.useEffect(() => { - setRecentJoins(undefined) + let canceled = false getRecentJoinsRPC( [{convID: T.Chat.keyToConversationID(conversationIDKey)}], - r => setRecentJoins(r), + recentJoins => { + if (!canceled) { + setLoadedRecentJoins({conversationIDKey, recentJoins}) + } + }, () => {} ) - }, [conversationIDKey, getRecentJoinsRPC, setRecentJoins]) - return recentJoins + return () => { + canceled = true + } + }, [conversationIDKey, getRecentJoinsRPC]) + return loadedRecentJoins?.conversationIDKey === conversationIDKey + ? loadedRecentJoins.recentJoins + : undefined } type HeaderTitleProps = { diff --git a/shared/teams/common/enable-contacts.tsx b/shared/teams/common/enable-contacts.tsx index 062fd599935a..fb96a0262602 100644 --- a/shared/teams/common/enable-contacts.tsx +++ b/shared/teams/common/enable-contacts.tsx @@ -11,12 +11,18 @@ import {openAppSettings} from '@/util/storeless-actions' * popup. */ const EnableContactsPopup = ({noAccess, onClose}: {noAccess: boolean; onClose: () => void}) => { - const [showingPopup, setShowingPopup] = React.useState(noAccess) - React.useEffect(() => { - setShowingPopup(noAccess) - }, [noAccess]) + const [dismissState, setDismissState] = React.useState(() => ({ + dismissed: false, + noAccess, + })) + let dismissed = dismissState.dismissed + if (dismissState.noAccess !== noAccess) { + dismissed = false + setDismissState({dismissed: false, noAccess}) + } + const showingPopup = noAccess && !dismissed const onClosePopup = () => { - setShowingPopup(false) + setDismissState({dismissed: true, noAccess}) onClose() } diff --git a/shared/teams/common/use-contacts.native.tsx b/shared/teams/common/use-contacts.native.tsx index 0b74fa763798..3a4f05e08abc 100644 --- a/shared/teams/common/use-contacts.native.tsx +++ b/shared/teams/common/use-contacts.native.tsx @@ -78,41 +78,42 @@ const fetchContacts = async (regionFromState: string): Promise<[Array, return [mapped, region] } +type ContactsLoadState = + | {contacts: Array; errorMessage?: undefined; key: string; region: string} + | {contacts?: undefined; errorMessage: string; key: string; region?: undefined} + const useContacts = () => { - const [contacts, setContacts] = React.useState>([]) - const [region, setRegion] = React.useState('') - const [errorMessage, setErrorMessage] = React.useState() - const [noAccessPermanent, setNoAccessPermanent] = React.useState(false) - const [loading, setLoading] = React.useState(true) + const [loadState, setLoadState] = React.useState() const permStatus = useSettingsContactsState(s => s.permissionStatus) const savedRegion = useSettingsContactsState(s => s.userCountryCode) + const contactsKey = permStatus === 'granted' ? savedRegion || '' : undefined React.useEffect(() => { - if (permStatus === 'granted') { - setNoAccessPermanent(false) - fetchContacts(savedRegion || '') - .then( - ([contacts, region]) => { - setContacts(contacts) - setRegion(region) - setErrorMessage(undefined) - setLoading(false) - }, - (_err: unknown) => { - const err = _err as {message: string} - logger.warn('Error fetching contacts:', err) - setErrorMessage(err.message) - setLoading(false) + if (contactsKey === undefined) { + return + } + let canceled = false + fetchContacts(contactsKey) + .then( + ([contacts, region]) => { + if (!canceled) { + setLoadState({contacts, key: contactsKey, region}) + } + }, + (_err: unknown) => { + const err = _err as {message: string} + logger.warn('Error fetching contacts:', err) + if (!canceled) { + setLoadState({errorMessage: err.message, key: contactsKey}) } - ) - .catch(() => {}) - } else if (permStatus === 'denied') { - setErrorMessage('Keybase does not have permission to access your contacts.') - setNoAccessPermanent(true) - setLoading(false) + } + ) + .catch(() => {}) + return () => { + canceled = true } - }, [setErrorMessage, setContacts, permStatus, savedRegion]) + }, [contactsKey]) const requestPermissions = useSettingsContactsState(s => s.dispatch.requestPermissions) React.useEffect(() => { @@ -120,11 +121,19 @@ const useContacts = () => { // whether to dispatch `createRequestContactPermissions` so we never // dispatch more than once. if (permStatus === 'unknown' || permStatus === 'undetermined') { - setNoAccessPermanent(false) requestPermissions(false) } }, [requestPermissions, permStatus]) + const visibleLoadState = loadState?.key === contactsKey ? loadState : undefined + const noAccessPermanent = permStatus === 'denied' + const errorMessage = noAccessPermanent + ? 'Keybase does not have permission to access your contacts.' + : visibleLoadState?.errorMessage + const loading = permStatus === 'granted' ? !visibleLoadState : !noAccessPermanent + const contacts = visibleLoadState?.contacts ?? [] + const region = visibleLoadState?.region ?? '' + return {contacts, errorMessage, loading, noAccessPermanent, region} } diff --git a/shared/teams/emojis/add-alias.tsx b/shared/teams/emojis/add-alias.tsx index 716ff82a012b..38718a7b17f9 100644 --- a/shared/teams/emojis/add-alias.tsx +++ b/shared/teams/emojis/add-alias.tsx @@ -21,32 +21,63 @@ type ChosenEmoji = { renderableEmoji: RenderableEmoji } +type EmojiSelection = { + alias: string + defaultSelectedKey: string + emoji?: ChosenEmoji +} + +const aliasFromEmojiStr = (emojiStr: string) => + emojiStr + // first merge skin-tone part into name, e.g. + // ":+1::skin-tone-1:" into ":+1-skin-tone-1:" + .replace(/::/g, '-') + // then strip colons. + .replace(/:/g, '') + +const selectionFromDefault = (defaultSelected?: EmojiData): EmojiSelection => { + if (!defaultSelected) { + return {alias: '', defaultSelectedKey: ''} + } + const emojiStr = getEmojiStr(defaultSelected) + return { + alias: aliasFromEmojiStr(emojiStr), + defaultSelectedKey: emojiStr, + emoji: {emojiStr, renderableEmoji: emojiDataToRenderableEmoji(defaultSelected)}, + } +} + const AddAliasModal = (props: Props) => { const {defaultSelected} = props - const [emoji, setEmoji] = React.useState(undefined) - const [alias, setAlias] = React.useState('') + const defaultSelectedKey = defaultSelected ? getEmojiStr(defaultSelected) : '' + const [selection, setSelection] = React.useState(() => selectionFromDefault(defaultSelected)) + let currentSelection = selection + if (defaultSelected && selection.defaultSelectedKey !== defaultSelectedKey) { + currentSelection = selectionFromDefault(defaultSelected) + setSelection(currentSelection) + } + const {alias, emoji} = currentSelection const [error, setError] = React.useState(undefined) const conversationIDKey = ConvoState.useChatContext(s => s.id) const aliasInputRef = React.useRef(null) const onChoose = (emojiStr: string, renderableEmoji: RenderableEmoji) => { - setEmoji({emojiStr, renderableEmoji}) - setAlias( - emojiStr - // first merge skin-tone part into name, e.g. - // ":+1::skin-tone-1:" into ":+1-skin-tone-1:" - .replace(/::/g, '-') - // then strip colons. - .replace(/:/g, '') - ) + setSelection(selected => ({ + ...selected, + alias: aliasFromEmojiStr(emojiStr), + emoji: {emojiStr, renderableEmoji}, + })) aliasInputRef.current?.focus() } + const onChangeAlias = (alias: string) => { + setSelection(selected => ({...selected, alias})) + } - React.useEffect( - () => - defaultSelected && onChoose(getEmojiStr(defaultSelected), emojiDataToRenderableEmoji(defaultSelected)), - [defaultSelected] - ) + React.useEffect(() => { + if (defaultSelected) { + aliasInputRef.current?.focus() + } + }, [defaultSelected]) const addAliasRpc = C.useRPC(T.RPCChat.localAddEmojiAliasRpcPromise) const [addAliasWaiting, setAddAliasWaiting] = React.useState(false) @@ -110,7 +141,7 @@ const AddAliasModal = (props: Props) => { error={error} disabled={!emoji} alias={alias} - onChangeAlias={setAlias} + onChangeAlias={onChangeAlias} onEnterKeyDown={doAddAlias} small={false} /> @@ -127,20 +158,22 @@ type ChooseEmojiProps = { const ChooseEmoji = Kb.Styles.isMobile ? (props: ChooseEmojiProps) => { const pickKey = 'addAlias' - const {emojiStr, renderableEmoji} = usePickerState(s => s.pickerMap.get(pickKey)) ?? { - emojiStr: '', - renderableEmoji: {}, - } + const pickedEmoji = usePickerState(s => s.pickerMap.get(pickKey)) const updatePickerMap = usePickerState(s => s.dispatch.updatePickerMap) + const onChoose = React.useEffectEvent(props.onChoose) - const [lastEmoji, setLastEmoji] = React.useState('') - if (lastEmoji !== emojiStr) { - setTimeout(() => { - setLastEmoji(emojiStr) - emojiStr && props.onChoose(emojiStr, renderableEmoji) + const lastEmojiRef = React.useRef('') + React.useEffect(() => { + const emojiStr = pickedEmoji?.emojiStr ?? '' + if (lastEmojiRef.current === emojiStr) { + return + } + lastEmojiRef.current = emojiStr + if (emojiStr) { + onChoose(emojiStr, pickedEmoji?.renderableEmoji ?? {}) updatePickerMap(pickKey, undefined) - }, 1) - } + } + }, [pickedEmoji, updatePickerMap]) const navigateAppend = C.Router2.navigateAppend const conversationIDKey = ConvoState.useChatContext(s => s.id) diff --git a/shared/teams/external-team.tsx b/shared/teams/external-team.tsx index 872fee43828a..17db52b000c3 100644 --- a/shared/teams/external-team.tsx +++ b/shared/teams/external-team.tsx @@ -10,33 +10,42 @@ import {useSafeNavigation} from '@/util/safe-navigation' import {navToProfile} from '@/constants/router' type Props = {teamname: string} +type TeamInfoResult = {teamname: string; info?: T.RPCGen.UntrustedTeamInfo} const ExternalTeam = (props: Props) => { const teamname = props.teamname const getTeamInfo = C.useRPC(T.RPCGen.teamsGetUntrustedTeamInfoRpcPromise) - const [teamInfo, setTeamInfo] = React.useState() - const [waiting, setWaiting] = React.useState(false) + const [teamInfoResult, setTeamInfoResult] = React.useState() + const requestIDRef = React.useRef(0) React.useEffect(() => { - setWaiting(true) + requestIDRef.current += 1 + const requestID = requestIDRef.current getTeamInfo( [{teamName: {parts: teamname.split('.')}}], // TODO this should just take a string result => { - // Note: set all state variables in both of these cases even if they're - // not changing from defaults. The user might be stacking these pages on - // top of one another, in which case react will preserve state from - // previously rendered teams. - setWaiting(false) - setTeamInfo(result) + if (requestIDRef.current === requestID) { + setTeamInfoResult({info: result, teamname}) + } }, _ => { - setWaiting(false) - setTeamInfo(undefined) + if (requestIDRef.current === requestID) { + setTeamInfoResult({teamname}) + } } ) + return () => { + if (requestIDRef.current === requestID) { + requestIDRef.current += 1 + } + } }, [getTeamInfo, teamname]) + const visibleTeamInfoResult = teamInfoResult?.teamname === teamname ? teamInfoResult : undefined + const teamInfo = visibleTeamInfoResult?.info + const waiting = !visibleTeamInfoResult + if (teamInfo) { return ( diff --git a/shared/teams/join-team/container.tsx b/shared/teams/join-team/container.tsx index 2d98d5224c13..9e977f16b95d 100644 --- a/shared/teams/join-team/container.tsx +++ b/shared/teams/join-team/container.tsx @@ -25,7 +25,9 @@ const getJoinTeamError = (error: unknown) => { return error instanceof Error ? error.message : 'Something went wrong.' } -const Container = ({initialTeamname, success: successParam}: OwnProps) => { +const Container = (props: OwnProps) => + +const ContainerInner = ({initialTeamname, success: successParam}: OwnProps) => { const [errorText, setErrorText] = React.useState('') const [open, setOpen] = React.useState(false) const [successTeamName, setSuccessTeamName] = React.useState('') @@ -41,13 +43,6 @@ const Container = ({initialTeamname, success: successParam}: OwnProps) => { const setName = (n: string) => _setName(n.toLowerCase()) const onBack = () => navigateUp() - React.useEffect(() => { - _setName(initialTeamname ?? '') - setErrorText('') - setOpen(false) - setSuccessTeamName('') - }, [initialTeamname]) - const onSubmit = () => { setErrorText('') setOpen(false) diff --git a/shared/teams/join-team/join-from-invite.tsx b/shared/teams/join-team/join-from-invite.tsx index a24ce3593b00..b48ab3bdf5e0 100644 --- a/shared/teams/join-team/join-from-invite.tsx +++ b/shared/teams/join-team/join-from-invite.tsx @@ -27,7 +27,14 @@ const getInviteError = (error: unknown, missingKey: boolean) => { return error instanceof Error ? error.message : 'Something went wrong.' } -const JoinFromInvite = ({inviteDetails: initialInviteDetails, inviteID = '', inviteKey = ''}: Props) => { +const JoinFromInvite = (props: Props) => ( + +) + +const JoinFromInviteInner = ({inviteDetails: initialInviteDetails, inviteID = '', inviteKey = ''}: Props) => { const [details, setDetails] = React.useState(initialInviteDetails) const [error, setError] = React.useState('') const loaded = details !== undefined || !!error @@ -41,13 +48,6 @@ const JoinFromInvite = ({inviteDetails: initialInviteDetails, inviteID = '', inv const rpcWaiting = C.Waiting.useAnyWaiting(C.waitingKeyTeamsJoinTeam) const waiting = rpcWaiting && clickedJoin - React.useEffect(() => { - setDetails(initialInviteDetails) - setError('') - setClickedJoin(false) - setShowSuccess(false) - }, [initialInviteDetails, inviteID, inviteKey]) - React.useEffect(() => { if (!canLoadDetails) { return diff --git a/shared/teams/new-team/wizard/new-team-info.tsx b/shared/teams/new-team/wizard/new-team-info.tsx index 4e7999ad919b..4b95992c981b 100644 --- a/shared/teams/new-team/wizard/new-team-info.tsx +++ b/shared/teams/new-team/wizard/new-team-info.tsx @@ -25,6 +25,7 @@ const getTeamTakenMessage = (status: T.RPCGen.StatusCode): string => { } const cannotJoinAsOwner = {admin: `Users can't join open teams as admins`} +type TeamNameTakenResult = {exists: boolean; status: T.RPCGen.StatusCode; teamname: string} type Props = { wizard: NewTeamWizard @@ -51,35 +52,30 @@ const NewTeamInfo = ({wizard: teamWizardState}: Props) => { ) const teamname = parentName ? `${parentName}.${name}` : name const setName = (newName: string) => _setName(newName.replace(/[^a-zA-Z0-9_]/, '')) - const [teamNameTakenStatus, setTeamNameTakenStatus] = React.useState( - T.RPCGen.StatusCode.scok - ) - const [teamNameTaken, setTeamNameTaken] = React.useState(false) + const [teamNameTakenResult, setTeamNameTakenResult] = React.useState() // TODO this should check subteams too (ideally in go) // Also it shouldn't leak the names of subteams people make to the server const checkTeam = C.useDebouncedCallback(C.useRPC(T.RPCGen.teamsUntrustedTeamExistsRpcPromise), 100) + const canCheckTeamName = !waitingOnParentTeam && name.length >= minLength React.useEffect(() => { - if (waitingOnParentTeam) { - setTeamNameTaken(false) - setTeamNameTakenStatus(0) + if (!canCheckTeamName) { return } - if (name.length >= minLength) { - checkTeam( - [{teamName: {parts: teamname.split('.')}}], - ({exists, status}) => { - setTeamNameTaken(exists) - setTeamNameTakenStatus(status) - }, - () => {} // TODO: handle errors? - ) - } else { - setTeamNameTaken(false) - setTeamNameTakenStatus(0) - } - }, [teamname, name.length, checkTeam, minLength, waitingOnParentTeam]) + checkTeam( + [{teamName: {parts: teamname.split('.')}}], + ({exists, status}) => { + setTeamNameTakenResult({exists, status, teamname}) + }, + () => {} // TODO: handle errors? + ) + }, [teamname, checkTeam, canCheckTeamName]) + + const visibleTeamNameTakenResult = + canCheckTeamName && teamNameTakenResult?.teamname === teamname ? teamNameTakenResult : undefined + const teamNameTaken = visibleTeamNameTakenResult?.exists ?? false + const teamNameTakenStatus = visibleTeamNameTakenResult?.status ?? T.RPCGen.StatusCode.scok const [description, setDescription] = React.useState(teamWizardState.description) const [openTeam, _setOpenTeam] = React.useState( diff --git a/shared/teams/role-picker.tsx b/shared/teams/role-picker.tsx index ee28c1eec7ae..8040c27717c4 100644 --- a/shared/teams/role-picker.tsx +++ b/shared/teams/role-picker.tsx @@ -262,13 +262,13 @@ const Header = () => ( const RolePicker = (props: Props) => { const {presetRole} = props const filteredRole = filterRole(presetRole) - const [selectedRole, setSelectedRole] = React.useState( - filteredRole ?? ('reader' as Role) - ) - React.useEffect(() => { - const newRole = filterRole(presetRole) ?? ('reader' as Role) - setSelectedRole(newRole) - }, [presetRole]) + const presetSelectedRole = filteredRole ?? ('reader' as Role) + const [selectedRole, setSelectedRole] = React.useState(presetSelectedRole) + const [previousPresetRole, setPreviousPresetRole] = React.useState(presetRole) + if (previousPresetRole !== presetRole) { + setPreviousPresetRole(presetRole) + setSelectedRole(presetSelectedRole) + } // as because convincing TS that filtering this makes it a different type is hard const roles = orderedRoles.filter(r => props.includeSetIndividually || r !== 'setIndividually') as Array< diff --git a/shared/teams/team/index.tsx b/shared/teams/team/index.tsx index 8b3174085795..4b0ad5fb8061 100644 --- a/shared/teams/team/index.tsx +++ b/shared/teams/team/index.tsx @@ -91,19 +91,38 @@ const TeamBody = (props: Props) => { const initialTab = props.initialTab const navigation = useNavigation() const [selectedTab, setSelectedTab] = useTabsState(teamID, initialTab) - const [invitesCollapsed, setInvitesCollapsed] = React.useState(false) - const [subteamFilter, setSubteamFilter] = React.useState('') + const [teamLocalState, setTeamLocalState] = React.useState({ + invitesCollapsed: false, + subteamFilter: '', + teamID, + }) + if (teamLocalState.teamID !== teamID) { + setTeamLocalState({invitesCollapsed: false, subteamFilter: '', teamID}) + } + const invitesCollapsed = teamLocalState.invitesCollapsed + const subteamFilter = teamLocalState.subteamFilter + const setInvitesCollapsed: React.Dispatch> = nextInvitesCollapsed => { + setTeamLocalState(prev => ({ + ...prev, + invitesCollapsed: + typeof nextInvitesCollapsed === 'function' + ? nextInvitesCollapsed(prev.invitesCollapsed) + : nextInvitesCollapsed, + })) + } + const setSubteamFilter: React.Dispatch> = nextSubteamFilter => { + setTeamLocalState(prev => ({ + ...prev, + subteamFilter: + typeof nextSubteamFilter === 'function' ? nextSubteamFilter(prev.subteamFilter) : nextSubteamFilter, + })) + } const clearJustFinishedAddWizard = React.useCallback(() => { navigation.setParams({justFinishedAddWizard: undefined}) }, [navigation]) const {loading: loadingTeam, teamDetails, teamMeta, yourOperations} = useLoadedTeam(teamID) - React.useEffect(() => { - setInvitesCollapsed(false) - setSubteamFilter('') - }, [teamID]) - C.Router2.useSafeFocusEffect(() => { return () => teamSeen(teamID) }) diff --git a/shared/teams/team/member/index.new.tsx b/shared/teams/team/member/index.new.tsx index 6cc5beae0644..a5cf3b2c75d1 100644 --- a/shared/teams/team/member/index.new.tsx +++ b/shared/teams/team/member/index.new.tsx @@ -46,14 +46,27 @@ type TeamTreeMembershipState = { lastActivity: Map memberships: Array sparseMemberInfos: Map + targetTeamID: T.Teams.TeamID + username: string } -const makeEmptyTeamTreeMembershipState = (): TeamTreeMembershipState => ({ +const makeEmptyTeamTreeMembershipState = ( + targetTeamID: T.Teams.TeamID, + username: string +): TeamTreeMembershipState => ({ lastActivity: new Map(), memberships: [], sparseMemberInfos: new Map(), + targetTeamID, + username, }) +const matchesTeamTreeMembershipState = ( + state: TeamTreeMembershipState, + targetTeamID: T.Teams.TeamID, + username: string +) => state.targetTeamID === targetTeamID && state.username === username + const consumeTeamTreeMembershipValue = ( value: T.RPCGen.TeamTreeMembershipValue ): T.Teams.TreeloaderSparseMemberInfo => ({ @@ -70,7 +83,7 @@ const useTeamTreeMemberships = (targetTeamID: T.Teams.TeamID, username: string) const loadTeamTreeMemberships = C.useRPC(T.RPCGen.teamsLoadTeamTreeMembershipsAsyncRpcPromise) const {teams} = useTeamsList() const teamMetas = new Map(teams.map(team => [team.id, team] as const)) - const [state, setState] = React.useState(makeEmptyTeamTreeMembershipState) + const [state, setState] = React.useState(() => makeEmptyTeamTreeMembershipState(targetTeamID, username)) const hasFocusedSinceMountRef = React.useRef(false) const loadLastActivity = React.useEffectEvent((teamID: T.Teams.TeamID) => { @@ -81,6 +94,9 @@ const useTeamTreeMemberships = (targetTeamID: T.Teams.TeamID, username: string) ) .then(activityMap => { setState(prev => { + if (!matchesTeamTreeMembershipState(prev, targetTeamID, username)) { + return prev + } const nextLastActivity = new Map(prev.lastActivity) Object.entries(activityMap ?? {}).forEach(([activityTeamID, lastActivity]) => { nextLastActivity.set(activityTeamID, lastActivity) @@ -94,8 +110,7 @@ const useTeamTreeMemberships = (targetTeamID: T.Teams.TeamID, username: string) ) }) - const reload = React.useCallback(() => { - setState(makeEmptyTeamTreeMembershipState()) + const load = React.useCallback(() => { loadTeamTreeMemberships( [{teamID: targetTeamID, username}], () => {}, @@ -105,9 +120,14 @@ const useTeamTreeMemberships = (targetTeamID: T.Teams.TeamID, username: string) ) }, [loadTeamTreeMemberships, targetTeamID, username]) + const reload = React.useCallback(() => { + setState(makeEmptyTeamTreeMembershipState(targetTeamID, username)) + load() + }, [load, targetTeamID, username]) + React.useEffect(() => { - reload() - }, [reload]) + load() + }, [load]) C.Router2.useSafeFocusEffect( React.useCallback(() => { @@ -125,17 +145,20 @@ const useTeamTreeMemberships = (targetTeamID: T.Teams.TeamID, username: string) return } setState(prev => { - if (prev.guid !== undefined && result.guid < prev.guid) { - return prev + const base = matchesTeamTreeMembershipState(prev, targetTeamID, username) + ? prev + : makeEmptyTeamTreeMembershipState(targetTeamID, username) + if (base.guid !== undefined && result.guid < base.guid) { + return base } - if (prev.guid === undefined || result.guid > prev.guid) { + if (base.guid === undefined || result.guid > base.guid) { return { - ...makeEmptyTeamTreeMembershipState(), + ...makeEmptyTeamTreeMembershipState(targetTeamID, username), expectedCount: result.expectedCount, guid: result.guid, } } - return {...prev, expectedCount: result.expectedCount} + return {...base, expectedCount: result.expectedCount} }) }) @@ -145,15 +168,18 @@ const useTeamTreeMemberships = (targetTeamID: T.Teams.TeamID, username: string) return } setState(prev => { - if (prev.guid !== undefined && membership.guid < prev.guid) { - return prev + const base = matchesTeamTreeMembershipState(prev, targetTeamID, username) + ? prev + : makeEmptyTeamTreeMembershipState(targetTeamID, username) + if (base.guid !== undefined && membership.guid < base.guid) { + return base } const nextMemberships = - prev.guid === undefined || membership.guid > prev.guid + base.guid === undefined || membership.guid > base.guid ? [membership] - : [...prev.memberships, membership] + : [...base.memberships, membership] const nextSparseMemberInfos = - prev.guid === undefined || membership.guid > prev.guid ? new Map() : new Map(prev.sparseMemberInfos) + base.guid === undefined || membership.guid > base.guid ? new Map() : new Map(base.sparseMemberInfos) if (membership.result.s === T.RPCGen.TeamTreeMembershipStatus.ok) { nextSparseMemberInfos.set( membership.result.ok.teamID, @@ -161,7 +187,7 @@ const useTeamTreeMemberships = (targetTeamID: T.Teams.TeamID, username: string) ) } return { - ...prev, + ...base, guid: membership.guid, memberships: nextMemberships, sparseMemberInfos: nextSparseMemberInfos, @@ -175,23 +201,26 @@ const useTeamTreeMemberships = (targetTeamID: T.Teams.TeamID, username: string) const errors: Array = [] const nodesNotIn: Array = [] const nodesIn: Array = [] + const visibleState = matchesTeamTreeMembershipState(state, targetTeamID, username) + ? state + : makeEmptyTeamTreeMembershipState(targetTeamID, username) // Note that we do not directly take any information directly from the TeamTree result other // than the **shape of the tree**. Membership metadata comes from the async tree-membership // results themselves instead of peeking into the global teams cache. - for (const membership of state.memberships) { + for (const membership of visibleState.memberships) { const teamname = membership.teamName if (T.RPCGen.TeamTreeMembershipStatus.ok === membership.result.s) { const teamID = membership.result.ok.teamID - const sparseMemberInfo = getSparseMemberInfo(state.sparseMemberInfos, teamID) + const sparseMemberInfo = getSparseMemberInfo(visibleState.sparseMemberInfos, teamID) if (!sparseMemberInfo) { continue } const row = { joinTime: sparseMemberInfo.joinTime, - lastActivity: state.lastActivity.get(teamID), + lastActivity: visibleState.lastActivity.get(teamID), // memberCount should always be populated because the TeamList, which is synced // eagerly, provides it. memberCount: teamMetas.get(teamID)?.memberCount, @@ -213,7 +242,8 @@ const useTeamTreeMemberships = (targetTeamID: T.Teams.TeamID, username: string) } return { errors, - loading: state.expectedCount === undefined || state.memberships.length < state.expectedCount, + loading: + visibleState.expectedCount === undefined || visibleState.memberships.length < visibleState.expectedCount, nodesIn, nodesNotIn, reload, diff --git a/shared/teams/team/rows/index.tsx b/shared/teams/team/rows/index.tsx index ada8b2b4de3e..e99039d09a4d 100644 --- a/shared/teams/team/rows/index.tsx +++ b/shared/teams/team/rows/index.tsx @@ -286,19 +286,19 @@ export const useSubteamsSections = ( } const useGeneralConversationIDKey = (teamID?: T.Teams.TeamID) => { - const [conversationIDKey, setConversationIDKey] = React.useState() + const [conversationIDKeyResult, setConversationIDKeyResult] = React.useState< + {conversationIDKey: T.Chat.ConversationIDKey; teamID: T.Teams.TeamID} | undefined + >() const findGeneralConvIDFromTeamID = C.useRPC(T.RPCChat.localFindGeneralConvFromTeamIDRpcPromise) const requestIDRef = React.useRef(0) + const conversationIDKey = + conversationIDKeyResult?.teamID === teamID ? conversationIDKeyResult.conversationIDKey : undefined React.useEffect(() => { - setConversationIDKey(undefined) - }, [teamID]) - - React.useEffect(() => { - requestIDRef.current += 1 if (conversationIDKey || !teamID) { return } + requestIDRef.current += 1 const requestID = requestIDRef.current findGeneralConvIDFromTeamID( [{teamID}], @@ -311,7 +311,7 @@ const useGeneralConversationIDKey = (teamID?: T.Teams.TeamID) => { return } ConvoState.metasReceived([meta]) - setConversationIDKey(meta.conversationIDKey) + setConversationIDKeyResult({conversationIDKey: meta.conversationIDKey, teamID}) }, () => {} ) @@ -326,7 +326,6 @@ const useGeneralConversationIDKey = (teamID?: T.Teams.TeamID) => { export const useEmojiSections = (teamID: T.Teams.TeamID, shouldActuallyLoad: boolean): Array

=> { const convID = useGeneralConversationIDKey(teamID) - const [lastActuallyLoad, setLastActuallyLoad] = React.useState(false) const getUserEmoji = C.useRPC(T.RPCChat.localUserEmojisRpcPromise) const [customEmoji, setCustomEmoji] = React.useState([]) const [filter, setFilter] = React.useState('') @@ -358,19 +357,10 @@ export const useEmojiSections = (teamID: T.Teams.TeamID, shouldActuallyLoad: boo }) const updatedTrigger = useEmojiState(s => s.emojiUpdatedTrigger) - const [lastUpdatedTrigger, setLastUpdatedTrigger] = React.useState(updatedTrigger) React.useEffect(() => { - if (shouldActuallyLoad !== lastActuallyLoad || lastUpdatedTrigger !== updatedTrigger) { - setLastActuallyLoad(shouldActuallyLoad) - setLastUpdatedTrigger(updatedTrigger) - doGetUserEmoji() - } - }, [lastActuallyLoad, lastUpdatedTrigger, shouldActuallyLoad, updatedTrigger]) - - C.useOnMountOnce(() => { doGetUserEmoji() - }) + }, [convID, shouldActuallyLoad, updatedTrigger]) let filteredEmoji: T.RPCChat.Emoji[] = customEmoji if (filter !== '') { diff --git a/shared/teams/team/settings-tab/default-channels.tsx b/shared/teams/team/settings-tab/default-channels.tsx index a3ad1c74d778..2686a497e78e 100644 --- a/shared/teams/team/settings-tab/default-channels.tsx +++ b/shared/teams/team/settings-tab/default-channels.tsx @@ -23,14 +23,16 @@ export const useDefaultChannels = (teamID: T.Teams.TeamID) => { defaultChannels: [], error: undefined, loadedTeamID: teamID, - waiting: false, + waiting: true, }) const requestVersionRef = React.useRef(0) const requestTeamIDRef = React.useRef(teamID) - const reloadDefaultChannels = React.useCallback(() => { + const reloadDefaultChannels = React.useCallback((showWaiting = true) => { const requestVersion = ++requestVersionRef.current - setState(prev => ({...prev, error: undefined, waiting: true})) + if (showWaiting) { + setState(prev => ({...prev, error: undefined, waiting: true})) + } getDefaultChannelsRPC( [{teamID}], result => { @@ -63,8 +65,9 @@ export const useDefaultChannels = (teamID: T.Teams.TeamID) => { } }, [teamID]) - // Initialize - React.useEffect(reloadDefaultChannels, [reloadDefaultChannels]) + React.useEffect(() => { + reloadDefaultChannels(false) + }, [reloadDefaultChannels]) const visibleState = state.loadedTeamID === teamID diff --git a/shared/teams/team/settings-tab/index.tsx b/shared/teams/team/settings-tab/index.tsx index 1322bae8d46c..ab740bf0dfe5 100644 --- a/shared/teams/team/settings-tab/index.tsx +++ b/shared/teams/team/settings-tab/index.tsx @@ -433,15 +433,11 @@ const Container = (ownProps: OwnProps) => { ] ) - // reset if incoming props change on us - const [key, setKey] = React.useState(0) - React.useEffect(() => { - setKey(k => k + 1) - }, [ignoreAccessRequests, openTeam, openTeamRole, publicityAnyMember, publicityMember, publicityTeam]) + const settingsKey = `${ignoreAccessRequests}:${openTeam}:${openTeamRole}:${publicityAnyMember}:${publicityMember}:${publicityTeam}` return ( { const {policy, showInheritOption, teamPolicy, saveRetentionPolicy, entityType} = p const {containerStyle, dropdownStyle, policyIsExploding, showOverrideNotice, showSaveIndicator} = p - const [saving, setSaving] = React.useState(false) - const [selected, _setSelected] = React.useState(undefined) + const [pendingPolicy, setPendingPolicy] = React.useState(undefined) + const [selected, setSelected] = React.useState(undefined) - const userSelectedRef = React.useRef(false) - - const setSelected = (r: T.Retention.RetentionPolicy, userSelected: boolean) => { - if (userSelected) { - userSelectedRef.current = userSelected - } - _setSelected(r) - } - - const showSaved = React.useRef(false) - - const isSelected = (p: T.Retention.RetentionPolicy) => { - return policyEquals(policy, p) - } + const isSelected = (p: T.Retention.RetentionPolicy) => policyEquals(policy, p) const modalConfirmed = useConfirm(s => s.confirmed) - const modalOpen = useConfirm(s => s.modalOpen) const updateConfirm = useConfirm(s => s.dispatch.updateConfirm) - const [lastConfirmed, setLastConfirmed] = React.useState(undefined) - const [lastModalOpen, setLastModalOpen] = React.useState(modalOpen) + const navigateAppend = C.Router2.navigateAppend + const confirmedSubmittedRef = React.useRef(undefined) + const selectedMatchesConfirmed = + !!selected && + !!modalConfirmed && + (policyEquals(selected, modalConfirmed) || + (selected.type === 'inherit' && !!teamPolicy && policyEquals(teamPolicy, modalConfirmed))) React.useEffect(() => { - if (lastModalOpen !== modalOpen) { - setLastModalOpen(modalOpen) - if (!modalOpen) { - setSelected(policy, false) - } + if (!modalConfirmed) { + confirmedSubmittedRef.current = undefined + return + } + if (selected && selectedMatchesConfirmed && confirmedSubmittedRef.current !== modalConfirmed) { + confirmedSubmittedRef.current = modalConfirmed + saveRetentionPolicy(selected) + } + updateConfirm(undefined) + }, [modalConfirmed, saveRetentionPolicy, selected, selectedMatchesConfirmed, updateConfirm]) + + const selectPolicy = (nextPolicy: T.Retention.RetentionPolicy) => { + setSelected(nextPolicy) + const changed = !policyEquals(nextPolicy, policy) + if (!changed) { + setPendingPolicy(undefined) + return } - }, [lastModalOpen, modalOpen, policy]) - - if (lastConfirmed !== modalConfirmed) { - setTimeout(() => { - setLastConfirmed(modalConfirmed) - if (selected === modalConfirmed) { - selected && saveRetentionPolicy(selected) - } - updateConfirm(undefined) - }, 1) - } - const navigateAppend = C.Router2.navigateAppend - React.useEffect(() => { - if (userSelectedRef.current) { - userSelectedRef.current = false - const changed = !policyEquals(selected, policy) - const decreased = policyToComparable(selected, teamPolicy) < policyToComparable(policy, teamPolicy) - - // show dialog if decreased, set immediately if not - if (changed) { - if (decreased) { - // show warning - showSaved.current = false - if (selected) { - navigateAppend({ - name: 'retentionWarning', - params: {entityType, policy: selected.type === 'inherit' && teamPolicy ? teamPolicy : selected}, - }) - } - } else { - const onConfirm = () => { - selected && saveRetentionPolicy(selected) - } - // set immediately - onConfirm() - showSaved.current = true - setSaving(true) - } - } + const decreased = policyToComparable(nextPolicy, teamPolicy) < policyToComparable(policy, teamPolicy) + if (decreased) { + setPendingPolicy(undefined) + navigateAppend({ + name: 'retentionWarning', + params: {entityType, policy: nextPolicy.type === 'inherit' && teamPolicy ? teamPolicy : nextPolicy}, + }) + return } - }, [selected, policy, saveRetentionPolicy, teamPolicy, navigateAppend, entityType]) - const lastPolicy = React.useRef(policy) - const lastTeamPolicy = React.useRef(teamPolicy) + saveRetentionPolicy(nextPolicy) + setPendingPolicy(nextPolicy) + } - React.useEffect(() => { - if (!policyEquals(policy, lastPolicy.current) || !policyEquals(teamPolicy, lastTeamPolicy.current)) { - if (policyEquals(policy, selected)) { - // we just got updated retention policy matching the selected one - setSaving(false) - } // we could show a notice that we received a new value in an else block - setSelected(policy, false) - } - lastPolicy.current = policy - lastTeamPolicy.current = teamPolicy - }, [policy, teamPolicy, selected]) + const saving = !!pendingPolicy && !policyEquals(policy, pendingPolicy) const makePopup = (p: Kb.Popup2Parms) => { const {attachTo, hidePopup} = p @@ -139,7 +101,7 @@ const RetentionPicker = (p: Props) => { ...arr, { isSelected: isSelected(policy), - onClick: () => setSelected(policy, true), + onClick: () => selectPolicy(policy), title: policy.title, } as const, ] @@ -159,7 +121,7 @@ const RetentionPicker = (p: Props) => { return [ { isSelected: isSelected(policy), - onClick: () => setSelected(policy, true), + onClick: () => selectPolicy(policy), title, } as const, 'Divider' as const, @@ -175,7 +137,7 @@ const RetentionPicker = (p: Props) => { icon: 'iconfont-timer', iconIsVisible: true, isSelected: isSelected(policy), - onClick: () => setSelected(policy, true), + onClick: () => selectPolicy(policy), title: policy.title, } as const, ] @@ -472,8 +434,30 @@ const useLoadedTeamRetentionPolicy = (teamID: T.Teams.TeamID) => { }, [teamID]) React.useEffect(() => { - void reload() - }, [reload]) + let canceled = false + const requestVersion = ++requestVersionRef.current + const load = async () => { + try { + const servicePolicy = await T.RPCChat.localGetTeamRetentionLocalRpcPromise( + {teamID}, + C.waitingKeyTeamsLoadRetentionPolicy(teamID) + ) + if (canceled || requestVersion !== requestVersionRef.current) { + return + } + setTeamRetentionPolicy(servicePolicy) + } catch { + if (canceled || requestVersion !== requestVersionRef.current) { + return + } + setTeamRetentionPolicy(undefined) + } + } + void load() + return () => { + canceled = true + } + }, [setTeamRetentionPolicy, teamID]) C.Router2.useSafeFocusEffect( React.useCallback(() => { diff --git a/shared/teams/team/team-info.tsx b/shared/teams/team/team-info.tsx index a46cfe3c5d66..ce027aa1f110 100644 --- a/shared/teams/team/team-info.tsx +++ b/shared/teams/team/team-info.tsx @@ -15,9 +15,29 @@ const TeamInfo = (props: Props) => { const _leafName = isSubteam ? teamname.substring(lastDot + 1) : teamname const parentTeamNameWithDot = isSubteam ? teamname.substring(0, lastDot + 1) : undefined - const [newName, _setName] = React.useState(_leafName) - const setName = (newName: string) => _setName(newName.replace(/[^a-zA-Z0-9_]/, '')) - const [description, setDescription] = React.useState(teamDetails.description) + const [draft, setDraft] = React.useState(() => ({ + description: teamDetails.description, + name: _leafName, + sourceDescription: teamDetails.description, + sourceName: _leafName, + })) + const hasNewSource = draft.sourceName !== _leafName || draft.sourceDescription !== teamDetails.description + const newName = hasNewSource ? _leafName : draft.name + const description = hasNewSource ? teamDetails.description : draft.description + const setName = (name: string) => + setDraft({ + description, + name: name.replace(/[^a-zA-Z0-9_]/, ''), + sourceDescription: teamDetails.description, + sourceName: _leafName, + }) + const setDescription = (description: string) => + setDraft({ + description, + name: newName, + sourceDescription: teamDetails.description, + sourceName: _leafName, + }) const [descError, setDescError] = React.useState('') const saveDisabled = (description === teamDetails.description && newName === _leafName) || newName.length < 3 @@ -71,14 +91,6 @@ const TeamInfo = (props: Props) => { wasWaitingRef.current = waiting }, [waiting]) - React.useEffect(() => { - _setName(_leafName) - }, [_leafName]) - - React.useEffect(() => { - setDescription(teamDetails.description) - }, [teamDetails.description]) - return ( <> {Object.keys(errors).map(k => diff --git a/shared/teams/use-cached-resource.tsx b/shared/teams/use-cached-resource.tsx index cce418a893bb..d122bfc184fa 100644 --- a/shared/teams/use-cached-resource.tsx +++ b/shared/teams/use-cached-resource.tsx @@ -20,6 +20,12 @@ type CachedResourceState = { loading: boolean } +type StoredCachedResourceState = CachedResourceState & { + cache: CachedResourceCache + cacheKey: K + initialData: T +} + type Props = { cache: CachedResourceCache cacheKey: K @@ -37,6 +43,27 @@ const emptyState = (data: T): CachedResourceState => ({ loading: false, }) +const cachedState = ( + cache: CachedResourceCache, + cacheKey: K, + initialData: T +): CachedResourceState => + Object.is(cache.getKey(), cacheKey) && cache.getLoadedAt() + ? {data: cache.getData(), loaded: true, loading: false} + : emptyState(initialData) + +const storedState = ( + cache: CachedResourceCache, + cacheKey: K, + initialData: T, + state: CachedResourceState +): StoredCachedResourceState => ({ + ...state, + cache, + cacheKey, + initialData, +}) + export const createCachedResourceCache = (initialData: T, key: K): CachedResourceCache => { let data = initialData let generation = 0 @@ -96,10 +123,8 @@ export const getCachedResourceCache = ( export const useCachedResource = (props: Props) => { const {cache, cacheKey, enabled = true, initialData, load, onError, refreshKey, staleMs} = props - const [state, setState] = React.useState>( - Object.is(cache.getKey(), cacheKey) && cache.getLoadedAt() - ? {data: cache.getData(), loaded: true, loading: false} - : emptyState(initialData) + const [state, setState] = React.useState>(() => + storedState(cache, cacheKey, initialData, cachedState(cache, cacheKey, initialData)) ) const hasFocusedSinceMountRef = React.useRef(false) const requestVersionRef = React.useRef(0) @@ -115,15 +140,14 @@ export const useCachedResource = (props: Props) => { (nextKey: K = cacheKey) => { requestVersionRef.current += 1 resetCache(nextKey) - setState(emptyState(initialData)) + setState(storedState(cache, nextKey, initialData, emptyState(initialData))) }, - [cacheKey, initialData, resetCache] + [cache, cacheKey, initialData, resetCache] ) const latestRef = React.useRef({ cache, cacheKey, - clear, enabled, initialData, load, @@ -135,7 +159,6 @@ export const useCachedResource = (props: Props) => { latestRef.current = { cache, cacheKey, - clear, enabled, initialData, load, @@ -143,35 +166,39 @@ export const useCachedResource = (props: Props) => { resetCache, staleMs, } - }, [cache, cacheKey, clear, enabled, initialData, load, onError, resetCache, staleMs]) + }, [cache, cacheKey, enabled, initialData, load, onError, resetCache, staleMs]) const loadResource = React.useCallback( async (force: boolean) => { - const {cache, cacheKey, clear, enabled, initialData, load, onError, resetCache, staleMs} = + const {cache, cacheKey, enabled, initialData, load, onError, resetCache, staleMs} = latestRef.current if (!Object.is(cache.getKey(), cacheKey)) { requestVersionRef.current += 1 resetCache(cacheKey) - setState(emptyState(initialData)) } if (!enabled) { - clear(cacheKey) + requestVersionRef.current += 1 + resetCache(cacheKey) return } const loadedAt = cache.getLoadedAt() if (!force && loadedAt && Date.now() - loadedAt < staleMs) { - setState({data: cache.getData(), loaded: true, loading: false}) + setState(storedState(cache, cacheKey, initialData, {data: cache.getData(), loaded: true, loading: false})) return } const requestVersion = ++requestVersionRef.current - setState(prev => ({...prev, loading: true})) + setState(prev => + prev.cache === cache && Object.is(prev.cacheKey, cacheKey) && Object.is(prev.initialData, initialData) + ? {...prev, loading: true} + : storedState(cache, cacheKey, initialData, {...emptyState(initialData), loading: true}) + ) let request: Promise | undefined try { const inFlight = cache.getInFlight() if (inFlight) { const data = await inFlight if (requestVersion === requestVersionRef.current) { - setState({data, loaded: true, loading: false}) + setState(storedState(cache, cacheKey, initialData, {data, loaded: true, loading: false})) } return } @@ -183,7 +210,7 @@ export const useCachedResource = (props: Props) => { cache.setInFlight(request) const data = await request if (requestVersion === requestVersionRef.current) { - setState({data, loaded: true, loading: false}) + setState(storedState(cache, cacheKey, initialData, {data, loaded: true, loading: false})) } } catch (error) { if (requestVersion !== requestVersionRef.current) { @@ -209,16 +236,14 @@ export const useCachedResource = (props: Props) => { }, [loadResource]) React.useEffect(() => { - setState( - Object.is(cache.getKey(), cacheKey) && cache.getLoadedAt() - ? {data: cache.getData(), loaded: true, loading: false} - : emptyState(initialData) - ) - if (!Object.is(cache.getKey(), cacheKey)) { - clear(cacheKey) + if (!Object.is(cache.getKey(), cacheKey) || !enabled) { + requestVersionRef.current += 1 + resetCache(cacheKey) + } + if (enabled) { + void loadIfStale() } - void loadIfStale() - }, [cache, cacheKey, clear, enabled, initialData, loadIfStale, refreshKey]) + }, [cache, cacheKey, enabled, loadIfStale, refreshKey, resetCache]) C.Router2.useSafeFocusEffect( React.useCallback(() => { @@ -233,5 +258,23 @@ export const useCachedResource = (props: Props) => { }, [enabled, loadIfStale]) ) - return {...state, clear, loadIfStale, reload} + const stateMatches = + state.cache === cache && + Object.is(state.cacheKey, cacheKey) && + Object.is(state.initialData, initialData) && + (!state.loaded || !!cache.getLoadedAt()) + const visibleState = !enabled + ? emptyState(initialData) + : stateMatches + ? state + : cachedState(cache, cacheKey, initialData) + + return { + clear, + data: visibleState.data, + loadIfStale, + loaded: visibleState.loaded, + loading: visibleState.loading, + reload, + } } diff --git a/shared/util/featured-bots.tsx b/shared/util/featured-bots.tsx index 9cb34ffb6df5..18f983e8baea 100644 --- a/shared/util/featured-bots.tsx +++ b/shared/util/featured-bots.tsx @@ -33,64 +33,85 @@ export const getFeaturedSorted = (featuredBots: ReadonlyArray { - const [featuredBot, setFeaturedBot] = React.useState() + const [loadedFeaturedBot, setLoadedFeaturedBot] = React.useState<{ + bot?: T.RPCGen.FeaturedBot + botUsername: string + }>() const searchFeaturedBots = C.useRPC(T.RPCGen.featuredBotSearchRpcPromise) React.useEffect(() => { if (!botUsername) { - setFeaturedBot(undefined) return } + let canceled = false searchFeaturedBots( [{limit: 10, offset: 0, query: botUsername}], result => { - setFeaturedBot(pickFeaturedBot(botUsername, result.bots ?? [])) + if (!canceled) { + setLoadedFeaturedBot({bot: pickFeaturedBot(botUsername, result.bots ?? []), botUsername}) + } }, error => { - logger.info(`Featured bot load failed for ${botUsername}: ${error.message}`) + if (!canceled) { + logger.info(`Featured bot load failed for ${botUsername}: ${error.message}`) + } } ) + return () => { + canceled = true + } }, [botUsername, searchFeaturedBots]) - return featuredBot + return loadedFeaturedBot?.botUsername === botUsername ? loadedFeaturedBot.bot : undefined } export const useFeaturedBotPage = () => { const [featuredBots, setFeaturedBots] = React.useState>([]) const [featuredBotsPage, setFeaturedBotsPage] = React.useState(-1) const [loadedAllBots, setLoadedAllBots] = React.useState(false) - const [loadingBots, setLoadingBots] = React.useState(false) + const [pendingFeaturedBotsPage, setPendingFeaturedBotsPage] = React.useState(0) const loadFeaturedBots = C.useRPC(T.RPCGen.featuredBotFeaturedBotsRpcPromise) + const loadingBots = pendingFeaturedBotsPage !== undefined - const loadNextBotPage = React.useCallback(() => { + const loadNextBotPage = () => { if (loadingBots || loadedAllBots) { return } - const nextPage = featuredBotsPage + 1 - setLoadingBots(true) + setPendingFeaturedBotsPage(featuredBotsPage + 1) + } + + React.useEffect(() => { + if (pendingFeaturedBotsPage === undefined) { + return + } + + let canceled = false loadFeaturedBots( - [{limit: featuredBotPageSize, offset: nextPage * featuredBotPageSize, skipCache: false}], + [{limit: featuredBotPageSize, offset: pendingFeaturedBotsPage * featuredBotPageSize, skipCache: false}], result => { + if (canceled) { + return + } const bots = result.bots ?? [] setFeaturedBots(previous => mergeFeaturedBots(previous, bots)) - setFeaturedBotsPage(nextPage) + setFeaturedBotsPage(pendingFeaturedBotsPage) setLoadedAllBots(bots.length < featuredBotPageSize) - setLoadingBots(false) + setPendingFeaturedBotsPage(undefined) }, error => { + if (canceled) { + return + } logger.info(`Featured bots page load failed: ${error.message}`) - setLoadingBots(false) + setPendingFeaturedBotsPage(undefined) } ) - }, [featuredBotsPage, loadFeaturedBots, loadedAllBots, loadingBots]) - - React.useEffect(() => { - if (featuredBotsPage === -1 && !loadedAllBots) { - loadNextBotPage() + return () => { + canceled = true } - }, [featuredBotsPage, loadedAllBots, loadNextBotPage]) + }, [loadFeaturedBots, pendingFeaturedBotsPage]) return {featuredBots, loadNextBotPage, loadedAllBots, loadingBots} } diff --git a/shared/util/phone-numbers/index.tsx b/shared/util/phone-numbers/index.tsx index 11a1599dc405..0918a30a0fb8 100644 --- a/shared/util/phone-numbers/index.tsx +++ b/shared/util/phone-numbers/index.tsx @@ -232,10 +232,6 @@ export const useDefaultPhoneCountry = () => { React.useEffect(() => { let canceled = false - if (_defaultPhoneCountry) { - setDefaultCountry(_defaultPhoneCountry) - return - } void loadDefaultPhoneCountry().then(country => { if (!canceled) { setDefaultCountry(country) diff --git a/shared/util/use-intersection-observer.desktop.tsx b/shared/util/use-intersection-observer.desktop.tsx index ab1ac9d00bc3..f256baa6f0e9 100644 --- a/shared/util/use-intersection-observer.desktop.tsx +++ b/shared/util/use-intersection-observer.desktop.tsx @@ -23,29 +23,16 @@ function useIntersectionObserver( target: null, time: 0, })) - const [observer, setObserver] = React.useState(() => - getIntersectionObserver({ - pollInterval, - root, - rootMargin, - threshold, - useMutationObserver, - }) - ) + const thresholdKey = JSON.stringify(threshold) - React.useEffect(() => { + React.useLayoutEffect(() => { const observer = getIntersectionObserver({ pollInterval, root, rootMargin, - threshold, + threshold: JSON.parse(thresholdKey) as IntersectionObserverOptions['threshold'], useMutationObserver, }) - setObserver(observer) - // eslint-disable-next-line - }, [root, rootMargin, pollInterval, useMutationObserver, JSON.stringify(threshold)]) - - React.useLayoutEffect(() => { const targetEl = target && 'current' in target ? target.current : target if (!observer || !targetEl) return let didUnsubscribe = false @@ -71,7 +58,7 @@ function useIntersectionObserver( observer.observer.unobserve(targetEl) observer.unsubscribe(callback) } - }, [target, observer]) + }, [target, root, rootMargin, pollInterval, useMutationObserver, thresholdKey]) return entry } diff --git a/shared/wallets/really-remove-account.tsx b/shared/wallets/really-remove-account.tsx index e92ba75e0d4b..3f2357c3f689 100644 --- a/shared/wallets/really-remove-account.tsx +++ b/shared/wallets/really-remove-account.tsx @@ -18,7 +18,8 @@ const ReallyRemoveAccountPopup = (props: OwnProps) => { const attachmentRef = React.useRef(null) const setShowToastFalseLater = Kb.useTimeout(() => setShowToast(false), 2000) - const [sk, setSK] = React.useState('') + const [secretKeyState, setSecretKeyState] = React.useState({accountID: '', sk: ''}) + const sk = secretKeyState.accountID === accountID ? secretKeyState.sk : '' const loading = !sk const getSecretKey = C.useRPC(T.RPCStellar.localGetWalletAccountSecretKeyLocalRpcPromise) const deleteAccount = C.useRPC(T.RPCStellar.localDeleteWalletAccountLocalRpcPromise) @@ -30,14 +31,19 @@ const ReallyRemoveAccountPopup = (props: OwnProps) => { } React.useEffect(() => { - setSK('') + let canceled = false getSecretKey( [{accountID}], r => { - setSK(r) + if (!canceled) { + setSecretKeyState({accountID, sk: r}) + } }, () => {} ) + return () => { + canceled = true + } }, [getSecretKey, accountID]) const onCopy = () => { From 3b012f127aed4558ef69b322aca79baf1808985b Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Sat, 25 Apr 2026 09:53:49 -0400 Subject: [PATCH 20/27] WIP --- plans/use-effects-lint-todo.md | 10 +- .../conversation/attachment-get-titles.tsx | 8 +- shared/chat/conversation/bot/install.tsx | 4 +- shared/chat/conversation/team-hooks.tsx | 125 ++++++++++++++---- .../popup/floating-box/index.desktop.tsx | 1 - .../relative-floating-box.desktop.tsx | 17 ++- shared/common-adapters/toast.native.tsx | 20 +-- shared/fs/browser/rows/editing.tsx | 2 +- shared/fs/footer/use-upload-countdown.tsx | 3 +- shared/settings/files/hooks.tsx | 27 +++- shared/teams/common/activity.tsx | 10 +- shared/teams/team/rows/index.tsx | 4 +- .../team/settings-tab/default-channels.tsx | 27 +++- shared/teams/use-cached-resource.tsx | 2 +- shared/util/featured-bots.tsx | 4 +- 15 files changed, 205 insertions(+), 59 deletions(-) diff --git a/plans/use-effects-lint-todo.md b/plans/use-effects-lint-todo.md index 61c565817510..0bffdba564a6 100644 --- a/plans/use-effects-lint-todo.md +++ b/plans/use-effects-lint-todo.md @@ -24,7 +24,7 @@ Scope: only `react-hooks/set-state-in-effect` findings. Ignore `refs`, `immutabi ## Batch 2: Chat Modals And Bot Install - [x] `shared/chat/blocking/block-modal.tsx:244:7` - moved default block settings into state initializers and left the effect for the one-time external block-state refresh. -- [x] `shared/chat/conversation/attachment-get-titles.tsx:122:5` - already absent in current source; the logged `kbfsPreviewURL` reset effect is no longer present. +- [x] `shared/chat/conversation/attachment-get-titles.tsx:122:5` - tags KBFS preview URLs by path and derives stale previews as absent instead of clearing preview state in the effect. - [x] `shared/chat/conversation/bot/install.tsx:68:5` - derived the visible conversation ID from the input conversation or a team-tagged async lookup result. - [x] `shared/chat/conversation/bot/install.tsx:242:5` - tagged loaded bot public commands by username instead of clearing command state in an effect. @@ -50,8 +50,8 @@ Scope: only `react-hooks/set-state-in-effect` findings. Ignore `refs`, `immutabi - [x] `shared/chat/conversation/normal/container.tsx:79:9` - folded mobile app-state clearing into the guarded orange-line state adjustment during render. - [x] `shared/chat/conversation/search.tsx:254:5` - keyed thread-search internals by conversation/query instead of clearing search state in an effect. - [x] `shared/chat/conversation/search.tsx:264:5` - seeded initial-query state on mount and started the request without syncing input state from an effect. -- [x] `shared/chat/conversation/team-hooks.tsx:290:10` - split team-member effect loading from manual reload loading state and derived initial loading for stale team IDs. -- [x] `shared/chat/conversation/team-hooks.tsx:373:10` - split team-name effect loading from manual reload loading state and keyed visible results by the requested team IDs. +- [x] `shared/chat/conversation/team-hooks.tsx:290:10` - split team-member fetching from state application so the initial effect starts an async request without calling the reload state setter. +- [x] `shared/chat/conversation/team-hooks.tsx:373:10` - split team-name fetching from state application so the initial effect starts an async request while visible results stay keyed by requested team IDs. ## Batch 6: Common Adapter Components @@ -103,7 +103,7 @@ Scope: only `react-hooks/set-state-in-effect` findings. Ignore `refs`, `immutabi - [x] `shared/settings/account/index.tsx:222:5` - clears account banner state with a guarded focus-keyed render adjustment. - [x] `shared/settings/account/index.tsx:231:7` - derives invalid or verified added-email banner clearing during render. - [x] `shared/settings/chat.tsx:199:7` - seeds selected team notification settings with a guarded render update. -- [x] `shared/settings/files/hooks.tsx:38:21` - keeps file-settings loading state for explicit refreshes while the initial effect starts the request without a synchronous loading update. +- [x] `shared/settings/files/hooks.tsx:38:21` - keeps file-settings loading state for explicit refreshes while the initial effect performs its own cancellable async settings read. - [x] `shared/settings/proxy.tsx:123:9` - keys proxy form state to loaded `proxyData` via guarded render adjustment. - [x] `shared/signup/device-name.tsx:139:7` - sanitizes the device name in the initializer and input handler instead of an effect. @@ -132,7 +132,7 @@ Scope: only `react-hooks/set-state-in-effect` findings. Ignore `refs`, `immutabi ## Batch 13: Teams Settings And Cached Resource -- [x] `shared/teams/team/settings-tab/default-channels.tsx:67:19` - starts the initial default-channel load without a synchronous waiting update while preserving request/team guards. +- [x] `shared/teams/team/settings-tab/default-channels.tsx:67:19` - starts the initial default-channel RPC directly from the effect callback path without calling the reload setter. - [x] `shared/teams/team/settings-tab/index.tsx:439:5` - replaces reset key state/effect with a derived settings key. - [x] `shared/teams/team/settings-tab/retention/index.tsx:63:7` - removes modal-open mirror state and reset effect. - [x] `shared/teams/team/settings-tab/retention/index.tsx:105:11` - moves retention warning/save logic into the menu selection handler. diff --git a/shared/chat/conversation/attachment-get-titles.tsx b/shared/chat/conversation/attachment-get-titles.tsx index a5e28e1d12c5..86bfa7dedad2 100644 --- a/shared/chat/conversation/attachment-get-titles.tsx +++ b/shared/chat/conversation/attachment-get-titles.tsx @@ -117,9 +117,11 @@ const Container = (ownProps: OwnProps) => { const inputRef = React.useRef(null) const {info, path} = pathAndInfos[index] ?? {} - const [kbfsPreviewURL, setKbfsPreviewURL] = React.useState(undefined) + const [kbfsPreview, setKbfsPreview] = React.useState< + {path: string; url: string | undefined} | undefined + >() + const kbfsPreviewURL = kbfsPreview?.path === path ? kbfsPreview.url : undefined React.useEffect(() => { - setKbfsPreviewURL(undefined) if (info?.type !== 'image' || info.url || !path || !isKbfsPath(path)) { return } @@ -130,7 +132,7 @@ const Container = (ownProps: OwnProps) => { path: FS.pathToRPCPath(T.FS.stringToPath(path)).kbfs, }) if (!canceled) { - setKbfsPreviewURL(fileContext.url) + setKbfsPreview({path, url: fileContext.url}) } } catch {} } diff --git a/shared/chat/conversation/bot/install.tsx b/shared/chat/conversation/bot/install.tsx index bee739e89a0f..84f6d2e0909b 100644 --- a/shared/chat/conversation/bot/install.tsx +++ b/shared/chat/conversation/bot/install.tsx @@ -71,7 +71,9 @@ export const useBotConversationIDKey = (inConvIDKey?: T.Chat.ConversationIDKey, const requestIDRef = React.useRef(0) const conversationIDKey = cleanInConvIDKey ?? - (generalConversation?.teamID === teamID ? generalConversation.conversationIDKey : undefined) + (generalConversation && generalConversation.teamID === teamID + ? generalConversation.conversationIDKey + : undefined) React.useEffect(() => { requestIDRef.current += 1 diff --git a/shared/chat/conversation/team-hooks.tsx b/shared/chat/conversation/team-hooks.tsx index 188964b7f5fb..5938d0be63e1 100644 --- a/shared/chat/conversation/team-hooks.tsx +++ b/shared/chat/conversation/team-hooks.tsx @@ -261,15 +261,19 @@ const useChatTeamMembersRaw = (teamID: T.Teams.TeamID, enabled = true): ChatTeam setState(makeEmptyChatTeamMembersState()) } + const loadMemberInfos = React.useCallback( + async (teamID: T.Teams.TeamID) => + Teams.rpcDetailsToMemberInfos((await T.RPCGen.teamsTeamGetMembersByIDRpcPromise({id: teamID})) ?? []), + [] + ) + const loadMembers = React.useCallback(async () => { if (!enabled || !validTeamID) { return } const requestVersion = ++requestVersionRef.current try { - const members = Teams.rpcDetailsToMemberInfos( - (await T.RPCGen.teamsTeamGetMembersByIDRpcPromise({id: validTeamID})) ?? [] - ) + const members = await loadMemberInfos(validTeamID) if (requestVersion !== requestVersionRef.current) { return } @@ -287,7 +291,7 @@ const useChatTeamMembersRaw = (teamID: T.Teams.TeamID, enabled = true): ChatTeam logger.warn(`Failed to reload chat team members for ${validTeamID}`, error) setState(prev => ({...prev, loadedTeamID: validTeamID, loading: false})) } - }, [enabled, validTeamID]) + }, [enabled, loadMemberInfos, validTeamID]) const reload = React.useCallback(async () => { if (!enabled || !validTeamID) { @@ -306,8 +310,39 @@ const useChatTeamMembersRaw = (teamID: T.Teams.TeamID, enabled = true): ChatTeam : state React.useEffect(() => { - void loadMembers() - }, [loadMembers]) + if (!enabled || !validTeamID) { + requestVersionRef.current += 1 + return undefined + } + const requestVersion = ++requestVersionRef.current + const f = async () => { + try { + const members = await loadMemberInfos(validTeamID) + if (requestVersion !== requestVersionRef.current) { + return + } + useUsersState.getState().dispatch.updates( + [...members.values()].map(member => ({ + info: {fullname: member.fullName}, + name: member.username, + })) + ) + setState({loadedTeamID: validTeamID, loading: false, members}) + } catch (error) { + if (requestVersion !== requestVersionRef.current) { + return + } + logger.warn(`Failed to reload chat team members for ${validTeamID}`, error) + setState(prev => ({...prev, loadedTeamID: validTeamID, loading: false})) + } + } + C.ignorePromise(f()) + return () => { + if (requestVersionRef.current === requestVersion) { + requestVersionRef.current += 1 + } + } + }, [enabled, loadMemberInfos, validTeamID]) C.Router2.useSafeFocusEffect( React.useCallback(() => { void loadMembers() @@ -353,32 +388,37 @@ const useChatTeamNamesRaw = (teamIDs: ReadonlyArray, enabled = t setState(makeEmptyChatTeamNamesState()) } + const loadTeamNamesForIDs = React.useCallback(async (teamIDs: ReadonlyArray) => { + const resolvedTeamnames = await Promise.all( + teamIDs.map(async teamID => { + try { + const teamname = (await T.RPCGen.teamsGetAnnotatedTeamRpcPromise({teamID})).name + return teamname ? ([teamID, teamname] as const) : undefined + } catch (error) { + logger.warn(`Failed to load chat team name for ${teamID}`, error) + return undefined + } + }) + ) + const teamnames = new Map() + resolvedTeamnames.forEach(entry => { + if (entry) { + teamnames.set(entry[0], entry[1]) + } + }) + return teamnames + }, []) + const loadTeamNames = React.useCallback(async () => { if (!enabled || !teamIDsKey || !username) { return } const requestVersion = ++requestVersionRef.current try { - const resolvedTeamnames = await Promise.all( - validTeamIDs.map(async teamID => { - try { - const teamname = (await T.RPCGen.teamsGetAnnotatedTeamRpcPromise({teamID})).name - return teamname ? ([teamID, teamname] as const) : undefined - } catch (error) { - logger.warn(`Failed to load chat team name for ${teamID}`, error) - return undefined - } - }) - ) + const teamnames = await loadTeamNamesForIDs(validTeamIDs) if (requestVersion !== requestVersionRef.current) { return } - const teamnames = new Map() - resolvedTeamnames.forEach(entry => { - if (entry) { - teamnames.set(entry[0], entry[1]) - } - }) setState({ loadedTeamIDsKey: teamIDsKey, loading: false, @@ -395,7 +435,7 @@ const useChatTeamNamesRaw = (teamIDs: ReadonlyArray, enabled = t teamnames: prev.loadedTeamIDsKey === teamIDsKey ? new Map(prev.teamnames) : new Map(), })) } - }, [enabled, teamIDsKey, username, validTeamIDs]) + }, [enabled, loadTeamNamesForIDs, teamIDsKey, username, validTeamIDs]) const reload = React.useCallback(async () => { if (!enabled || !teamIDsKey || !username) { @@ -418,8 +458,41 @@ const useChatTeamNamesRaw = (teamIDs: ReadonlyArray, enabled = t : state React.useEffect(() => { - void loadTeamNames() - }, [loadTeamNames]) + if (!enabled || !teamIDsKey || !username) { + requestVersionRef.current += 1 + return undefined + } + const requestVersion = ++requestVersionRef.current + const f = async () => { + try { + const teamnames = await loadTeamNamesForIDs(validTeamIDs) + if (requestVersion !== requestVersionRef.current) { + return + } + setState({ + loadedTeamIDsKey: teamIDsKey, + loading: false, + teamnames, + }) + } catch (error) { + if (requestVersion !== requestVersionRef.current) { + return + } + logger.warn(`Failed to load chat team names for ${teamIDsKey}`, error) + setState(prev => ({ + loadedTeamIDsKey: teamIDsKey, + loading: false, + teamnames: prev.loadedTeamIDsKey === teamIDsKey ? new Map(prev.teamnames) : new Map(), + })) + } + } + C.ignorePromise(f()) + return () => { + if (requestVersionRef.current === requestVersion) { + requestVersionRef.current += 1 + } + } + }, [enabled, loadTeamNamesForIDs, teamIDsKey, username, validTeamIDs]) C.Router2.useSafeFocusEffect( React.useCallback(() => { void loadTeamNames() diff --git a/shared/common-adapters/popup/floating-box/index.desktop.tsx b/shared/common-adapters/popup/floating-box/index.desktop.tsx index 773e068c0121..1965fe1d5e3a 100644 --- a/shared/common-adapters/popup/floating-box/index.desktop.tsx +++ b/shared/common-adapters/popup/floating-box/index.desktop.tsx @@ -1,4 +1,3 @@ -import * as React from 'react' import type {Props} from '.' import {RelativeFloatingBox} from './relative-floating-box.desktop' import noop from 'lodash/noop' diff --git a/shared/common-adapters/popup/floating-box/relative-floating-box.desktop.tsx b/shared/common-adapters/popup/floating-box/relative-floating-box.desktop.tsx index 239139333151..9fba0d9229d5 100644 --- a/shared/common-adapters/popup/floating-box/relative-floating-box.desktop.tsx +++ b/shared/common-adapters/popup/floating-box/relative-floating-box.desktop.tsx @@ -240,6 +240,7 @@ export const RelativeFloatingBox = (props: ModalPositionRelativeProps) => { const downRef = React.useRef(undefined) const {attachTo, children, propagateOutsideClicks, onClosePopup, style: _style} = props const {position, matchDimension, positionFallbacks, disableEscapeKey, offset = 0, remeasureHint} = props + const remeasureHintRef = React.useRef(remeasureHint) const popupNode = popupState?.node const setPopupRef = React.useCallback( @@ -266,9 +267,23 @@ export const RelativeFloatingBox = (props: ModalPositionRelativeProps) => { style, }) }, - [attachTo, matchDimension, offset, position, positionFallbacks, remeasureHint, _style] + [attachTo, matchDimension, offset, position, positionFallbacks, _style] ) + React.useEffect(() => { + const hintChanged = remeasureHintRef.current !== remeasureHint + remeasureHintRef.current = remeasureHint + if (!hintChanged || !popupNode) { + return undefined + } + const frameID = requestAnimationFrame(() => { + setPopupRef(popupNode) + }) + return () => { + cancelAnimationFrame(frameID) + } + }, [popupNode, remeasureHint, setPopupRef]) + React.useEffect(() => { const handleDown = (e: MouseEvent) => { downRef.current = {x: e.clientX, y: e.clientY} diff --git a/shared/common-adapters/toast.native.tsx b/shared/common-adapters/toast.native.tsx index 363a45888856..1edc53087eaf 100644 --- a/shared/common-adapters/toast.native.tsx +++ b/shared/common-adapters/toast.native.tsx @@ -90,16 +90,18 @@ const Toast = (props: Props) => { {props.children} diff --git a/shared/fs/browser/rows/editing.tsx b/shared/fs/browser/rows/editing.tsx index 8552104f67e9..17a881ee100f 100644 --- a/shared/fs/browser/rows/editing.tsx +++ b/shared/fs/browser/rows/editing.tsx @@ -1,4 +1,4 @@ -import * as React from 'react' +import type * as React from 'react' import * as Kb from '@/common-adapters' import {rowStyles} from './common' import * as T from '@/constants/types' diff --git a/shared/fs/footer/use-upload-countdown.tsx b/shared/fs/footer/use-upload-countdown.tsx index e58029398c60..56c8637906a6 100644 --- a/shared/fs/footer/use-upload-countdown.tsx +++ b/shared/fs/footer/use-upload-countdown.tsx @@ -55,7 +55,7 @@ const updateCountdownState = ( return isUploading ? {...state, inputKey} : {glueTTL: state.glueTTL, inputKey, mode: state.glueTTL > 0 ? Mode.Sticky : Mode.Hidden} - case Mode.Sticky: + case Mode.Sticky: { if (isUploading) { return {glueTTL: initialGlueTTL, inputKey, mode: Mode.CountDown} } @@ -64,6 +64,7 @@ const updateCountdownState = ( } const glueTTL = Math.max(0, state.glueTTL - 1) return glueTTL > 0 ? {glueTTL, inputKey, mode: Mode.Sticky} : {glueTTL, inputKey, mode: Mode.Hidden} + } } } diff --git a/shared/settings/files/hooks.tsx b/shared/settings/files/hooks.tsx index 3b76fac69301..20357844d65a 100644 --- a/shared/settings/files/hooks.tsx +++ b/shared/settings/files/hooks.tsx @@ -17,12 +17,13 @@ const useFiles = () => { spaceAvailableNotificationThreshold: 0, syncOnCellular: false, })) + const readSettings = React.useCallback(async () => T.RPCGen.SimpleFSSimpleFSSettingsRpcPromise(), []) const loadSettings = React.useEffectEvent(async (showLoading: boolean) => { if (showLoading) { setSettings(s => ({...s, isLoading: true})) } try { - const next = await T.RPCGen.SimpleFSSimpleFSSettingsRpcPromise() + const next = await readSettings() setSettings({ isLoading: false, spaceAvailableNotificationThreshold: next.spaceAvailableNotificationThreshold, @@ -37,8 +38,28 @@ const useFiles = () => { }) React.useEffect(() => { - C.ignorePromise(loadSettings(false)) - }, []) + let canceled = false + const f = async () => { + try { + const next = await readSettings() + if (!canceled) { + setSettings({ + isLoading: false, + spaceAvailableNotificationThreshold: next.spaceAvailableNotificationThreshold, + syncOnCellular: next.syncOnCellular, + }) + } + } catch { + if (!canceled) { + setSettings(s => ({...s, isLoading: false})) + } + } + } + C.ignorePromise(f()) + return () => { + canceled = true + } + }, [readSettings]) useEngineActionListener('keybase.1.NotifyFS.FSSubscriptionNotify', action => { const {clientID, topic} = action.payload.params diff --git a/shared/teams/common/activity.tsx b/shared/teams/common/activity.tsx index 141f8abb78cc..c677c06985b9 100644 --- a/shared/teams/common/activity.tsx +++ b/shared/teams/common/activity.tsx @@ -52,6 +52,7 @@ const emptyActivityLevelsData: ActivityLevelsData = { channels: emptyChannelActivityLevels, teams: emptyTeamActivityLevels, } +const activityLevelsCacheKey = 'activity' as const const parseActivityLevels = ( results: Awaited> @@ -77,12 +78,12 @@ const parseActivityLevels = ( } const useActivityLevelsRaw = ( - cache: CachedResourceCache, + cache: CachedResourceCache, enabled = true ): ActivityLevels => { const {data, loaded, loading, reload} = useCachedResource({ cache, - cacheKey: 'activity', + cacheKey: activityLevelsCacheKey, enabled, initialData: emptyActivityLevelsData, load: async () => parseActivityLevels(await T.RPCChat.localGetLastActiveForTeamsRpcPromise()), @@ -98,7 +99,10 @@ const useActivityLevelsRaw = ( export const ActivityLevelsProvider = (props: React.PropsWithChildren) => { const {children} = props const [cache] = React.useState(() => - createCachedResourceCache(emptyActivityLevelsData, 'activity') + createCachedResourceCache( + emptyActivityLevelsData, + activityLevelsCacheKey + ) ) const value = useActivityLevelsRaw(cache) return {children} diff --git a/shared/teams/team/rows/index.tsx b/shared/teams/team/rows/index.tsx index e99039d09a4d..bfe4c44e5b30 100644 --- a/shared/teams/team/rows/index.tsx +++ b/shared/teams/team/rows/index.tsx @@ -292,7 +292,9 @@ const useGeneralConversationIDKey = (teamID?: T.Teams.TeamID) => { const findGeneralConvIDFromTeamID = C.useRPC(T.RPCChat.localFindGeneralConvFromTeamIDRpcPromise) const requestIDRef = React.useRef(0) const conversationIDKey = - conversationIDKeyResult?.teamID === teamID ? conversationIDKeyResult.conversationIDKey : undefined + conversationIDKeyResult && conversationIDKeyResult.teamID === teamID + ? conversationIDKeyResult.conversationIDKey + : undefined React.useEffect(() => { if (conversationIDKey || !teamID) { diff --git a/shared/teams/team/settings-tab/default-channels.tsx b/shared/teams/team/settings-tab/default-channels.tsx index 2686a497e78e..5d8870dd600c 100644 --- a/shared/teams/team/settings-tab/default-channels.tsx +++ b/shared/teams/team/settings-tab/default-channels.tsx @@ -66,8 +66,31 @@ export const useDefaultChannels = (teamID: T.Teams.TeamID) => { }, [teamID]) React.useEffect(() => { - reloadDefaultChannels(false) - }, [reloadDefaultChannels]) + const requestVersion = ++requestVersionRef.current + getDefaultChannelsRPC( + [{teamID}], + result => { + if (requestVersion !== requestVersionRef.current) { + return + } + setState({ + defaultChannels: [ + {channelname: 'general', conversationIDKey: 'unused'}, + ...(result.convs || []).map(conv => ({channelname: conv.channel, conversationIDKey: conv.convID})), + ], + error: undefined, + loadedTeamID: teamID, + waiting: false, + }) + }, + err => { + if (requestVersion !== requestVersionRef.current) { + return + } + setState(prev => ({...prev, error: err, loadedTeamID: teamID, waiting: false})) + } + ) + }, [getDefaultChannelsRPC, teamID]) const visibleState = state.loadedTeamID === teamID diff --git a/shared/teams/use-cached-resource.tsx b/shared/teams/use-cached-resource.tsx index d122bfc184fa..6c64c0cd9c12 100644 --- a/shared/teams/use-cached-resource.tsx +++ b/shared/teams/use-cached-resource.tsx @@ -79,7 +79,7 @@ export const createCachedResourceCache = (initialData: T, key: K): CachedR }, getData: () => data, getGeneration: () => generation, - getInFlight: () => inFlight, + getInFlight: (): Promise | undefined => inFlight, getKey: () => storedKey, getLoadedAt: () => loadedAt, invalidate: nextKey => { diff --git a/shared/util/featured-bots.tsx b/shared/util/featured-bots.tsx index 18f983e8baea..f80ce95611b0 100644 --- a/shared/util/featured-bots.tsx +++ b/shared/util/featured-bots.tsx @@ -63,7 +63,9 @@ export const useFeaturedBot = (botUsername?: string) => { } }, [botUsername, searchFeaturedBots]) - return loadedFeaturedBot?.botUsername === botUsername ? loadedFeaturedBot.bot : undefined + return loadedFeaturedBot && loadedFeaturedBot.botUsername === botUsername + ? loadedFeaturedBot.bot + : undefined } export const useFeaturedBotPage = () => { From 5277a28897453b508e371c5aa65d78766bf0db7c Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Sat, 25 Apr 2026 10:08:37 -0400 Subject: [PATCH 21/27] WIP --- .../conversation/attachment-get-titles.tsx | 2 +- shared/chat/conversation/info-panel/index.tsx | 2 +- .../conversation/list-area/index.desktop.tsx | 10 +-- shared/common-adapters/choice-list.native.tsx | 14 +-- .../relative-floating-box.desktop.tsx | 89 +++++++++++++------ shared/fs/footer/use-upload-countdown.tsx | 38 +++++--- shared/teams/use-cached-resource.tsx | 2 +- shared/util/use-debounce.tsx | 14 ++- 8 files changed, 116 insertions(+), 55 deletions(-) diff --git a/shared/chat/conversation/attachment-get-titles.tsx b/shared/chat/conversation/attachment-get-titles.tsx index 86bfa7dedad2..36cdf0e0266f 100644 --- a/shared/chat/conversation/attachment-get-titles.tsx +++ b/shared/chat/conversation/attachment-get-titles.tsx @@ -120,7 +120,7 @@ const Container = (ownProps: OwnProps) => { const [kbfsPreview, setKbfsPreview] = React.useState< {path: string; url: string | undefined} | undefined >() - const kbfsPreviewURL = kbfsPreview?.path === path ? kbfsPreview.url : undefined + const kbfsPreviewURL = kbfsPreview && kbfsPreview.path === path ? kbfsPreview.url : undefined React.useEffect(() => { if (info?.type !== 'image' || info.url || !path || !isKbfsPath(path)) { return diff --git a/shared/chat/conversation/info-panel/index.tsx b/shared/chat/conversation/info-panel/index.tsx index 069c3f9ab711..9cea0a5f1788 100644 --- a/shared/chat/conversation/info-panel/index.tsx +++ b/shared/chat/conversation/info-panel/index.tsx @@ -26,7 +26,7 @@ const InfoPanelConnector = (ownProps: Props) => { const teamname = meta.teamname const {role: yourRole} = useChatTeam(meta.teamID, teamname) - const [uncontrolledSelectedTab, onSelectTab] = React.useState('members') + const [uncontrolledSelectedTab, onSelectTab] = React.useState(() => ownProps.tab ?? 'members') const selectedTab = ownProps.tab ?? uncontrolledSelectedTab const showInfoPanel = ConvoState.useChatContext(s => s.dispatch.showInfoPanel) diff --git a/shared/chat/conversation/list-area/index.desktop.tsx b/shared/chat/conversation/list-area/index.desktop.tsx index ac2e1a9e3cb6..237c8945d496 100644 --- a/shared/chat/conversation/list-area/index.desktop.tsx +++ b/shared/chat/conversation/list-area/index.desktop.tsx @@ -20,6 +20,7 @@ import {copyToClipboard} from '@/util/storeless-actions' // Infinite scrolling list. // We group messages into a series of Waypoints. When the waypoint exits the screen we replace it with a single div instead const scrollOrdinalKey = 'scroll-ordinal-key' +const ordinalsInAWaypoint = 10 // We load the first thread automatically so in order to mark it read // we send an action on the first mount once @@ -345,7 +346,7 @@ const useScrolling = (p: { const waypoints = listRef.current?.querySelectorAll('[data-key]') if (waypoints) { // find an id that should be our parent - const toFind = Math.floor(T.Chat.ordinalToNumber(editingOrdinal) / 10) + const toFind = Math.floor(T.Chat.ordinalToNumber(editingOrdinal) / ordinalsInAWaypoint) const allWaypoints = Array.from(waypoints) as Array const found = findLast(allWaypoints, w => { const key = w.dataset['key'] @@ -366,7 +367,6 @@ const useItems = (p: { editingOrdinal: T.Chat.Ordinal | undefined }) => { const {messageOrdinals, centeredHighlightOrdinal, centeredOrdinal, editingOrdinal} = p - const ordinalsInAWaypoint = 10 const waypointData = React.useMemo(() => { const items: Array<{key: string; ordinals: Array}> = [] const numOrdinals = messageOrdinals.length @@ -391,8 +391,8 @@ const useItems = (p: { ordinals.push(ordinal) } if (ordinals.length) { - // don't allow buckets to be too big, we have sends which can allow > 10 ordinals in a bucket so we split it further - const chunks = chunk(ordinals, 10) + // don't allow buckets to be too big; sends can put more ordinals than expected in one bucket + const chunks = chunk(ordinals, ordinalsInAWaypoint) chunks.forEach((toAdd, cidx) => { const key = `${lastBucket || ''}:${cidx + baseIndex}` items.push({key, ordinals: toAdd}) @@ -413,7 +413,7 @@ const useItems = (p: { }) return items - }, [centeredOrdinal, messageOrdinals, ordinalsInAWaypoint]) + }, [centeredOrdinal, messageOrdinals]) const rowRenderer = (ordinal: T.Chat.Ordinal) => { return ( diff --git a/shared/common-adapters/choice-list.native.tsx b/shared/common-adapters/choice-list.native.tsx index dde6b1453e89..c4b1887757f9 100644 --- a/shared/common-adapters/choice-list.native.tsx +++ b/shared/common-adapters/choice-list.native.tsx @@ -8,12 +8,16 @@ import type {Props} from './choice-list' const Kb = {Box2, ClickableBox, IconAuto, Text} +const makeOptionsKey = (options: Props['options']) => + options.map(option => `${option.title}:${option.description}:${String(option.icon)}`).join('|') + const ChoiceList = (props: Props) => { const {options} = props - const [active, setActive] = React.useState<{index?: number; options: Props['options']}>(() => ({ - options, + const optionsKey = makeOptionsKey(options) + const [active, setActive] = React.useState<{index?: number; optionsKey: string}>(() => ({ + optionsKey, })) - const activeIndex = active.options === options ? active.index : undefined + const activeIndex = active.optionsKey === optionsKey ? active.index : undefined return ( @@ -24,8 +28,8 @@ const ChoiceList = (props: Props) => { key={idx} underlayColor={Styles.globalColors.blueLighter2} onClick={op.onClick} - onPressIn={() => setActive({index: idx, options})} - onPressOut={() => setActive({options})} + onPressIn={() => setActive({index: idx, optionsKey})} + onPressOut={() => setActive({optionsKey})} > diff --git a/shared/common-adapters/popup/floating-box/relative-floating-box.desktop.tsx b/shared/common-adapters/popup/floating-box/relative-floating-box.desktop.tsx index 9fba0d9229d5..6b8267778faf 100644 --- a/shared/common-adapters/popup/floating-box/relative-floating-box.desktop.tsx +++ b/shared/common-adapters/popup/floating-box/relative-floating-box.desktop.tsx @@ -235,6 +235,49 @@ type PopupState = { style: Styles.StylesCrossPlatform } +const stylesEqual = (left: Styles.StylesCrossPlatform, right: Styles.StylesCrossPlatform) => { + if (left === right) { + return true + } + const leftRecord = left as Record + const rightRecord = right as Record + const leftKeys = Object.keys(leftRecord) + const rightKeys = Object.keys(rightRecord) + return ( + leftKeys.length === rightKeys.length && + leftKeys.every(key => Object.is(leftRecord[key], rightRecord[key])) + ) +} + +const popupStatesEqual = (left: PopupState | undefined, right: PopupState) => + !!left && left.node === right.node && stylesEqual(left.style, right.style) + +const makePopupState = ( + node: HTMLDivElement, + attachTo: React.RefObject | undefined, + position: Styles.Position, + matchDimension: boolean | undefined, + positionFallbacks: ReadonlyArray | undefined, + offset: number, + style: Styles.StylesCrossPlatform | undefined +): PopupState => { + const targetRect = attachTo?.current?.getBoundingClientRect() + const popupStyle = targetRect + ? Styles.collapseStyles([ + computePopupStyle( + position, + targetRect, + node.getBoundingClientRect(), + !!matchDimension, + positionFallbacks, + offset + ), + style, + ]) + : hiddenStyle + return {node, style: popupStyle} +} + export const RelativeFloatingBox = (props: ModalPositionRelativeProps) => { const [popupState, setPopupState] = React.useState() const downRef = React.useRef(undefined) @@ -243,32 +286,13 @@ export const RelativeFloatingBox = (props: ModalPositionRelativeProps) => { const remeasureHintRef = React.useRef(remeasureHint) const popupNode = popupState?.node - const setPopupRef = React.useCallback( - (node: HTMLDivElement | null) => { - if (!node) { - return - } - const targetRect = attachTo?.current?.getBoundingClientRect() - const style = targetRect - ? Styles.collapseStyles([ - computePopupStyle( - position, - targetRect, - node.getBoundingClientRect(), - !!matchDimension, - positionFallbacks, - offset - ), - _style, - ]) - : hiddenStyle - setPopupState({ - node, - style, - }) - }, - [attachTo, matchDimension, offset, position, positionFallbacks, _style] - ) + const setPopupRef = (node: HTMLDivElement | null) => { + if (!node) { + return + } + const nextState = makePopupState(node, attachTo, position, matchDimension, positionFallbacks, offset, _style) + setPopupState(prev => (popupStatesEqual(prev, nextState) ? prev : nextState)) + } React.useEffect(() => { const hintChanged = remeasureHintRef.current !== remeasureHint @@ -277,12 +301,21 @@ export const RelativeFloatingBox = (props: ModalPositionRelativeProps) => { return undefined } const frameID = requestAnimationFrame(() => { - setPopupRef(popupNode) + const nextState = makePopupState( + popupNode, + attachTo, + position, + matchDimension, + positionFallbacks, + offset, + _style + ) + setPopupState(prev => (popupStatesEqual(prev, nextState) ? prev : nextState)) }) return () => { cancelAnimationFrame(frameID) } - }, [popupNode, remeasureHint, setPopupRef]) + }, [attachTo, matchDimension, offset, popupNode, position, positionFallbacks, remeasureHint, _style]) React.useEffect(() => { const handleDown = (e: MouseEvent) => { diff --git a/shared/fs/footer/use-upload-countdown.tsx b/shared/fs/footer/use-upload-countdown.tsx index 56c8637906a6..79a5edb1e742 100644 --- a/shared/fs/footer/use-upload-countdown.tsx +++ b/shared/fs/footer/use-upload-countdown.tsx @@ -36,31 +36,39 @@ type UploadCountdownState = { mode: Mode } -const makeInputKey = (isOnline: boolean, files: number, totalSyncingBytes: number, endEstimate: number, now: number) => - `${isOnline}:${files}:${totalSyncingBytes}:${endEstimate}:${now}` +const makeInputKey = (isOnline: boolean, files: number, totalSyncingBytes: number, endEstimate: number) => + `${isOnline}:${files}:${totalSyncingBytes}:${endEstimate}` const updateCountdownState = ( state: UploadCountdownState, isUploading: boolean, displayDuration: number, - inputKey: string + inputKey: string, + isTick = false ): UploadCountdownState => { - if (state.inputKey === inputKey) { + if (state.inputKey === inputKey && !isTick) { return state } switch (state.mode) { case Mode.Hidden: - return isUploading ? {glueTTL: initialGlueTTL, inputKey, mode: Mode.CountDown} : {...state, inputKey} + if (isUploading) { + return {glueTTL: initialGlueTTL, inputKey, mode: Mode.CountDown} + } + return state.inputKey === inputKey ? state : {...state, inputKey} case Mode.CountDown: - return isUploading - ? {...state, inputKey} - : {glueTTL: state.glueTTL, inputKey, mode: state.glueTTL > 0 ? Mode.Sticky : Mode.Hidden} + if (isUploading) { + return state.inputKey === inputKey ? state : {...state, inputKey} + } + return {glueTTL: state.glueTTL, inputKey, mode: state.glueTTL > 0 ? Mode.Sticky : Mode.Hidden} case Mode.Sticky: { if (isUploading) { return {glueTTL: initialGlueTTL, inputKey, mode: Mode.CountDown} } if (displayDuration !== 0) { - return {...state, inputKey} + return state.inputKey === inputKey ? state : {...state, inputKey} + } + if (!isTick) { + return state.inputKey === inputKey ? state : {...state, inputKey} } const glueTTL = Math.max(0, state.glueTTL - 1) return glueTTL > 0 ? {glueTTL, inputKey, mode: Mode.Sticky} : {glueTTL, inputKey, mode: Mode.Hidden} @@ -74,7 +82,7 @@ export const useUploadCountdown = (p: UploadCountdownHOCProps) => { const [now, setNow] = React.useState(() => Date.now()) const displayDuration = endEstimate ? endEstimate - now : 0 const isUploading = isOnline && (!!files || !!totalSyncingBytes) - const inputKey = makeInputKey(isOnline, files, totalSyncingBytes, endEstimate || 0, now) + const inputKey = makeInputKey(isOnline, files, totalSyncingBytes, endEstimate || 0) const [countdownState, setCountdownState] = React.useState(() => updateCountdownState({glueTTL: 0, inputKey: '', mode: Mode.Hidden}, isUploading, displayDuration, inputKey) ) @@ -87,11 +95,17 @@ export const useUploadCountdown = (p: UploadCountdownHOCProps) => { if (visibleCountdownState.mode === Mode.Hidden) { return } - const tickerID = setInterval(() => setNow(Date.now()), tickInterval) + const tickerID = setInterval(() => { + const nextNow = Date.now() + setNow(nextNow) + setCountdownState(state => + updateCountdownState(state, isUploading, endEstimate ? endEstimate - nextNow : 0, inputKey, true) + ) + }, tickInterval) return () => { clearInterval(tickerID) } - }, [visibleCountdownState.mode]) + }, [endEstimate, inputKey, isUploading, visibleCountdownState.mode]) return { debugToggleShow, diff --git a/shared/teams/use-cached-resource.tsx b/shared/teams/use-cached-resource.tsx index 6c64c0cd9c12..9306d1222b0b 100644 --- a/shared/teams/use-cached-resource.tsx +++ b/shared/teams/use-cached-resource.tsx @@ -155,7 +155,7 @@ export const useCachedResource = (props: Props) => { resetCache, staleMs, }) - React.useEffect(() => { + React.useLayoutEffect(() => { latestRef.current = { cache, cacheKey, diff --git a/shared/util/use-debounce.tsx b/shared/util/use-debounce.tsx index 2df3d14c6423..669b31e872ae 100644 --- a/shared/util/use-debounce.tsx +++ b/shared/util/use-debounce.tsx @@ -136,7 +136,12 @@ export function useDebouncedCallback( return next }, [leading, trailing, waitMs]) - React.useEffect(() => () => debounced.cancel(), [debounced]) + React.useLayoutEffect(() => { + runtimeRef.current = {} + return () => { + debounced.cancel() + } + }, [debounced]) return debounced } @@ -242,7 +247,12 @@ export function useThrottledCallback( return next }, [leading, trailing, waitMs]) - React.useEffect(() => () => throttled.cancel(), [throttled]) + React.useLayoutEffect(() => { + runtimeRef.current = {} + return () => { + throttled.cancel() + } + }, [throttled]) return throttled } From d90b37830e53cf30c5ed5054f3970f9a2d9de771 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Sat, 25 Apr 2026 10:24:42 -0400 Subject: [PATCH 22/27] WIP --- plans/use-effects-lint-todo.md | 151 --------------------------------- 1 file changed, 151 deletions(-) delete mode 100644 plans/use-effects-lint-todo.md diff --git a/plans/use-effects-lint-todo.md b/plans/use-effects-lint-todo.md deleted file mode 100644 index 0bffdba564a6..000000000000 --- a/plans/use-effects-lint-todo.md +++ /dev/null @@ -1,151 +0,0 @@ -# React Effect Lint TODO - -Source log: `/Users/ChrisNojima/Downloads/temp.log`. - -Scope: only `react-hooks/set-state-in-effect` findings. Ignore `refs`, `immutability`, `purity`, `preserve-manual-memoization`, and TypeScript diagnostics unless they are directly touched by a set-state-in-effect fix. - -## Batch Rules - -- Before starting each batch, read `skill/react-effect-lints/SKILL.md` and use its workflow. -- For each lint, classify the effect first: derived render data, initial state only, identity reset, partial state adjustment, user-event consequence, or external sync/async request. -- Do not fix these by deferring the state update with `setTimeout`, `Promise.resolve`, `queueMicrotask`, `deferEffectUpdate`, or a similar wrapper. -- Do not trade a `react-hooks/set-state-in-effect` fix for another React lint violation. Avoid mutating refs during render, doing side effects during render, adding unguarded render-time `setState`, breaking hook dependency rules, or silencing React lint rules. -- Preserve behavior, guards, platform branches, route params, waiting keys, and stale async protection. -- Remove unused state, refs, imports, helpers, and comments after each item. -- After each batch, update this checklist in the same change. -- On this machine, do not run `yarn`, `npm`, `yarn lint`, or `yarn tsc` because `node_modules` is unavailable. Use pure code review plus `git diff --check`. If working on a machine where repo node tooling is available and allowed, run the focused lint/type checks for the touched files. - -## Batch 1: Chat Info Panel - -- [x] `shared/chat/conversation/info-panel/attachments.tsx:453:7` - already handled with `skill/react-effect-lints`; moved attachment-view load into `onAttachmentViewChange` and removed `lastSAV`. -- [x] `shared/chat/conversation/info-panel/index.tsx:55:5` - moved the prop tab correction to a guarded render update so the prop-provided tab still wins without an effect. -- [x] `shared/chat/conversation/info-panel/members.tsx:56:7` - replaced last-team-name state with a ref that gates the refresh RPC. - -## Batch 2: Chat Modals And Bot Install - -- [x] `shared/chat/blocking/block-modal.tsx:244:7` - moved default block settings into state initializers and left the effect for the one-time external block-state refresh. -- [x] `shared/chat/conversation/attachment-get-titles.tsx:122:5` - tags KBFS preview URLs by path and derives stale previews as absent instead of clearing preview state in the effect. -- [x] `shared/chat/conversation/bot/install.tsx:68:5` - derived the visible conversation ID from the input conversation or a team-tagged async lookup result. -- [x] `shared/chat/conversation/bot/install.tsx:242:5` - tagged loaded bot public commands by username instead of clearing command state in an effect. - -## Batch 3: Chat Input And Inbox State - -- [x] `shared/chat/conversation/input-area/normal/index.tsx:264:5` - derived exploding-mode seconds directly from conversation state instead of mirroring it in local state. -- [x] `shared/chat/conversation/input-area/normal/input.native.tsx:233:5` - replaced the emoji picker repeat guard state with an effect-local ref. -- [x] `shared/chat/inbox/use-inbox-state.tsx:45:5` - keyed small-row and expansion state by username so username changes render defaults without a reset effect. -- [x] `shared/chat/user-emoji.tsx:34:7` - tagged emoji request completion and derived loading instead of synchronously clearing loading in the effect. - -## Batch 4: Chat Message Wrappers And Timers - -- [x] `shared/chat/conversation/list-area/index.desktop.tsx:662:9` - preserved waypoint virtualization height with a guarded ref-callback state update instead of state set from an effect. -- [x] `shared/chat/conversation/messages/wrapper/exploding-height-retainer/index.desktop.tsx:20:7` - derived the active burn animation from retained-message state and left the effect only to finish the timer. -- [x] `shared/chat/conversation/messages/wrapper/exploding-height-retainer/index.native.tsx:135:7` - moved random emoji child creation into the animated listener path instead of a render-data effect. -- [x] `shared/chat/conversation/messages/wrapper/exploding-meta.tsx:82:7` - initialized countdown state from keyed message/pending inputs instead of starting it in an effect. -- [x] `shared/chat/conversation/messages/wrapper/exploding-meta.tsx:105:9` - keyed pending transitions so countdown state restarts without a pending-change reset effect. -- [x] `shared/chat/conversation/messages/wrapper/send-indicator.tsx:86:7` - derived status from props plus local timer state and kept effects for the encrypting/sent timers only. - -## Batch 5: Chat Conversation Container And Search - -- [x] `shared/chat/conversation/normal/container.tsx:40:5` - keyed orange-line state by conversation and let the load effect fetch without synchronously resetting state. -- [x] `shared/chat/conversation/normal/container.tsx:79:9` - folded mobile app-state clearing into the guarded orange-line state adjustment during render. -- [x] `shared/chat/conversation/search.tsx:254:5` - keyed thread-search internals by conversation/query instead of clearing search state in an effect. -- [x] `shared/chat/conversation/search.tsx:264:5` - seeded initial-query state on mount and started the request without syncing input state from an effect. -- [x] `shared/chat/conversation/team-hooks.tsx:290:10` - split team-member fetching from state application so the initial effect starts an async request without calling the reload state setter. -- [x] `shared/chat/conversation/team-hooks.tsx:373:10` - split team-name fetching from state application so the initial effect starts an async request while visible results stay keyed by requested team IDs. - -## Batch 6: Common Adapter Components - -- [x] `shared/common-adapters/choice-list.native.tsx:16:5` - keyed the active press state by the current options array so option changes render with no active item without an effect reset. -- [x] `shared/common-adapters/copy-text.tsx:97:11` - moved copy-after-load into the copy request callback path and kept toast hiding as a timer effect. -- [x] `shared/common-adapters/phone-input.tsx:198:7` - derived the country picker selected value from the latest selected prop plus local picker edits instead of syncing it in an effect. -- [x] `shared/common-adapters/phone-input.tsx:388:7` - converted the late default-country initialization to a guarded render state adjustment. -- [x] `shared/common-adapters/popup/floating-box/index.desktop.tsx:18:5` - moved anchor measurement out of an effect and into the floating-box commit ref path. -- [x] `shared/common-adapters/popup/floating-box/relative-floating-box.desktop.tsx:284:7` - derived popup style from measured popup/anchor rects collected by the popup ref callback. -- [x] `shared/common-adapters/save-indicator.tsx:35:9` - moved saving-state transitions into a guarded render update and left only the saved-state timeout effect. -- [x] `shared/common-adapters/toast.native.tsx:33:7` - derived render visibility from the visible prop plus delayed hide state and kept animation/timer effects free of synchronous show updates. -- [x] `shared/common-adapters/zoomable-image.desktop.tsx:46:7` - set zoom toast visibility from the zoom click path and left the effect only to expire the toast timer. - -## Batch 7: Desktop And Remote Surfaces - -- [x] `shared/desktop/remote/remote-component.desktop.tsx:41:5` - keyed received remote props by component/param and used a guarded render reset instead of clearing props in the subscription effect. -- [x] `shared/menubar/remote-proxy.desktop.tsx:281:7` - moved logout/user-switch TLF clearing into guarded render state while the effect only invalidates pending loads and starts enabled refreshes. -- [x] `shared/pinentry/remote-proxy.desktop.tsx:78:7` - moved popup hiding on logout into a guarded render reset and left the effect to clear remote action handlers only. - -## Batch 8: Files, Devices, Git, Incoming Share, Wallets - -- [x] `shared/devices/device-revoke.tsx:144:5` - derived the visible device from props or a matching loaded device instead of mirroring `ownProps.device`. -- [x] `shared/fs/browser/rows/editing.tsx:27:5` - removed filename mirror state and writes edits directly through `editSession.setEditName`. -- [x] `shared/fs/common/hooks.tsx:884:7` - kept known-path version invalidation but derives known path info instead of synchronously resetting cache state. -- [x] `shared/fs/common/hooks.tsx:1040:7` - kept non-file version invalidation and derives empty file context for non-file/stale keys. -- [x] `shared/fs/common/use-files-tab-upload-icon.tsx:63:7` - invalidates pending badge loads on disconnect and derives the disconnected icon as `undefined`. -- [x] `shared/fs/footer/upload.desktop.tsx:18:7` - derives upload draw state from `showing` plus delayed hide completion, leaving the effect to own the hide timer. -- [x] `shared/fs/footer/use-upload-countdown.tsx:72:11` - moved countdown state transitions into a guarded render state machine and left the effect to own the interval. -- [x] `shared/git/index.tsx:141:5` - replaced route-param expansion effect/ref with guarded route-key expansion state. -- [x] `shared/incoming-share/index.tsx:309:5` - derives Android share items from `androidShare` instead of syncing local state. -- [x] `shared/wallets/really-remove-account.tsx:33:5` - keys the loaded secret key by `accountID` and protects stale async callbacks instead of clearing in the effect. - -## Batch 9: Login, Profile, Provision - -- [x] `shared/login/relogin/container.tsx:68:5` - replaced default-user sync with a guarded reset keyed by the selected default username. -- [x] `shared/login/relogin/container.tsx:73:7` - replaced the need-password error latch effect with a guarded same-component state update. -- [x] `shared/profile/add-to-team.tsx:181:5` - keyed the inner add-to-team component by username so username changes reset local form state before loading. -- [x] `shared/profile/generic/proofs-list.tsx:680:5` - replaced initial-username sync with guarded username state. -- [x] `shared/profile/generic/proofs-list.tsx:684:5` - replaced error prop sync with guarded error state while preserving submit-cleared local errors. -- [x] `shared/profile/generic/proofs-list.tsx:761:5` - replaced generic step username sync with guarded username state. -- [x] `shared/profile/use-proof-suggestions.tsx:100:5` - keys proof-suggestion results by enabled/load state and hides disabled or stale results without a clearing effect. -- [x] `shared/provision/code-page/container.tsx:71:5` - replaced default-tab sync with guarded tab state. - -## Batch 10: Settings And Signup - -- [x] `shared/router-v2/account-switcher/index.tsx:142:7` - resets clicked row state with a guarded waiting-key adjustment. -- [x] `shared/settings/account/index.tsx:208:5` - consumes added-email route banner state during guarded render while the effect only clears route params. -- [x] `shared/settings/account/index.tsx:215:5` - consumes added-phone route banner state during guarded render while the effect only clears route params. -- [x] `shared/settings/account/index.tsx:222:5` - clears account banner state with a guarded focus-keyed render adjustment. -- [x] `shared/settings/account/index.tsx:231:7` - derives invalid or verified added-email banner clearing during render. -- [x] `shared/settings/chat.tsx:199:7` - seeds selected team notification settings with a guarded render update. -- [x] `shared/settings/files/hooks.tsx:38:21` - keeps file-settings loading state for explicit refreshes while the initial effect performs its own cancellable async settings read. -- [x] `shared/settings/proxy.tsx:123:9` - keys proxy form state to loaded `proxyData` via guarded render adjustment. -- [x] `shared/signup/device-name.tsx:139:7` - sanitizes the device name in the initializer and input handler instead of an effect. - -## Batch 11: Teams Entry Forms And Permissions - -- [x] `shared/teams/add-members-wizard/confirm.tsx:49:5` - replaced wizard mirror state with guarded render adjustment keyed by the incoming wizard. -- [x] `shared/teams/add-members-wizard/confirm.tsx:301:5` - removed redundant role mirror state and derives from the wizard role. -- [x] `shared/teams/channel/create-channels.tsx:21:5` - keyed an inner component by `teamID` for identity resets. -- [x] `shared/teams/channel/header.tsx:15:5` - tags recent-joins results by `conversationIDKey` with stale callback cleanup. -- [x] `shared/teams/common/enable-contacts.tsx:16:5` - derives popup visibility from `noAccess` plus local dismissal state. -- [x] `shared/teams/common/use-contacts.native.tsx:93:7` - tags contact load state by permission/region key and derives visible loading/error state. -- [x] `shared/teams/common/use-contacts.native.tsx:123:7` - derives permanent no-access state from permission status instead of writing local state. -- [x] `shared/teams/emojis/add-alias.tsx:47:26` - seeds and guards alias selection from `defaultSelected` while keeping focus as the only effect. - -## Batch 12: Teams Loading And Navigation - -- [x] `shared/teams/external-team.tsx:22:5` - tags team-info results by teamname and request ID, deriving waiting from the matching result. -- [x] `shared/teams/join-team/container.tsx:45:5` - keyed an inner join-team component by `initialTeamname`. -- [x] `shared/teams/join-team/join-from-invite.tsx:45:5` - keyed an inner invite component by invite identity. -- [x] `shared/teams/new-team/wizard/new-team-info.tsx:65:7` - tags debounced team-name validation results by teamname and derives stale or too-short names as available. -- [x] `shared/teams/role-picker.tsx:270:5` - uses a guarded render reset for `presetRole`. -- [x] `shared/teams/team/index.tsx:103:5` - scopes team-local collapsed/filter state by `teamID`. -- [x] `shared/teams/team/member/index.new.tsx:109:5` - tags team-tree membership state by team/user and splits explicit reload from initial load. -- [x] `shared/teams/team/rows/index.tsx:294:5` - tags general-conversation lookup results by `teamID` and preserves request stale guards. -- [x] `shared/teams/team/rows/index.tsx:365:7` - removes trigger mirror state and loads emoji directly from the trigger effect. - -## Batch 13: Teams Settings And Cached Resource - -- [x] `shared/teams/team/settings-tab/default-channels.tsx:67:19` - starts the initial default-channel RPC directly from the effect callback path without calling the reload setter. -- [x] `shared/teams/team/settings-tab/index.tsx:439:5` - replaces reset key state/effect with a derived settings key. -- [x] `shared/teams/team/settings-tab/retention/index.tsx:63:7` - removes modal-open mirror state and reset effect. -- [x] `shared/teams/team/settings-tab/retention/index.tsx:105:11` - moves retention warning/save logic into the menu selection handler. -- [x] `shared/teams/team/settings-tab/retention/index.tsx:118:9` - derives saving state from pending policy instead of policy-change effect writes. -- [x] `shared/teams/team/settings-tab/retention/index.tsx:475:10` - inlines initial retention loading as a cancellable async effect with request-version stale protection. -- [x] `shared/teams/team/team-info.tsx:75:5` - tags draft team name by source name and derives resets when the source changes. -- [x] `shared/teams/team/team-info.tsx:79:5` - tags draft description by source description and derives resets when the source changes. -- [x] `shared/teams/use-cached-resource.tsx:146:5` - derives visible cache state for key/cache/enabled mismatches instead of synchronously resetting from the prop-change effect. - -## Batch 14: Utilities And App Global Error - -- [x] `shared/app/global-errors.tsx:76:5` - derives size from the current error plus expanded-error state and clears the expanded marker when the error is gone. -- [x] `shared/util/featured-bots.tsx:41:7` - keys featured-bot results by username with cancellation instead of clearing on empty username. -- [x] `shared/util/featured-bots.tsx:91:7` - drives featured-bot page loads from pending-page state and settles async results with cancellation guards. -- [x] `shared/util/phone-numbers/index.tsx:236:7` - initializes from the cached default country and lets the async loader update without a synchronous cache set in the effect. -- [x] `shared/util/use-intersection-observer.desktop.tsx:44:5` - creates and subscribes the observer directly in the layout effect instead of mirroring observer state. From 596e30db6434b2afaabe8dd28935084681a0e241 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Sat, 25 Apr 2026 10:24:55 -0400 Subject: [PATCH 23/27] WIP --- .../use-intersection-observer.desktop.tsx | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/shared/util/use-intersection-observer.desktop.tsx b/shared/util/use-intersection-observer.desktop.tsx index f256baa6f0e9..baaf4a57d61e 100644 --- a/shared/util/use-intersection-observer.desktop.tsx +++ b/shared/util/use-intersection-observer.desktop.tsx @@ -24,15 +24,19 @@ function useIntersectionObserver( time: 0, })) const thresholdKey = JSON.stringify(threshold) + const getThreshold = React.useEffectEvent(() => threshold) React.useLayoutEffect(() => { - const observer = getIntersectionObserver({ - pollInterval, - root, - rootMargin, - threshold: JSON.parse(thresholdKey) as IntersectionObserverOptions['threshold'], - useMutationObserver, - }) + const observer = getIntersectionObserver( + { + pollInterval, + root, + rootMargin, + threshold: getThreshold(), + useMutationObserver, + }, + thresholdKey + ) const targetEl = target && 'current' in target ? target.current : target if (!observer || !targetEl) return let didUnsubscribe = false @@ -98,9 +102,9 @@ const _intersectionObserver: Map< Record> > = new Map() -function getIntersectionObserver(options: IntersectionObserverOptions) { +function getIntersectionObserver(options: IntersectionObserverOptions, thresholdKey: string) { const {root, ...keys} = options - const key = JSON.stringify(keys) + const key = JSON.stringify({...keys, threshold: thresholdKey}) let base = _intersectionObserver.get(root) if (!base) { base = {} From f903c2367832d49085e6532a7ed8feeb38a9ff6c Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Sat, 25 Apr 2026 10:34:26 -0400 Subject: [PATCH 24/27] WIP --- .../input-area/normal/input.native.tsx | 2 +- shared/constants/chat/common.tsx | 2 +- shared/desktop/app/main-window.desktop.tsx | 4 +- shared/desktop/yarn-helper/font.mts | 3 +- shared/desktop/yarn-helper/log-to-trace.mts | 2 +- shared/eslint.config.mjs | 9 +- shared/package.json | 4 +- .../use-notification-settings.tsx | 2 +- shared/stores/convostate.tsx | 2 +- shared/stores/fs.tsx | 2 +- shared/team-building/index.tsx | 2 +- shared/teams/team/member/index.new.tsx | 2 +- shared/teams/team/rows/invite-row/invite.tsx | 13 +- .../team/settings-tab/retention/index.tsx | 2 +- .../util/platform-specific/index.native.tsx | 4 +- shared/yarn.lock | 177 ++++++++---------- skill/update-dependencies/SKILL.md | 10 +- 17 files changed, 117 insertions(+), 125 deletions(-) diff --git a/shared/chat/conversation/input-area/normal/input.native.tsx b/shared/chat/conversation/input-area/normal/input.native.tsx index f2785fff933d..205a2ebd966f 100644 --- a/shared/chat/conversation/input-area/normal/input.native.tsx +++ b/shared/chat/conversation/input-area/normal/input.native.tsx @@ -112,7 +112,7 @@ export function Input(p: InputLowLevelProps) { const commonStyle = Kb.Styles.collapseStyles([inputLowLevelStyles.common, textStyle]) const lineHeight = textStyle.lineHeight - let lineStyle = new Array() + let lineStyle: Array if (multiline) { const defaultRowsToShow = Math.min(2, rowsMax ?? 2) const paddingStyles = padding ? Kb.Styles.padding(Kb.Styles.globalMargins[padding]) : {} diff --git a/shared/constants/chat/common.tsx b/shared/constants/chat/common.tsx index 4e51cdec4016..934d22f10b50 100644 --- a/shared/constants/chat/common.tsx +++ b/shared/constants/chat/common.tsx @@ -19,7 +19,7 @@ export {isSplit, threadRouteName} from './layout' export const isUserActivelyLookingAtThisThread = (conversationIDKey: T.Chat.ConversationIDKey) => { const selectedConversationIDKey = getSelectedConversation() - let chatThreadSelected = false + let chatThreadSelected: boolean if (!isSplit) { chatThreadSelected = true // conversationIDKey === selectedConversationIDKey is the only thing that matters in the new router } else { diff --git a/shared/desktop/app/main-window.desktop.tsx b/shared/desktop/app/main-window.desktop.tsx index b49a28b75a59..e3592f16e1fa 100644 --- a/shared/desktop/app/main-window.desktop.tsx +++ b/shared/desktop/app/main-window.desktop.tsx @@ -29,8 +29,8 @@ const setupDefaultSession = () => { return callback(true) } - let ourPathname = '' - let requestPathname = '' + let ourPathname: string + let requestPathname: string try { ourPathname = new URL(htmlFile).pathname requestPathname = new URL(webContents.getURL()).pathname diff --git a/shared/desktop/yarn-helper/font.mts b/shared/desktop/yarn-helper/font.mts index 614a27806849..316b3ec47492 100644 --- a/shared/desktop/yarn-helper/font.mts +++ b/shared/desktop/yarn-helper/font.mts @@ -120,7 +120,8 @@ function updateIconFont(web: boolean) { const error = error_ as {message: string} if (error.message.includes('not found')) { throw new Error( - 'FontForge is required to generate the icon font. Run `yarn`, install FontForge CLI globally, and try again.' + 'FontForge is required to generate the icon font. Run `yarn`, install FontForge CLI globally, and try again.', + {cause: error_} ) } throw error diff --git a/shared/desktop/yarn-helper/log-to-trace.mts b/shared/desktop/yarn-helper/log-to-trace.mts index 1e3723787562..709dce70c640 100644 --- a/shared/desktop/yarn-helper/log-to-trace.mts +++ b/shared/desktop/yarn-helper/log-to-trace.mts @@ -62,7 +62,7 @@ const convertGuiLine = (line: string): Info | undefined => { return } const [, type, time = '', _data = ''] = e - let name = '' + let name: string let args = {} switch (type) { case 'Error': diff --git a/shared/eslint.config.mjs b/shared/eslint.config.mjs index 3652aa582ecc..ae6ce4c1cbb2 100644 --- a/shared/eslint.config.mjs +++ b/shared/eslint.config.mjs @@ -1,3 +1,4 @@ +import {fixupConfigRules} from '@eslint/compat' import eslint from '@eslint/js' import pluginPromise from 'eslint-plugin-promise' import reactPlugin from 'eslint-plugin-react' @@ -180,18 +181,18 @@ export default [ ...reactHooks.configs.flat.recommended, }, pluginPromise.configs['flat/recommended'], - { + ...fixupConfigRules({ name: 'react', ...reactPlugin.configs.flat.recommended, settings: { ...reactPlugin.configs.flat.recommended.settings, react: {version: 'detect'}, }, - }, - { + }), + ...fixupConfigRules({ name: 'react-jsx', ...reactPlugin.configs.flat['jsx-runtime'], - }, + }), { ignores: [...ignores, '**/*.js'], files: ['**/*.mts', '**/*.ts', '**/*.tsx', '**/*.d.ts', '**/*.native.tsx', '**/*.desktop.tsx'], diff --git a/shared/package.json b/shared/package.json index 4c39e4501d8c..1eb2d7e50b49 100644 --- a/shared/package.json +++ b/shared/package.json @@ -168,7 +168,9 @@ "cross-env": "10.1.0", "css-loader": "7.1.4", "electron": "41.2.2", - "eslint": "9.39.2", + "@eslint/compat": "2.0.5", + "@eslint/js": "10.0.1", + "eslint": "10.2.1", "eslint-plugin-deprecation": "3.0.0", "eslint-plugin-promise": "7.2.1", "eslint-plugin-react": "7.37.5", diff --git a/shared/settings/notifications/use-notification-settings.tsx b/shared/settings/notifications/use-notification-settings.tsx index 87dcc9aa641f..ba68fc8a7d65 100644 --- a/shared/settings/notifications/use-notification-settings.tsx +++ b/shared/settings/notifications/use-notification-settings.tsx @@ -190,7 +190,7 @@ const useNotificationSettings = (): UseNotificationSettingsResult => { ignorePromise(maybeClear()) const f = async () => { - let body = '' + let body: string let chatGlobalSettings: T.RPCChat.GlobalAppNotificationSettings try { diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index 788063cddb2a..1315f345481f 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -2927,7 +2927,7 @@ const createSlice = }) } catch (err) { logger.error('Failed to save attachment: ' + err) - throw new Error('Failed to save attachment: ' + err) + throw new Error('Failed to save attachment: ' + err, {cause: err}) } } ignorePromise(f()) diff --git a/shared/stores/fs.tsx b/shared/stores/fs.tsx index e59442997435..37a4d1d006cf 100644 --- a/shared/stores/fs.tsx +++ b/shared/stores/fs.tsx @@ -476,7 +476,7 @@ export const useFSState = Z.createZustand('fs', (set, get) => { } const f = async () => { - let shouldRefreshDaemonStatus = false + let shouldRefreshDaemonStatus: boolean try { while (isCurrentAsyncGeneration(generation)) { const {syncingPaths, totalSyncingBytes, endEstimate} = diff --git a/shared/team-building/index.tsx b/shared/team-building/index.tsx index a2e5e20496d2..8591bab70c34 100644 --- a/shared/team-building/index.tsx +++ b/shared/team-building/index.tsx @@ -16,7 +16,7 @@ import {useSharedValue} from '@/common-adapters/reanimated' const deriveSelectedUsers = (teamSoFar: ReadonlySet): Array => [...teamSoFar].map(userInfo => { - let username = '' + let username: string let serviceId: T.TB.ServiceIdWithContact if (userInfo.contact && userInfo.serviceMap.keybase) { // resolved contact - pass username @ 'keybase' to teambox diff --git a/shared/teams/team/member/index.new.tsx b/shared/teams/team/member/index.new.tsx index a5cf3b2c75d1..1f3c8048fa77 100644 --- a/shared/teams/team/member/index.new.tsx +++ b/shared/teams/team/member/index.new.tsx @@ -355,7 +355,7 @@ const TeamMember = (props: OwnProps) => { if (error.result.error.willSkipAncestors) { failedAt.push('its parent teams') } - let failedAtStr = '' + let failedAtStr: string if (failedAt.length > 1) { const last = failedAt.pop() failedAtStr = failedAt.join(', ') + ', and ' + last diff --git a/shared/teams/team/rows/invite-row/invite.tsx b/shared/teams/team/rows/invite-row/invite.tsx index 0bb376bc9adc..96eaecbd3a35 100644 --- a/shared/teams/team/rows/invite-row/invite.tsx +++ b/shared/teams/team/rows/invite-row/invite.tsx @@ -92,16 +92,11 @@ const Container = (ownProps: OwnProps) => { const user = [...invites].find(invite => invite.id === ownProps.id) || Teams.emptyInviteInfo - let label: string = '' - let subLabel: undefined | string - let role: T.Teams.TeamRoleType = 'reader' - let isKeybaseUser = false - + let label = user.username || user.name || user.email || user.phone + let subLabel: undefined | string = user.name ? user.phone || user.email : undefined + const role = user.role + const isKeybaseUser = !!user.username const onCancelInvite = () => _onCancelInvite(ownProps.id) - label = user.username || user.name || user.email || user.phone - subLabel = user.name ? user.phone || user.email : undefined - role = user.role - isKeybaseUser = !!user.username if (!subLabel && labelledInviteRegex.test(label)) { const match = labelledInviteRegex.exec(label)! label = match[1] ?? '' diff --git a/shared/teams/team/settings-tab/retention/index.tsx b/shared/teams/team/settings-tab/retention/index.tsx index a1d53060a34b..ed3230104ada 100644 --- a/shared/teams/team/settings-tab/retention/index.tsx +++ b/shared/teams/team/settings-tab/retention/index.tsx @@ -194,7 +194,7 @@ const RetentionDisplay = ( entityType: RetentionEntityType } & Props ) => { - let convType = '' + let convType: string switch (props.entityType) { case 'big team': convType = 'team' diff --git a/shared/util/platform-specific/index.native.tsx b/shared/util/platform-specific/index.native.tsx index f374d77d53d6..94601a8f854e 100644 --- a/shared/util/platform-specific/index.native.tsx +++ b/shared/util/platform-specific/index.native.tsx @@ -98,7 +98,7 @@ export const showShareActionSheet = async (options: { await androidShareText(options.message, options.mimeType) return {completed: true, method: ''} } catch (e) { - throw new Error('Failed to share: ' + String(e)) + throw new Error('Failed to share: ' + String(e), {cause: e}) } } @@ -106,7 +106,7 @@ export const showShareActionSheet = async (options: { await androidShare(options.filePath ?? '', options.mimeType) return {completed: true, method: ''} } catch (e) { - throw new Error('Failed to share: ' + String(e)) + throw new Error('Failed to share: ' + String(e), {cause: e}) } } } diff --git a/shared/yarn.lock b/shared/yarn.lock index b52a7dad2b75..78a934dc7d4d 100644 --- a/shared/yarn.lock +++ b/shared/yarn.lock @@ -1403,65 +1403,57 @@ dependencies: eslint-visitor-keys "^3.4.3" -"@eslint-community/regexpp@^4.12.1", "@eslint-community/regexpp@^4.12.2": +"@eslint-community/regexpp@^4.12.2": version "4.12.2" resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b" integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== -"@eslint/config-array@^0.21.1": - version "0.21.2" - resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.21.2.tgz#f29e22057ad5316cf23836cee9a34c81fffcb7e6" - integrity sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw== +"@eslint/compat@2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@eslint/compat/-/compat-2.0.5.tgz#65421b3f6e5a864e0255ab31884fb26fdc4d0210" + integrity sha512-IbHDbHJfkVNv6xjlET8AIVo/K1NQt7YT4Rp6ok/clyBGcpRx1l6gv0Rq3vBvYfPJIZt6ODf66Zq08FJNDpnzgg== dependencies: - "@eslint/object-schema" "^2.1.7" - debug "^4.3.1" - minimatch "^3.1.5" + "@eslint/core" "^1.2.1" -"@eslint/config-helpers@^0.4.2": - version "0.4.2" - resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.4.2.tgz#1bd006ceeb7e2e55b2b773ab318d300e1a66aeda" - integrity sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw== +"@eslint/config-array@^0.23.5": + version "0.23.5" + resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.5.tgz#56e86d243049195d8acc0c06a1b3dfdc3fa3de95" + integrity sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA== dependencies: - "@eslint/core" "^0.17.0" + "@eslint/object-schema" "^3.0.5" + debug "^4.3.1" + minimatch "^10.2.4" -"@eslint/core@^0.17.0": - version "0.17.0" - resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.17.0.tgz#77225820413d9617509da9342190a2019e78761c" - integrity sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ== +"@eslint/config-helpers@^0.5.5": + version "0.5.5" + resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.5.5.tgz#ae16134e4792ac5fbdc533548a24ac1ea9f7f3ae" + integrity sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w== dependencies: - "@types/json-schema" "^7.0.15" + "@eslint/core" "^1.2.1" -"@eslint/eslintrc@^3.3.1": - version "3.3.5" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.5.tgz#c131793cfc1a7b96f24a83e0a8bbd4b881558c60" - integrity sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg== +"@eslint/core@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.2.1.tgz#c1da7cd1b82fa8787f98b5629fb811848a1b63ce" + integrity sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ== dependencies: - ajv "^6.14.0" - debug "^4.3.2" - espree "^10.0.1" - globals "^14.0.0" - ignore "^5.2.0" - import-fresh "^3.2.1" - js-yaml "^4.1.1" - minimatch "^3.1.5" - strip-json-comments "^3.1.1" + "@types/json-schema" "^7.0.15" -"@eslint/js@9.39.2": - version "9.39.2" - resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.2.tgz#2d4b8ec4c3ea13c1b3748e0c97ecd766bdd80599" - integrity sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA== +"@eslint/js@10.0.1": + version "10.0.1" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-10.0.1.tgz#1e8a876f50117af8ab67e47d5ad94d38d6622583" + integrity sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA== -"@eslint/object-schema@^2.1.7": - version "2.1.7" - resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.7.tgz#6e2126a1347e86a4dedf8706ec67ff8e107ebbad" - integrity sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA== +"@eslint/object-schema@^3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.5.tgz#88e9bf4d11d2b19c082e78ebe7ce88724a5eb091" + integrity sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw== -"@eslint/plugin-kit@^0.4.1": - version "0.4.1" - resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz#9779e3fd9b7ee33571a57435cf4335a1794a6cb2" - integrity sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA== +"@eslint/plugin-kit@^0.7.1": + version "0.7.1" + resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz#c4125fd015eceeb09b793109fdbcd4dd0a02d346" + integrity sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ== dependencies: - "@eslint/core" "^0.17.0" + "@eslint/core" "^1.2.1" levn "^0.4.1" "@expo/cli@55.0.26": @@ -3371,6 +3363,11 @@ "@types/estree" "*" "@types/json-schema" "*" +"@types/esrecurse@^4.3.1": + version "4.3.1" + resolved "https://registry.yarnpkg.com/@types/esrecurse/-/esrecurse-4.3.1.tgz#6f636af962fbe6191b830bd676ba5986926bccec" + integrity sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw== + "@types/estree@*", "@types/estree@^1.0.0", "@types/estree@^1.0.6", "@types/estree@^1.0.8": version "1.0.8" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" @@ -4270,7 +4267,7 @@ ajv-keywords@^5.1.0: dependencies: fast-deep-equal "^3.1.3" -ajv@^6.12.4, ajv@^6.12.5, ajv@^6.14.0: +ajv@^6.12.5, ajv@^6.14.0: version "6.14.0" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.14.0.tgz#fd067713e228210636ebb08c60bd3765d6dbe73a" integrity sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw== @@ -4897,7 +4894,7 @@ brace-expansion@^2.0.1, brace-expansion@^2.0.2: dependencies: balanced-match "^1.0.0" -brace-expansion@^5.0.2: +brace-expansion@^5.0.2, brace-expansion@^5.0.5: version "5.0.5" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.5.tgz#dcc3a37116b79f3e1b46db994ced5d570e930fdb" integrity sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ== @@ -6192,11 +6189,13 @@ eslint-scope@5.1.1: esrecurse "^4.3.0" estraverse "^4.1.1" -eslint-scope@^8.4.0: - version "8.4.0" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-8.4.0.tgz#88e646a207fad61436ffa39eb505147200655c82" - integrity sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg== +eslint-scope@^9.1.2: + version "9.1.2" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-9.1.2.tgz#b9de6ace2fab1cff24d2e58d85b74c8fcea39802" + integrity sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ== dependencies: + "@types/esrecurse" "^4.3.1" + "@types/estree" "^1.0.8" esrecurse "^4.3.0" estraverse "^5.2.0" @@ -6210,42 +6209,34 @@ eslint-visitor-keys@^3.4.3: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== -eslint-visitor-keys@^4.2.1: - version "4.2.1" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1" - integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== - -eslint-visitor-keys@^5.0.0: +eslint-visitor-keys@^5.0.0, eslint-visitor-keys@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be" integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== -eslint@9.39.2: - version "9.39.2" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.39.2.tgz#cb60e6d16ab234c0f8369a3fe7cc87967faf4b6c" - integrity sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw== +eslint@10.2.1: + version "10.2.1" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.2.1.tgz#224b2a6caeb34473eddcf918762363e2e063222a" + integrity sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q== dependencies: "@eslint-community/eslint-utils" "^4.8.0" - "@eslint-community/regexpp" "^4.12.1" - "@eslint/config-array" "^0.21.1" - "@eslint/config-helpers" "^0.4.2" - "@eslint/core" "^0.17.0" - "@eslint/eslintrc" "^3.3.1" - "@eslint/js" "9.39.2" - "@eslint/plugin-kit" "^0.4.1" + "@eslint-community/regexpp" "^4.12.2" + "@eslint/config-array" "^0.23.5" + "@eslint/config-helpers" "^0.5.5" + "@eslint/core" "^1.2.1" + "@eslint/plugin-kit" "^0.7.1" "@humanfs/node" "^0.16.6" "@humanwhocodes/module-importer" "^1.0.1" "@humanwhocodes/retry" "^0.4.2" "@types/estree" "^1.0.6" - ajv "^6.12.4" - chalk "^4.0.0" + ajv "^6.14.0" cross-spawn "^7.0.6" debug "^4.3.2" escape-string-regexp "^4.0.0" - eslint-scope "^8.4.0" - eslint-visitor-keys "^4.2.1" - espree "^10.4.0" - esquery "^1.5.0" + eslint-scope "^9.1.2" + eslint-visitor-keys "^5.0.1" + espree "^11.2.0" + esquery "^1.7.0" esutils "^2.0.2" fast-deep-equal "^3.1.3" file-entry-cache "^8.0.0" @@ -6255,26 +6246,25 @@ eslint@9.39.2: imurmurhash "^0.1.4" is-glob "^4.0.0" json-stable-stringify-without-jsonify "^1.0.1" - lodash.merge "^4.6.2" - minimatch "^3.1.2" + minimatch "^10.2.4" natural-compare "^1.4.0" optionator "^0.9.3" -espree@^10.0.1, espree@^10.4.0: - version "10.4.0" - resolved "https://registry.yarnpkg.com/espree/-/espree-10.4.0.tgz#d54f4949d4629005a1fa168d937c3ff1f7e2a837" - integrity sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ== +espree@^11.2.0: + version "11.2.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-11.2.0.tgz#01d5e47dc332aaba3059008362454a8cc34ccaa5" + integrity sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw== dependencies: - acorn "^8.15.0" + acorn "^8.16.0" acorn-jsx "^5.3.2" - eslint-visitor-keys "^4.2.1" + eslint-visitor-keys "^5.0.1" esprima@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== -esquery@^1.5.0: +esquery@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d" integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== @@ -7100,11 +7090,6 @@ global-agent@^3.0.0: semver "^7.3.2" serialize-error "^7.0.1" -globals@^14.0.0: - version "14.0.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" - integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== - globalthis@^1.0.1, globalthis@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.4.tgz#7430ed3a975d97bfb59bcce41f5cabbafa651236" @@ -7497,7 +7482,7 @@ immer@11.1.4: resolved "https://registry.yarnpkg.com/immer/-/immer-11.1.4.tgz#37aee86890b134a8f1a2fadd44361fb86c6ae67e" integrity sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw== -import-fresh@^3.2.1, import-fresh@^3.3.0: +import-fresh@^3.3.0: version "3.3.1" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf" integrity sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ== @@ -8464,7 +8449,7 @@ js-yaml@^3.13.1: argparse "^1.0.7" esprima "^4.0.0" -js-yaml@^4.1.0, js-yaml@^4.1.1: +js-yaml@^4.1.0: version "4.1.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== @@ -8767,11 +8752,6 @@ lodash.debounce@^4.0.8: resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== -lodash.merge@^4.6.2: - version "4.6.2" - resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" - integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== - lodash.throttle@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4" @@ -9419,7 +9399,14 @@ minimatch@^10.0.1, minimatch@^10.1.1, minimatch@^10.2.2: dependencies: brace-expansion "^5.0.2" -minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2, minimatch@^3.1.5: +minimatch@^10.2.4: + version "10.2.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.5.tgz#bd48687a0be38ed2961399105600f832095861d1" + integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== + dependencies: + brace-expansion "^5.0.5" + +minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.5" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e" integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w== diff --git a/skill/update-dependencies/SKILL.md b/skill/update-dependencies/SKILL.md index 5ecfe4c090bb..91db1691d583 100644 --- a/skill/update-dependencies/SKILL.md +++ b/skill/update-dependencies/SKILL.md @@ -19,13 +19,19 @@ These are pinned to the Expo SDK version — do not touch: These are outdated but blocked due to known compatibility issues. Always echo that you are skipping them: ``` -Skipping eslint — held back: eslint plugins not yet compatible with newer major versions Skipping react-error-boundary — held back: v6.x not compatible with our bundling setup ``` -- **`eslint`** — plugins not yet updated for newer major version compatibility - **`react-error-boundary`** — v6.x not compatible with our bundling setup +## ESLint 10 notes + +ESLint was upgraded to v10. The following were added to support it: +- `@eslint/js` — previously bundled with ESLint 9, now a separate package +- `@eslint/compat` — used in `eslint.config.mjs` via `fixupConfigRules` to wrap `eslint-plugin-react` (which still uses deprecated `context.getFilename()` API removed in ESLint 10) + +If updating `eslint-plugin-react` to a version that supports ESLint 10 natively, remove the `fixupConfigRules` wrapper in `eslint.config.mjs` and potentially drop `@eslint/compat`. + ## Process ### 1. Check what's outdated From c3822b3e8806f65cf1a3ac37ac09d7abf0d04873 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Sat, 25 Apr 2026 10:37:07 -0400 Subject: [PATCH 25/27] WIP --- shared/chat/user-emoji.tsx | 12 +++++++----- .../floating-box/relative-floating-box.desktop.tsx | 5 +---- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/shared/chat/user-emoji.tsx b/shared/chat/user-emoji.tsx index 67532c2a333b..f6f2b0ea2a81 100644 --- a/shared/chat/user-emoji.tsx +++ b/shared/chat/user-emoji.tsx @@ -78,10 +78,11 @@ export const useUserEmoji = ({ if (requestIDRef.current !== requestID) { return } - setLoadState(state => ({ - ...state, + setLoadState({ completedKey: requestKey, - })) + emojiGroups: emptyEmojiGroups, + emojis: emptyEmojis, + }) } ) @@ -92,9 +93,10 @@ export const useUserEmoji = ({ } }, [conversationIDKey, disabled, loadUserEmoji, requestKey, requestOnlyInTeam]) + const isCurrent = loadState.completedKey === requestKey return { - emojiGroups: disabled ? undefined : loadState.emojiGroups, - emojis: loadState.emojis, + emojiGroups: disabled ? undefined : isCurrent ? loadState.emojiGroups : emptyEmojiGroups, + emojis: isCurrent ? loadState.emojis : emptyEmojis, loading: !disabled && loadState.completedKey !== requestKey, } } diff --git a/shared/common-adapters/popup/floating-box/relative-floating-box.desktop.tsx b/shared/common-adapters/popup/floating-box/relative-floating-box.desktop.tsx index 6b8267778faf..14719365be47 100644 --- a/shared/common-adapters/popup/floating-box/relative-floating-box.desktop.tsx +++ b/shared/common-adapters/popup/floating-box/relative-floating-box.desktop.tsx @@ -283,7 +283,6 @@ export const RelativeFloatingBox = (props: ModalPositionRelativeProps) => { const downRef = React.useRef(undefined) const {attachTo, children, propagateOutsideClicks, onClosePopup, style: _style} = props const {position, matchDimension, positionFallbacks, disableEscapeKey, offset = 0, remeasureHint} = props - const remeasureHintRef = React.useRef(remeasureHint) const popupNode = popupState?.node const setPopupRef = (node: HTMLDivElement | null) => { @@ -295,9 +294,7 @@ export const RelativeFloatingBox = (props: ModalPositionRelativeProps) => { } React.useEffect(() => { - const hintChanged = remeasureHintRef.current !== remeasureHint - remeasureHintRef.current = remeasureHint - if (!hintChanged || !popupNode) { + if (!popupNode) { return undefined } const frameID = requestAnimationFrame(() => { From 8257d24fb8d9a16c4e23086721dd3a8f22e1e267 Mon Sep 17 00:00:00 2001 From: Chris Nojima Date: Sat, 25 Apr 2026 17:35:36 -0400 Subject: [PATCH 26/27] WIP --- shared/common-adapters/copy-text.tsx | 47 +++++++++++++++------ shared/teams/join-team/join-from-invite.tsx | 10 ++--- 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/shared/common-adapters/copy-text.tsx b/shared/common-adapters/copy-text.tsx index 15543ccbd9ae..e12fcf7dd3e1 100644 --- a/shared/common-adapters/copy-text.tsx +++ b/shared/common-adapters/copy-text.tsx @@ -40,10 +40,29 @@ const CopyText = (props: Props) => { const [showingToast, setShowingToast] = React.useState(false) const shareSheet = props.shareSheet && Styles.isMobile const copyRequestIDRef = React.useRef(0) + const copyOnLoadedRequestIDRef = React.useRef(0) + const popupAnchor = React.useRef(null) + + const doCopy = (t: string) => { + if (shareSheet) { + showShareActionSheet('', t, 'text/plain') + } else { + setShowingToast(true) + copyToClipboard(t) + } + onCopy?.() + if (hideOnCopy) { + setRevealed(false) + } + } + const doCopyLoadedText = React.useEffectEvent((loadedText: string) => { + doCopy(loadedText) + }) React.useEffect(() => { return () => { copyRequestIDRef.current += 1 + copyOnLoadedRequestIDRef.current = 0 } }, []) @@ -70,19 +89,15 @@ const CopyText = (props: Props) => { } }, [withReveal, text, loadText]) - const popupAnchor = React.useRef(null) - const doCopy = (t: string) => { - if (shareSheet) { - showShareActionSheet('', t, 'text/plain') - } else { - setShowingToast(true) - copyToClipboard(t) - } - onCopy?.() - if (hideOnCopy) { - setRevealed(false) + React.useEffect(() => { + const requestID = copyOnLoadedRequestIDRef.current + if (!requestID || !text || copyRequestIDRef.current !== requestID) { + return } - } + copyRequestIDRef.current = requestID + 1 + copyOnLoadedRequestIDRef.current = 0 + doCopyLoadedText(text) + }, [text]) const copy = () => { if (!text) { @@ -92,9 +107,15 @@ const CopyText = (props: Props) => { } const requestID = copyRequestIDRef.current + 1 copyRequestIDRef.current = requestID + copyOnLoadedRequestIDRef.current = requestID loadText(loadedText => { - if (copyRequestIDRef.current === requestID && loadedText) { + if ( + copyRequestIDRef.current === requestID && + copyOnLoadedRequestIDRef.current === requestID && + loadedText + ) { copyRequestIDRef.current = requestID + 1 + copyOnLoadedRequestIDRef.current = 0 doCopy(loadedText) } }) diff --git a/shared/teams/join-team/join-from-invite.tsx b/shared/teams/join-team/join-from-invite.tsx index b48ab3bdf5e0..b3e27707c497 100644 --- a/shared/teams/join-team/join-from-invite.tsx +++ b/shared/teams/join-team/join-from-invite.tsx @@ -27,12 +27,10 @@ const getInviteError = (error: unknown, missingKey: boolean) => { return error instanceof Error ? error.message : 'Something went wrong.' } -const JoinFromInvite = (props: Props) => ( - -) +const getInviteIdentityKey = ({inviteDetails, inviteID = '', inviteKey = ''}: Props) => + `${inviteID || inviteDetails?.inviteID || ''}:${inviteKey}` + +const JoinFromInvite = (props: Props) => const JoinFromInviteInner = ({inviteDetails: initialInviteDetails, inviteID = '', inviteKey = ''}: Props) => { const [details, setDetails] = React.useState(initialInviteDetails) From ad3b33014da5a9402af0578319699e2e2a0170c7 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Sat, 25 Apr 2026 17:56:34 -0400 Subject: [PATCH 27/27] WIP --- shared/eslint.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/eslint.config.mjs b/shared/eslint.config.mjs index ae6ce4c1cbb2..36efc12c25a9 100644 --- a/shared/eslint.config.mjs +++ b/shared/eslint.config.mjs @@ -225,7 +225,7 @@ export default [ 'no-constant-condition': ['warn', {checkLoops: false}], 'no-implied-eval': 'error', 'no-script-url': 'error', - 'no-undeff': 'off', + 'no-undef': 'off', 'no-self-compare': 'error', 'no-sequences': 'error', 'prefer-const': 'error',