diff --git a/AGENTS.md b/AGENTS.md index ea97b06782a4..5d13a9cf791e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,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/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/app/index.native.tsx b/shared/app/index.native.tsx index 8a2ed25a4ed9..783d0376645e 100644 --- a/shared/app/index.native.tsx +++ b/shared/app/index.native.tsx @@ -32,32 +32,29 @@ 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 {} +} + 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 => { @@ -124,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/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/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/attachment-get-titles.tsx b/shared/chat/conversation/attachment-get-titles.tsx index a5e28e1d12c5..36cdf0e0266f 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 && 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 01b093e9e387..84f6d2e0909b 100644 --- a/shared/chat/conversation/bot/install.tsx +++ b/shared/chat/conversation/bot/install.tsx @@ -60,13 +60,20 @@ 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 && generalConversation.teamID === teamID + ? generalConversation.conversationIDKey + : undefined) React.useEffect(() => { requestIDRef.current += 1 @@ -85,7 +92,7 @@ export const useBotConversationIDKey = (inConvIDKey?: T.Chat.ConversationIDKey, return } ConvoState.metasReceived([meta]) - setConversationIDKey(meta.conversationIDKey) + setGeneralConversation({conversationIDKey: meta.conversationIDKey, teamID}) }, () => {} ) @@ -135,7 +142,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 +161,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 +253,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 +272,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/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/shared/chat/conversation/info-panel/index.tsx b/shared/chat/conversation/info-panel/index.tsx index de2d3e6cf694..9cea0a5f1788 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,8 +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 [lastSNO, setLastSNO] = React.useState(shouldNavigateOut) + const [uncontrolledSelectedTab, onSelectTab] = React.useState(() => ownProps.tab ?? 'members') + const selectedTab = ownProps.tab ?? uncontrolledSelectedTab const showInfoPanel = ConvoState.useChatContext(s => s.dispatch.showInfoPanel) const clearAttachmentView = ConvoState.useConvoState(conversationIDKey, s => s.dispatch.clearAttachmentView) @@ -43,17 +42,15 @@ const InfoPanelConnector = (ownProps: Props) => { clearAttachmentView() } }, [showInfoPanel, clearAttachmentView]) - if (lastSNO !== shouldNavigateOut) { - setLastSNO(shouldNavigateOut) - if (!lastSNO && shouldNavigateOut) { - navigateToInbox() - } - } + const lastShouldNavigateOutRef = React.useRef(shouldNavigateOut) React.useEffect(() => { - if (ownProps.tab === undefined || ownProps.tab === selectedTab) return - onSelectTab(ownProps.tab) - }, [ownProps.tab, selectedTab]) + const lastShouldNavigateOut = lastShouldNavigateOutRef.current + lastShouldNavigateOutRef.current = shouldNavigateOut + if (!lastShouldNavigateOut && shouldNavigateOut) { + navigateToInbox() + } + }, [shouldNavigateOut]) 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 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 ( () + let lineStyle: Array if (multiline) { const defaultRowsToShow = Math.min(2, rowsMax ?? 2) const paddingStyles = padding ? Kb.Styles.padding(Kb.Styles.globalMargins[padding]) : {} @@ -225,15 +225,17 @@ const Buttons = function Buttons(p: ButtonsProps) { const emojiStr = usePickerState(s => 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/conversation/list-area/index.desktop.tsx b/shared/chat/conversation/list-area/index.desktop.tsx index 920fcd8e165b..237c8945d496 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' @@ -21,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 @@ -346,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'] @@ -367,32 +367,8 @@ const useItems = (p: { editingOrdinal: T.Chat.Ordinal | undefined }) => { const {messageOrdinals, centeredHighlightOrdinal, centeredOrdinal, editingOrdinal} = p - const ordinalsInAWaypoint = 10 - const rowRenderer = (ordinal: T.Chat.Ordinal) => { - return ( -
- - -
- ) - } - - 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 = [] + const waypointData = React.useMemo(() => { + const items: Array<{key: string; ordinals: Array}> = [] const numOrdinals = messageOrdinals.length let ordinals: Array = [] @@ -415,21 +391,11 @@ 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}` - let wayOrdinals = toAdd - const existing = wayOrdinalCachRef.current.get(key) - if (existing && shallowEqual(existing, wayOrdinals)) { - wayOrdinals = existing - } else { - wayOrdinalCachRef.current.set(key, wayOrdinals) - } - - items.push( - - ) + items.push({key, ordinals: toAdd}) }) // we pass previous so the OrdinalWaypoint can render the top item correctly ordinals = [] @@ -438,22 +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 - let wayOrdinals = [ordinal] - const existing = wayOrdinalCachRef.current.get(key) - if (existing && shallowEqual(existing, wayOrdinals)) { - wayOrdinals = existing - } else { - wayOrdinalCachRef.current.set(key, wayOrdinals) - } - items.push( - - ) + items.push({key: scrollOrdinalKey, ordinals: [ordinal]}) lastBucket = 0 baseIndex++ // push this up if we drop the centered ordinal waypoint } else { @@ -461,8 +412,36 @@ const useItems = (p: { } }) - return [, ...items, ] - })() + return items + }, [centeredOrdinal, messageOrdinals]) + + const rowRenderer = (ordinal: T.Chat.Ordinal) => { + return ( +
+ + +
+ ) + } + + const items = [ + , + ...waypointData.map(({key, ordinals}) => ( + + )), + , + ] return items } @@ -637,36 +616,23 @@ 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 [wRef, setRef] = React.useState(null) + const [setContentRef] = React.useState(() => (ref: HTMLDivElement | null) => { + if (ref) { + const height = ref.offsetHeight + if (height) { + setHeight(oldHeight => (oldHeight === height ? oldHeight : 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 { content = } 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' 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..5938d0be63e1 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,27 +246,34 @@ 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 loadMemberInfos = React.useCallback( + async (teamID: T.Teams.TeamID) => + Teams.rpcDetailsToMemberInfos((await T.RPCGen.teamsTeamGetMembersByIDRpcPromise({id: teamID})) ?? []), + [] + ) + + 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})) ?? [] - ) + const members = await loadMemberInfos(validTeamID) if (requestVersion !== requestVersionRef.current) { return } @@ -277,22 +289,64 @@ 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, loadMemberInfos, 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]) + 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 reload() - }, [reload]) + void loadMembers() + }, [loadMembers]) ) useEngineActionListener('keybase.1.NotifyTeam.teamChangedByID', action => { if (enabled && action.payload.params.teamID === validTeamID) { @@ -321,42 +375,52 @@ 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 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) { - 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 => { - 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, teamnames, }) @@ -365,17 +429,74 @@ 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(), + })) } - }, [clearState, enabled, teamIDsKey, username, validTeamIDs]) + }, [enabled, loadTeamNamesForIDs, teamIDsKey, username, validTeamIDs]) + + const reload = React.useCallback(async () => { + if (!enabled || !teamIDsKey || !username) { + clearState() + return + } + 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]) + 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 reload() - }, [reload]) + void loadTeamNames() + }, [loadTeamNames]) ) useEngineActionListener('keybase.1.NotifyTeam.teamMetadataUpdate', () => { if (enabled && teamIDsKey) { @@ -393,7 +514,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 +524,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 = { 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..f6f2b0ea2a81 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,21 @@ 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({ + completedKey: requestKey, + emojiGroups: emptyEmojiGroups, + emojis: emptyEmojis, + }) } ) @@ -75,11 +91,12 @@ export const useUserEmoji = ({ requestIDRef.current += 1 } } - }, [conversationIDKey, disabled, loadUserEmoji, onlyInTeam]) + }, [conversationIDKey, disabled, loadUserEmoji, requestKey, requestOnlyInTeam]) + const isCurrent = loadState.completedKey === requestKey return { - emojiGroups: disabled ? undefined : emojiGroups, - emojis, - loading: disabled ? false : loading, + emojiGroups: disabled ? undefined : isCurrent ? loadState.emojiGroups : emptyEmojiGroups, + emojis: isCurrent ? loadState.emojis : emptyEmojis, + loading: !disabled && loadState.completedKey !== requestKey, } } diff --git a/shared/common-adapters/choice-list.native.tsx b/shared/common-adapters/choice-list.native.tsx index 49e14c99d29e..c4b1887757f9 100644 --- a/shared/common-adapters/choice-list.native.tsx +++ b/shared/common-adapters/choice-list.native.tsx @@ -8,13 +8,16 @@ import type {Props} from './choice-list' const Kb = {Box2, ClickableBox, IconAuto, Text} -const ChoiceList = (props: Props) => { - const [activeIndex, setActiveIndex] = React.useState(undefined) +const makeOptionsKey = (options: Props['options']) => + options.map(option => `${option.title}:${option.description}:${String(option.icon)}`).join('|') +const ChoiceList = (props: Props) => { const {options} = props - React.useEffect(() => { - setActiveIndex(undefined) - }, [options]) + const optionsKey = makeOptionsKey(options) + const [active, setActive] = React.useState<{index?: number; optionsKey: string}>(() => ({ + optionsKey, + })) + const activeIndex = active.optionsKey === optionsKey ? active.index : undefined return ( @@ -25,8 +28,8 @@ const ChoiceList = (props: Props) => { key={idx} underlayColor={Styles.globalColors.blueLighter2} onClick={op.onClick} - onPressIn={() => setActiveIndex(idx)} - onPressOut={() => setActiveIndex(undefined)} + onPressIn={() => setActive({index: idx, optionsKey})} + onPressOut={() => setActive({optionsKey})} > diff --git a/shared/common-adapters/copy-text.tsx b/shared/common-adapters/copy-text.tsx index 08abd9e0af53..e12fcf7dd3e1 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,52 @@ 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) + const copyOnLoadedRequestIDRef = React.useRef(0) + const popupAnchor = React.useRef(null) - if (lastShowingToast !== showingToast) { - setLastShowingToast(showingToast) - showingToast && setShowingToastFalseLater() + 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 + } + }, []) + + React.useEffect(() => { + if (!showingToast) { + return undefined + } + const id = setTimeout(() => { + setShowingToast(false) + }, 1500) + return () => { + clearTimeout(id) + } + }, [showingToast]) React.useEffect(() => { if (!withReveal && !text) { @@ -60,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) { @@ -80,32 +105,25 @@ 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 + copyOnLoadedRequestIDRef.current = requestID + loadText(loadedText => { + if ( + copyRequestIDRef.current === requestID && + copyOnLoadedRequestIDRef.current === requestID && + loadedText + ) { + copyRequestIDRef.current = requestID + 1 + copyOnLoadedRequestIDRef.current = 0 + 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 +164,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 +175,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..1965fe1d5e3a 100644 --- a/shared/common-adapters/popup/floating-box/index.desktop.tsx +++ b/shared/common-adapters/popup/floating-box/index.desktop.tsx @@ -1,6 +1,4 @@ -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 +6,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,91 @@ type ModalPositionRelativeProps = { offset?: number // offset in pixels from edge } +const hiddenStyle = {opacity: 0, pointerEvents: 'none'} as const + +type PopupState = { + node: HTMLDivElement + 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 [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 = (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(() => { + if (!popupNode) { + return undefined + } + const frameID = requestAnimationFrame(() => { + const nextState = makePopupState( + popupNode, + attachTo, + position, + matchDimension, + positionFallbacks, + offset, + _style + ) + setPopupState(prev => (popupStatesEqual(prev, nextState) ? prev : nextState)) + }) + return () => { + cancelAnimationFrame(frameID) + } + }, [attachTo, matchDimension, offset, popupNode, position, positionFallbacks, remeasureHint, _style]) React.useEffect(() => { const handleDown = (e: MouseEvent) => { @@ -268,27 +348,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..1edc53087eaf 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' @@ -74,16 +90,18 @@ const Toast = (props: Props) => { {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/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/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/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/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/eslint.config.mjs b/shared/eslint.config.mjs index 3652aa582ecc..36efc12c25a9 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'], @@ -224,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', diff --git a/shared/fs/browser/rows/editing.tsx b/shared/fs/browser/rows/editing.tsx index c3ae60339f9f..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' @@ -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..79a5edb1e742 100644 --- a/shared/fs/footer/use-upload-countdown.tsx +++ b/shared/fs/footer/use-upload-countdown.tsx @@ -30,87 +30,88 @@ enum Mode { const tickInterval = 1000 const initialGlueTTL = 2 -export const useUploadCountdown = (p: UploadCountdownHOCProps) => { - const {endEstimate, files, fileName, isOnline, totalSyncingBytes, debugToggleShow, smallMode} = p - const tickerID = React.useRef>(undefined) +type UploadCountdownState = { + glueTTL: number + inputKey: string + mode: Mode +} - 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 makeInputKey = (isOnline: boolean, files: number, totalSyncingBytes: number, endEstimate: number) => + `${isOnline}:${files}:${totalSyncingBytes}:${endEstimate}` - React.useEffect(() => { - return () => { - if (tickerID.current) { - clearInterval(tickerID.current) - tickerID.current = undefined +const updateCountdownState = ( + state: UploadCountdownState, + isUploading: boolean, + displayDuration: number, + inputKey: string, + isTick = false +): UploadCountdownState => { + if (state.inputKey === inputKey && !isTick) { + return state + } + switch (state.mode) { + case Mode.Hidden: + if (isUploading) { + return {glueTTL: initialGlueTTL, inputKey, mode: Mode.CountDown} } - } - }, []) - - React.useEffect(() => { - const startTicker = () => { - if (tickerID.current) { - return + return state.inputKey === inputKey ? state : {...state, inputKey} + case Mode.CountDown: + if (isUploading) { + return state.inputKey === inputKey ? state : {...state, inputKey} } - tickerID.current = setInterval(() => setNow(Date.now()), tickInterval) - } - const stopTicker = () => { - if (!tickerID.current) { - return + 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 === inputKey ? state : {...state, inputKey} } - clearInterval(tickerID.current) - tickerID.current = undefined + 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} } + } +} + +export const useUploadCountdown = (p: UploadCountdownHOCProps) => { + const {endEstimate, files, fileName, isOnline, totalSyncingBytes, debugToggleShow, smallMode} = p - 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 [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) + 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(() => { + if (visibleCountdownState.mode === Mode.Hidden) { + return + } + const tickerID = setInterval(() => { + const nextNow = Date.now() + setNow(nextNow) + setCountdownState(state => + updateCountdownState(state, isUploading, endEstimate ? endEstimate - nextNow : 0, inputKey, true) + ) + }, tickInterval) + return () => { + clearInterval(tickerID) } - }, [isOnline, files, totalSyncingBytes, endEstimate, glueTTL, mode, now]) + }, [endEstimate, inputKey, isUploading, 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/package.json b/shared/package.json index 684de86a7ce7..1eb2d7e50b49 100644 --- a/shared/package.json +++ b/shared/package.json @@ -168,12 +168,14 @@ "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", "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/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 16885c14ac70..1cc233d0fceb 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) } }, []) @@ -673,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) { @@ -756,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..20357844d65a 100644 --- a/shared/settings/files/hooks.tsx +++ b/shared/settings/files/hooks.tsx @@ -17,10 +17,13 @@ const useFiles = () => { spaceAvailableNotificationThreshold: 0, syncOnCellular: false, })) - const loadSettings = React.useEffectEvent(async () => { - setSettings(s => ({...s, isLoading: true})) + 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, @@ -35,13 +38,33 @@ const useFiles = () => { }) React.useEffect(() => { - C.ignorePromise(loadSettings()) - }, []) + 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 if (clientID === fsClientID && topic === T.RPCGen.SubscriptionTopic.settings) { - C.ignorePromise(loadSettings()) + C.ignorePromise(loadSettings(true)) } }) @@ -50,7 +73,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 +88,7 @@ const useFiles = () => { {syncOnCellular}, C.waitingKeyFSSetSyncOnCellular ) - await loadSettings() + await loadSettings(true) refreshGlobalSettings() } catch {} } 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/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 ( ('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/add-members-wizard/confirm.tsx b/shared/teams/add-members-wizard/confirm.tsx index 800731cd6a6f..3a779470f14b 100644 --- a/shared/teams/add-members-wizard/confirm.tsx +++ b/shared/teams/add-members-wizard/confirm.tsx @@ -44,10 +44,15 @@ type TeamAddToTeamConfirmParamList = { const AddMembersConfirm = ({wizard: initialWizard}: Props) => { 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/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/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..b3e27707c497 100644 --- a/shared/teams/join-team/join-from-invite.tsx +++ b/shared/teams/join-team/join-from-invite.tsx @@ -27,7 +27,12 @@ const getInviteError = (error: unknown, missingKey: boolean) => { return error instanceof Error ? error.message : 'Something went wrong.' } -const JoinFromInvite = ({inviteDetails: initialInviteDetails, inviteID = '', inviteKey = ''}: 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) const [error, setError] = React.useState('') const loaded = details !== undefined || !!error @@ -41,13 +46,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..1f3c8048fa77 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, @@ -325,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/index.tsx b/shared/teams/team/rows/index.tsx index ada8b2b4de3e..bfe4c44e5b30 100644 --- a/shared/teams/team/rows/index.tsx +++ b/shared/teams/team/rows/index.tsx @@ -286,19 +286,21 @@ 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 && 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 +313,7 @@ const useGeneralConversationIDKey = (teamID?: T.Teams.TeamID) => { return } ConvoState.metasReceived([meta]) - setConversationIDKey(meta.conversationIDKey) + setConversationIDKeyResult({conversationIDKey: meta.conversationIDKey, teamID}) }, () => {} ) @@ -326,7 +328,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 +359,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/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/default-channels.tsx b/shared/teams/team/settings-tab/default-channels.tsx index a3ad1c74d778..5d8870dd600c 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,32 @@ export const useDefaultChannels = (teamID: T.Teams.TeamID) => { } }, [teamID]) - // Initialize - React.useEffect(reloadDefaultChannels, [reloadDefaultChannels]) + React.useEffect(() => { + 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/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, ] @@ -232,7 +194,7 @@ const RetentionDisplay = ( entityType: RetentionEntityType } & Props ) => { - let convType = '' + let convType: string switch (props.entityType) { case 'big team': convType = 'team' @@ -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 11d106afe3b5..9306d1222b0b 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 = { @@ -15,6 +20,12 @@ type CachedResourceState = { loading: boolean } +type StoredCachedResourceState = CachedResourceState & { + cache: CachedResourceCache + cacheKey: K + initialData: T +} + type Props = { cache: CachedResourceCache cacheKey: K @@ -32,13 +43,70 @@ const emptyState = (data: T): CachedResourceState => ({ loading: false, }) -export const createCachedResourceCache = (initialData: T, key: K): CachedResourceCache => ({ - data: initialData, - generation: 0, - key, - loadedAt: 0, +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 + let inFlight: Promise | undefined + let loadedAt = 0 + let storedKey = key + + return { + clearInFlight: request => { + if (inFlight === request) { + inFlight = undefined + } + }, + getData: () => data, + getGeneration: () => generation, + getInFlight: (): Promise | undefined => 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>, initialData: T, @@ -55,21 +123,15 @@ 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} - : 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) 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] ) @@ -78,81 +140,110 @@ 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 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, + enabled, + initialData, + load, + onError, + resetCache, + staleMs, + }) + React.useLayoutEffect(() => { + latestRef.current = { + cache, + cacheKey, + 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, enabled, initialData, load, onError, resetCache, staleMs]) + + const loadResource = React.useCallback( + async (force: boolean) => { + const {cache, cacheKey, enabled, initialData, load, onError, resetCache, staleMs} = + latestRef.current + if (!Object.is(cache.getKey(), cacheKey)) { + requestVersionRef.current += 1 + resetCache(cacheKey) } - 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) { + requestVersionRef.current += 1 + resetCache(cacheKey) + return } - } catch (error) { - if (requestVersion !== requestVersionRef.current) { + const loadedAt = cache.getLoadedAt() + if (!force && loadedAt && Date.now() - loadedAt < staleMs) { + setState(storedState(cache, cacheKey, initialData, {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.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(storedState(cache, cacheKey, initialData, {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(storedState(cache, cacheKey, initialData, {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} - : emptyState(initialData) - ) - if (!Object.is(cache.key, 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(() => { @@ -167,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/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/featured-bots.tsx b/shared/util/featured-bots.tsx index 9cb34ffb6df5..f80ce95611b0 100644 --- a/shared/util/featured-bots.tsx +++ b/shared/util/featured-bots.tsx @@ -33,64 +33,87 @@ 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 && 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/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/util/use-debounce.tsx b/shared/util/use-debounce.tsx index 0d4755f92a5a..669b31e872ae 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.useLayoutEffect(() => { + 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,49 +90,58 @@ 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]) - React.useEffect(() => () => debounced.cancel(), [debounced]) + React.useLayoutEffect(() => { + runtimeRef.current = {} + return () => { + debounced.cancel() + } + }, [debounced]) return debounced } @@ -134,87 +152,107 @@ export function useThrottledCallback( options?: DebounceOptions ): DebouncedState { const funcRef = React.useRef(func) - funcRef.current = func + React.useLayoutEffect(() => { + 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]) - React.useEffect(() => () => throttled.cancel(), [throttled]) + React.useLayoutEffect(() => { + runtimeRef.current = {} + return () => { + throttled.cancel() + } + }, [throttled]) return throttled } diff --git a/shared/util/use-intersection-observer.desktop.tsx b/shared/util/use-intersection-observer.desktop.tsx index ab1ac9d00bc3..baaf4a57d61e 100644 --- a/shared/util/use-intersection-observer.desktop.tsx +++ b/shared/util/use-intersection-observer.desktop.tsx @@ -23,29 +23,20 @@ function useIntersectionObserver( target: null, time: 0, })) - const [observer, setObserver] = React.useState(() => - getIntersectionObserver({ - pollInterval, - root, - rootMargin, - threshold, - useMutationObserver, - }) - ) - - React.useEffect(() => { - const observer = getIntersectionObserver({ - pollInterval, - root, - rootMargin, - threshold, - useMutationObserver, - }) - setObserver(observer) - // eslint-disable-next-line - }, [root, rootMargin, pollInterval, useMutationObserver, JSON.stringify(threshold)]) + const thresholdKey = JSON.stringify(threshold) + const getThreshold = React.useEffectEvent(() => threshold) React.useLayoutEffect(() => { + 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 @@ -71,7 +62,7 @@ function useIntersectionObserver( observer.observer.unobserve(targetEl) observer.unsubscribe(callback) } - }, [target, observer]) + }, [target, root, rootMargin, pollInterval, useMutationObserver, thresholdKey]) return entry } @@ -111,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 = {} 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) 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 = () => { diff --git a/shared/yarn.lock b/shared/yarn.lock index a718680429b3..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== @@ -6126,7 +6123,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== @@ -6181,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" @@ -6199,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" @@ -6244,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== @@ -7089,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" @@ -7486,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== @@ -8453,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== @@ -8756,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" @@ -9408,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/react-effect-lints/SKILL.md b/skill/react-effect-lints/SKILL.md new file mode 100644 index 000000000000..54e18aefa8b3 --- /dev/null +++ b/skill/react-effect-lints/SKILL.md @@ -0,0 +1,199 @@ +--- +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. 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. 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 + +### 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. + +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) +``` + +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. +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 + +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`. 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