diff --git a/shared/chat/conversation/bot/install.tsx b/shared/chat/conversation/bot/install.tsx index 5eddcd0ad832..627f9d2582c8 100644 --- a/shared/chat/conversation/bot/install.tsx +++ b/shared/chat/conversation/bot/install.tsx @@ -1,4 +1,5 @@ import * as C from '@/constants' +import * as Meta from '@/constants/chat/meta' import * as Chat from '@/stores/chat' import * as Kb from '@/common-adapters' import * as Teams from '@/stores/teams' @@ -15,17 +16,41 @@ const RestrictedItem = '---RESTRICTED---' 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 generalConvID = Chat.useChatState(s => teamID && s.teamIDToGeneralConvID.get(teamID)) - const findGeneralConvIDFromTeamID = Chat.useChatState(s => s.dispatch.findGeneralConvIDFromTeamID) + const findGeneralConvIDFromTeamID = C.useRPC(T.RPCChat.localFindGeneralConvFromTeamIDRpcPromise) + const metasReceived = Chat.useChatState(s => s.dispatch.metasReceived) + const requestIDRef = React.useRef(0) + React.useEffect(() => { - if (!cleanInConvIDKey && teamID) { - if (!generalConvID) { - findGeneralConvIDFromTeamID(teamID) - } else { - setConversationIDKey(generalConvID) + setConversationIDKey(cleanInConvIDKey) + }, [cleanInConvIDKey]) + + React.useEffect(() => { + requestIDRef.current += 1 + if (cleanInConvIDKey || !teamID) { + return + } + const requestID = requestIDRef.current + findGeneralConvIDFromTeamID( + [{teamID}], + conv => { + if (requestIDRef.current !== requestID) { + return + } + const meta = Meta.inboxUIItemToConversationMeta(conv) + if (!meta) { + return + } + metasReceived([meta]) + setConversationIDKey(meta.conversationIDKey) + }, + () => {} + ) + return () => { + if (requestIDRef.current === requestID) { + requestIDRef.current += 1 } } - }, [cleanInConvIDKey, findGeneralConvIDFromTeamID, generalConvID, teamID]) + }, [cleanInConvIDKey, findGeneralConvIDFromTeamID, metasReceived, teamID]) return conversationIDKey } @@ -66,8 +91,8 @@ 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 botPublicCommands = Chat.useChatState(s => s.botPublicCommands.get(botUsername)) const meta = Chat.useChatContext(s => s.meta) const commands = (() => { const {botCommands} = meta @@ -150,13 +175,39 @@ const InstallBotPopup = (props: Props) => { const noCommands = !commands?.commands const dispatchClearWaiting = C.Waiting.useDispatchClearWaiting() - const refreshBotPublicCommands = Chat.useChatState(s => s.dispatch.refreshBotPublicCommands) + const loadBotPublicCommands = C.useRPC(T.RPCChat.localListPublicBotCommandsLocalRpcPromise) + const botPublicCommandsRequestIDRef = React.useRef(0) React.useEffect(() => { dispatchClearWaiting([C.waitingKeyChatBotAdd, C.waitingKeyChatBotRemove]) - if (noCommands) { - refreshBotPublicCommands(botUsername) + botPublicCommandsRequestIDRef.current += 1 + if (!noCommands) { + setBotPublicCommands(undefined) + return + } + setBotPublicCommands(undefined) + const requestID = botPublicCommandsRequestIDRef.current + loadBotPublicCommands( + [{username: botUsername}], + res => { + if (botPublicCommandsRequestIDRef.current !== requestID) { + return + } + const commands = (res.commands ?? []).map(command => command.name) + setBotPublicCommands({commands, loadError: false}) + }, + () => { + if (botPublicCommandsRequestIDRef.current !== requestID) { + return + } + setBotPublicCommands({commands: [], loadError: true}) + } + ) + return () => { + if (botPublicCommandsRequestIDRef.current === requestID) { + botPublicCommandsRequestIDRef.current += 1 + } } - }, [dispatchClearWaiting, refreshBotPublicCommands, noCommands, botUsername]) + }, [botUsername, dispatchClearWaiting, loadBotPublicCommands, noCommands]) const restrictedButton = ( diff --git a/shared/chat/conversation/info-panel/index.tsx b/shared/chat/conversation/info-panel/index.tsx index 477332899f76..e71c098b55f1 100644 --- a/shared/chat/conversation/info-panel/index.tsx +++ b/shared/chat/conversation/info-panel/index.tsx @@ -15,9 +15,7 @@ type Props = { } const InfoPanelConnector = (ownProps: Props) => { - const storeSelectedTab = Chat.useChatState(s => s.infoPanelSelectedTab) - const setInfoPanelTab = Chat.useChatState(s => s.dispatch.setInfoPanelTab) - const initialTab = ownProps.tab ?? storeSelectedTab + const initialTab = ownProps.tab const conversationIDKey = Chat.useChatContext(s => s.id) const meta = Chat.useConvoState(conversationIDKey, s => s.meta) const shouldNavigateOut = meta.conversationIDKey === Chat.noConversationIDKey @@ -52,9 +50,9 @@ const InfoPanelConnector = (ownProps: Props) => { } React.useEffect(() => { - if (selectedTab === storeSelectedTab) return - setInfoPanelTab(selectedTab) - }, [selectedTab, storeSelectedTab, setInfoPanelTab]) + if (ownProps.tab === undefined || ownProps.tab === selectedTab) return + onSelectTab(ownProps.tab) + }, [ownProps.tab, selectedTab]) const getTabs = (): Array> => { const showSettings = !isPreview || Teams.isAdmin(yourRole) || Teams.isOwner(yourRole) diff --git a/shared/chat/conversation/input-area/normal/index.tsx b/shared/chat/conversation/input-area/normal/index.tsx index d6e12dc928dc..8571056cd57e 100644 --- a/shared/chat/conversation/input-area/normal/index.tsx +++ b/shared/chat/conversation/input-area/normal/index.tsx @@ -14,6 +14,8 @@ import {assertionToDisplay} from '@/common-adapters/usernames' import {FocusContext, ScrollContext} from '@/chat/conversation/normal/context' import type {RefType as InputRef} from './input' import {useCurrentUserState} from '@/stores/current-user' +import {useRoute} from '@react-navigation/native' +import type {RootRouteProps} from '@/router-v2/route-params' const useHintText = (p: { isExploding: boolean @@ -107,7 +109,8 @@ const doInjectText = (inputRef: React.RefObject, text: string, } const ConnectedPlatformInput = function ConnectedPlatformInput() { - const infoPanelShowing = Chat.useChatState(s => s.infoPanelShowing) + const route = useRoute | RootRouteProps<'chatRoot'>>() + const infoPanelShowing = route.name === 'chatRoot' ? !!route.params?.infoPanel : false const data = Chat.useChatContext( C.useShallow(s => { const {meta, id: conversationIDKey, editing: editOrdinal, messageMap, unsentText} = s @@ -128,9 +131,9 @@ const ConnectedPlatformInput = function ConnectedPlatformInput() { : explodingMode // prettier-ignore return {cannotWrite, conversationIDKey, convoID, explodingMode, explodingModeSeconds, - infoPanelShowing, isEditing, jumpToRecent, minWriterRole, sendMessage, setEditing, - setExplodingMode, showReplyPreview, storeDraft, suggestBotCommandsUpdateStatus, - tlfname, unsentText, updateUnsentText} + isEditing, jumpToRecent, minWriterRole, sendMessage, setEditing, setExplodingMode, + showReplyPreview, storeDraft, suggestBotCommandsUpdateStatus, tlfname, unsentText, + updateUnsentText} }) ) diff --git a/shared/chat/conversation/input-area/suggestors/emoji.tsx b/shared/chat/conversation/input-area/suggestors/emoji.tsx index 4d6f3a49184a..2c427f4ee24b 100644 --- a/shared/chat/conversation/input-area/suggestors/emoji.tsx +++ b/shared/chat/conversation/input-area/suggestors/emoji.tsx @@ -1,9 +1,8 @@ -import * as C from '@/constants' import * as Chat from '@/stores/chat' import * as Common from './common' import * as Kb from '@/common-adapters' -import * as React from 'react' import {type EmojiData, RPCToEmojiData, emojiData} from '@/common-adapters/emoji' +import {useUserEmoji} from '@/chat/user-emoji' export const transformer = ( emoji: EmojiData, @@ -40,13 +39,7 @@ const empty = new Array() const useDataSource = (filter: string) => { const conversationIDKey = Chat.useChatContext(s => s.id) - const fetchUserEmoji = Chat.useChatState(s => s.dispatch.fetchUserEmoji) - React.useEffect(() => { - fetchUserEmoji(conversationIDKey) - }, [conversationIDKey, fetchUserEmoji]) - - const userEmojisLoading = C.Waiting.useAnyWaiting(C.waitingKeyChatLoadingEmoji) - const userEmojis = Chat.useChatState(s => s.userEmojisForAutocomplete) + const {emojis: userEmojis, loading: userEmojisLoading} = useUserEmoji({conversationIDKey}) if (!emojiPrepass.test(filter)) { return { @@ -58,12 +51,10 @@ const useDataSource = (filter: string) => { // prefill data with stock emoji let results: Array = emojiData.emojiSearch(filter, 50) - if (userEmojis) { - const userEmoji = userEmojis - .filter(emoji => emoji.alias.toLowerCase().includes(filter)) - .map(emoji => RPCToEmojiData(emoji, false)) - results = userEmoji.sort((a, b) => a.short_name.localeCompare(b.short_name)).concat(results) - } + const userEmoji = userEmojis + .filter(emoji => emoji.alias.toLowerCase().includes(filter)) + .map(emoji => RPCToEmojiData(emoji, false)) + results = userEmoji.sort((a, b) => a.short_name.localeCompare(b.short_name)).concat(results) return { items: results, diff --git a/shared/chat/emoji-picker/container.tsx b/shared/chat/emoji-picker/container.tsx index 0f00eaa71add..9e96f13108d7 100644 --- a/shared/chat/emoji-picker/container.tsx +++ b/shared/chat/emoji-picker/container.tsx @@ -11,6 +11,7 @@ import EmojiPicker, {getSkinToneModifierStrIfAvailable} from '.' import {type RenderableEmoji, emojiData} from '@/common-adapters/emoji' import {usePickerState, type PickKey} from './use-picker' import {Keyboard} from 'react-native' +import {useUserEmoji} from '@/chat/user-emoji' type Props = { disableCustomEmoji?: boolean @@ -68,23 +69,12 @@ const useSkinTone = () => { const useCustomReacji = (onlyInTeam: boolean | undefined, disabled?: boolean) => { const conversationIDKey = Chat.useChatContext(s => s.id) - const customEmojiGroups = Chat.useChatState(s => s.userEmojis) - const waiting = C.Waiting.useAnyWaiting(C.waitingKeyChatLoadingEmoji) - const [lastOnlyInTeam, setLastOnlyInTeam] = React.useState(onlyInTeam) - const [lastDisabled, setLastDisabled] = React.useState(disabled) - const fetchUserEmoji = Chat.useChatState(s => s.dispatch.fetchUserEmoji) - - React.useEffect(() => { - if (lastOnlyInTeam !== onlyInTeam || lastDisabled !== disabled) { - setLastOnlyInTeam(onlyInTeam) - setLastDisabled(disabled) - } - if (!disabled) { - fetchUserEmoji(conversationIDKey, onlyInTeam) - } - }, [conversationIDKey, fetchUserEmoji, lastDisabled, lastOnlyInTeam, onlyInTeam, disabled]) - - return disabled ? {customEmojiGroups: undefined, waiting: false} : {customEmojiGroups, waiting} + const {emojiGroups: customEmojiGroups, loading: waiting} = useUserEmoji({ + conversationIDKey, + disabled, + onlyInTeam, + }) + return {customEmojiGroups, waiting} } const useCanManageEmoji = () => { diff --git a/shared/chat/inbox-and-conversation-header.tsx b/shared/chat/inbox-and-conversation-header.tsx index 9543be4eb0bb..3b30dc03ee76 100644 --- a/shared/chat/inbox-and-conversation-header.tsx +++ b/shared/chat/inbox-and-conversation-header.tsx @@ -26,8 +26,9 @@ const Header = () => { } const Header2 = () => { + const {params} = useRoute>() const username = useCurrentUserState(s => s.username) - const infoPanelShowing = Chat.useChatState(s => s.infoPanelShowing) + const infoPanelShowing = !!params?.infoPanel const data = Chat.useChatContext( C.useShallow(s => { const {meta, id, dispatch} = s diff --git a/shared/chat/inbox-and-conversation.tsx b/shared/chat/inbox-and-conversation.tsx index 868ae5fe079d..e57b86e7bca4 100644 --- a/shared/chat/inbox-and-conversation.tsx +++ b/shared/chat/inbox-and-conversation.tsx @@ -7,14 +7,17 @@ import type * as T from '@/constants/types' import Conversation from './conversation/container' import Inbox from './inbox' import InboxSearch from './inbox-search' -import InfoPanel from './conversation/info-panel' +import InfoPanel, {type Panel} from './conversation/info-panel' -type Props = {conversationIDKey?: T.Chat.ConversationIDKey} +type Props = { + conversationIDKey?: T.Chat.ConversationIDKey + infoPanel?: {tab?: Panel} +} function InboxAndConversation(props: Props) { const conversationIDKey = props.conversationIDKey ?? Chat.noConversationIDKey const inboxSearch = Chat.useChatState(s => s.inboxSearch) - const infoPanelShowing = Chat.useChatState(s => s.infoPanelShowing) + const infoPanel = props.infoPanel const validConvoID = conversationIDKey && conversationIDKey !== Chat.noConversationIDKey const seenValidCIDRef = React.useRef(validConvoID ? conversationIDKey : '') const selectNextConvo = Chat.useChatState(s => { @@ -47,9 +50,9 @@ function InboxAndConversation(props: Props) { - {infoPanelShowing ? ( + {infoPanel ? ( - + ) : null} diff --git a/shared/chat/user-emoji.tsx b/shared/chat/user-emoji.tsx new file mode 100644 index 000000000000..dadb2271d90c --- /dev/null +++ b/shared/chat/user-emoji.tsx @@ -0,0 +1,85 @@ +import * as C from '@/constants' +import * as T from '@/constants/types' +import * as React from 'react' + +const emptyEmojiGroups: ReadonlyArray = [] +const emptyEmojis: ReadonlyArray = [] + +const flattenUserEmojis = (groups: ReadonlyArray) => { + const emojis = new Array() + groups.forEach(group => { + group.emojis?.forEach(emoji => emojis.push(emoji)) + }) + return emojis +} + +export const useUserEmoji = ({ + conversationIDKey, + disabled, + onlyInTeam, +}: { + conversationIDKey?: T.Chat.ConversationIDKey + disabled?: boolean + 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 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( + [ + { + convID: + conversationIDKey && conversationIDKey !== T.Chat.noConversationIDKey + ? T.Chat.keyToConversationID(conversationIDKey) + : null, + opts: { + getAliases: true, + getCreationInfo: false, + onlyInTeam: onlyInTeam ?? false, + }, + }, + ], + results => { + if (requestIDRef.current !== requestID) { + return + } + const nextGroups = results.emojis.emojis ?? emptyEmojiGroups + setEmojiGroups(nextGroups) + setEmojis(flattenUserEmojis(nextGroups)) + setLoading(false) + }, + () => { + if (requestIDRef.current !== requestID) { + return + } + setLoading(false) + } + ) + + return () => { + if (requestIDRef.current === requestID) { + requestIDRef.current += 1 + } + } + }, [conversationIDKey, disabled, loadUserEmoji, onlyInTeam]) + + return { + emojiGroups: disabled ? undefined : emojiGroups, + emojis, + loading: disabled ? false : loading, + } +} diff --git a/shared/common-adapters/wave-button.tsx b/shared/common-adapters/wave-button.tsx index db5641fa830c..85cafd2adc78 100644 --- a/shared/common-adapters/wave-button.tsx +++ b/shared/common-adapters/wave-button.tsx @@ -7,8 +7,10 @@ import Text from './text' import Button from './button' import NativeEmoji from './emoji/native-emoji' import * as Styles from '@/styles' -import type * as T from '@/constants/types' +import * as T from '@/constants/types' import logger from '@/logger' +import {storeRegistry} from '@/stores/store-registry' +import {useCurrentUserState} from '@/stores/current-user' const Kb = { Box2, @@ -58,11 +60,38 @@ const WaveButtonImpl = (props: Props) => { const [waved, setWaved] = React.useState(false) const waitingKey = getWaveWaitingKey(props.username || props.conversationIDKey || 'missing') const waving = C.Waiting.useAnyWaiting(waitingKey) + const username = useCurrentUserState(s => s.username) const sendMessage = Chat.useChatContext(s => s.dispatch.sendMessage) - const messageSendByUsername = Chat.useChatState(s => s.dispatch.messageSendByUsername) + const createConversation = C.useRPC(T.RPCChat.localNewConversationLocalRpcPromise) const onWave = () => { if (props.username) { - messageSendByUsername(props.username, ':wave:', waitingKey) + if (!username) { + logger.warn('WaveButton: missing username for direct wave') + return + } + createConversation( + [ + { + identifyBehavior: T.RPCGen.TLFIdentifyBehavior.chatGui, + membersType: T.RPCChat.ConversationMembersType.impteamnative, + tlfName: `${username},${props.username}`, + tlfVisibility: T.RPCGen.TLFVisibility.private, + topicType: T.RPCChat.TopicType.chat, + }, + waitingKey, + ], + result => { + const conversationIDKey = T.Chat.conversationIDToKey(result.conv.info.id) + if (!conversationIDKey) { + logger.warn("WaveButton: couldn't resolve wave conversation") + return + } + storeRegistry.getConvoState(conversationIDKey).dispatch.sendMessage(':wave:') + }, + error => { + logger.warn('Could not send in WaveButton', error.message) + } + ) } else if (props.conversationIDKey) { sendMessage(':wave:') } else { diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index b17c5fea13ce..feb063d639bd 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -359,7 +359,6 @@ export const initSharedSubscriptions = () => { chatPreviewConversation: p => storeRegistry.getState('chat').dispatch.previewConversation(p), chatResetConversationErrored: () => storeRegistry.getState('chat').dispatch.resetConversationErrored(), chatUnboxRows: (convIDs, force) => storeRegistry.getState('chat').dispatch.unboxRows(convIDs, force), - chatUpdateInfoPanel: (show, tab) => storeRegistry.getState('chat').dispatch.updateInfoPanel(show, tab), teamsGetMembers: async teamID => storeRegistry.getState('teams').dispatch.getMembers(teamID), usersGetBio: username => storeRegistry.getState('users').dispatch.getBio(username), }) diff --git a/shared/constants/router.tsx b/shared/constants/router.tsx index 8fda1836ef61..a4f2d2ba269b 100644 --- a/shared/constants/router.tsx +++ b/shared/constants/router.tsx @@ -259,6 +259,37 @@ export const navToProfile = (username: string) => { navigateAppend({name: 'profile', params: {username}}) } +export const setChatRootParams = (params: Partial>) => { + const n = _getNavigator() + if (!n || !isSplit) return + const rs = getRootState() + const tabNavState = rs?.routes?.[0]?.state + if (!tabNavState?.key) return + const tabRoutes = tabNavState.routes as Array + const chatTabIndex = tabRoutes.findIndex(r => r.name === Tabs.chatTab) + if (chatTabIndex < 0) return + const updatedRoutes = tabRoutes.map((route, i) => { + if (i !== chatTabIndex) return route + const currentChatRoot = route.state ? route.state.routes[0] : undefined + const currentParams = + currentChatRoot?.name === 'chatRoot' && currentChatRoot.params && typeof currentChatRoot.params === 'object' + ? currentChatRoot.params + : undefined + return { + ...route, + state: { + ...(route.state ?? {}), + index: 0, + routes: [{name: 'chatRoot', params: {...currentParams, ...params}}], + }, + } + }) + n.dispatch({ + ...CommonActions.reset({...tabNavState, index: chatTabIndex, routes: updatedRoutes} as Parameters[0]), + target: tabNavState.key, + }) +} + export const navToThread = (conversationIDKey: T.Chat.ConversationIDKey) => { DEBUG_NAV && console.log('[Nav] navToThread', conversationIDKey) const n = _getNavigator() @@ -271,19 +302,7 @@ export const navToThread = (conversationIDKey: T.Chat.ConversationIDKey) => { // All tab stacks share the same screen config, so navigate('chatRoot') would target the // current tab. Separate switchTab + navigateAppend has a race (stale state between dispatches). // A single reset on the tab navigator atomically switches tabs and sets params. - const tabNavState = rs.routes?.[0]?.state - if (!tabNavState?.key) return - const tabRoutes = tabNavState.routes as Array - const chatTabIndex = tabRoutes.findIndex(r => r.name === Tabs.chatTab) - if (chatTabIndex < 0) return - const updatedRoutes = tabRoutes.map((route, i) => { - if (i !== chatTabIndex) return route - return {...route, state: {...(route.state ?? {}), index: 0, routes: [{name: 'chatRoot', params: {conversationIDKey}}]}} - }) - n.dispatch({ - ...CommonActions.reset({...tabNavState, index: chatTabIndex, routes: updatedRoutes} as Parameters[0]), - target: tabNavState.key, - }) + setChatRootParams({conversationIDKey}) } else { // Phone: full reset to build the chat → conversation stack const nextState = { diff --git a/shared/constants/strings.tsx b/shared/constants/strings.tsx index 105ac0133c9c..bd5c4925a05e 100644 --- a/shared/constants/strings.tsx +++ b/shared/constants/strings.tsx @@ -18,7 +18,6 @@ export const waitingKeyChatCreating = 'chat:creatingConvo' export const waitingKeyChatInboxSyncStarted = 'chat:inboxSyncStarted' export const waitingKeyChatBotAdd = 'chat:botAdd' export const waitingKeyChatBotRemove = 'chat:botRemove' -export const waitingKeyChatLoadingEmoji = 'chat:loadingEmoji' export const waitingKeyChatThreadLoad = (conversationIDKey: T.Chat.ConversationIDKey) => `chat:loadingThread:${conversationIDKeyToString(conversationIDKey)}` export const waitingKeyChatUnpin = (conversationIDKey: T.Chat.ConversationIDKey) => diff --git a/shared/provision/error.tsx b/shared/provision/error.tsx index 143f0b69f84b..4e25c7087c0b 100644 --- a/shared/provision/error.tsx +++ b/shared/provision/error.tsx @@ -5,7 +5,7 @@ import type * as React from 'react' import LoginContainer from '../login/forms/container' import {openURL} from '@/util/misc' import * as T from '@/constants/types' -import {useProvisionState} from '@/stores/provision' +import {type ProvisionRouteError, useProvisionState} from '@/stores/provision' const Wrapper = (p: {onBack: () => void; children: React.ReactNode}) => ( @@ -29,9 +29,17 @@ const rewriteErrorDesc = (s: string) => { } } +type Props = { + route: { + params: { + error?: ProvisionRouteError + } + } +} + // Normally this would be a component but I want the children to be flat so i can use a Box2 as the parent and have nice gaps -const RenderError = () => { - const error = useProvisionState(s => s.finalError) +const RenderError = ({route}: Props) => { + const error = route.params.error const username = useProvisionState(s => s.username) const startAccountReset = AutoReset.useAutoResetState(s => s.dispatch.startAccountReset) const _onAccountReset = (username: string) => { @@ -58,11 +66,11 @@ const RenderError = () => { ) } - const f = error.fields as Array | undefined + const f = error.fields const fields = f?.reduce<{[key: string]: string}>((acc, f) => { - const k = f && typeof f.key === 'string' ? f.key : '' - acc[k] = f?.value || '' + const k = typeof f.key === 'string' ? f.key : '' + acc[k] = f.value || '' return acc }, {}) ?? {} switch (error.code) { diff --git a/shared/provision/forgot-username.tsx b/shared/provision/forgot-username.tsx index ff79df6f88a9..993129bad078 100644 --- a/shared/provision/forgot-username.tsx +++ b/shared/provision/forgot-username.tsx @@ -2,16 +2,30 @@ import * as C from '@/constants' import * as React from 'react' import * as Kb from '@/common-adapters' import {SignupScreen, errorBanner} from '../signup/common' -import {useProvisionState} from '@/stores/provision' import {useDefaultPhoneCountry} from '@/util/phone-numbers' +import * as T from '@/constants/types' +import type {RPCError} from '@/util/errors' + +const decodeForgotUsernameError = (error: RPCError) => { + switch (error.code) { + case T.RPCGen.StatusCode.scnotfound: + return "We couldn't find an account with that email address. Try again?" + case T.RPCGen.StatusCode.scinputerror: + return "That doesn't look like a valid email address. Try again?" + default: + return error.desc + } +} const ForgotUsername = () => { const defaultCountry = useDefaultPhoneCountry() + const recoverUsernameWithEmail = C.useRPC(T.RPCGen.accountRecoverUsernameWithEmailRpcPromise) + const recoverUsernameWithPhone = C.useRPC(T.RPCGen.accountRecoverUsernameWithPhoneRpcPromise) - const forgotUsernameResult = useProvisionState(s => s.forgotUsernameResult) const navigateUp = C.useRouterState(s => s.dispatch.navigateUp) const onBack = navigateUp const waiting = C.Waiting.useAnyWaiting(C.waitingKeyProvisionForgotUsername) + const [forgotUsernameResult, setForgotUsernameResult] = React.useState('') const [emailSelected, setEmailSelected] = React.useState(true) const [email, setEmail] = React.useState('') @@ -20,13 +34,19 @@ const ForgotUsername = () => { // truthy when it's valid. This is used in the form validation logic in the code. const [phoneNumber, setPhoneNumber] = React.useState() - const forgotUsername = useProvisionState(s => s.dispatch.forgotUsername) - const onSubmit = () => { if (!emailSelected && phoneNumber) { - forgotUsername(phoneNumber) + recoverUsernameWithPhone( + [{phone: phoneNumber}, C.waitingKeyProvisionForgotUsername], + () => setForgotUsernameResult('success'), + error => setForgotUsernameResult(decodeForgotUsernameError(error)) + ) } else if (emailSelected) { - forgotUsername(undefined, email) + recoverUsernameWithEmail( + [{email}, C.waitingKeyProvisionForgotUsername], + () => setForgotUsernameResult('success'), + error => setForgotUsernameResult(decodeForgotUsernameError(error)) + ) } } diff --git a/shared/router-v2/route-params.tsx b/shared/router-v2/route-params.tsx index 7feafd9764ad..6137c4343727 100644 --- a/shared/router-v2/route-params.tsx +++ b/shared/router-v2/route-params.tsx @@ -64,13 +64,20 @@ type Distribute = U extends RouteKeys : never export type NavigateAppendType = Distribute -export type RootRouteProps = RouteProp +type MaybeMissingParamsRouteProp = Omit< + RouteProp, + 'params' +> & { + params?: RootParamList[RouteName] +} + +export type RootRouteProps = RouteName extends TabRoots + ? MaybeMissingParamsRouteProp + : RouteProp -// most roots have no params but chat can get it set after the fact in some flows +// Tab roots can mount before any params object exists, even when the screen prop type is an object. export type RouteProps2 = { - route: RouteName extends TabRoots - ? Partial> - : RouteProp + route: RootRouteProps navigation: NativeStackNavigationProp } diff --git a/shared/stores/chat.tsx b/shared/stores/chat.tsx index 8be8aab67099..4ac04964a569 100644 --- a/shared/stores/chat.tsx +++ b/shared/stores/chat.tsx @@ -241,7 +241,6 @@ type PreviewReason = | 'teamHeader' | 'teamInvite' | 'teamMember' | 'teamMention' | 'teamRow' | 'tracker' | 'transaction' type Store = T.Immutable<{ - botPublicCommands: Map createConversationError?: T.Chat.CreateConversationError smallTeamBadgeCount: number bigTeamBadgeCount: number @@ -251,10 +250,6 @@ type Store = T.Immutable<{ staticConfig?: T.Chat.StaticConfig // static config stuff from the service. only needs to be loaded once. if null, it hasn't been loaded, trustedInboxHasLoaded: boolean // if we've done initial trusted inbox load, userReacjis: T.Chat.UserReacjis - userEmojis?: Array - userEmojisForAutocomplete?: Array - infoPanelShowing: boolean - infoPanelSelectedTab?: 'settings' | 'members' | 'attachments' | 'bots' inboxNumSmallRows?: number inboxHasLoaded: boolean // if we've ever loaded, inboxRetriedOnCurrentEmpty: boolean @@ -263,7 +258,6 @@ type Store = T.Immutable<{ inboxRows: Array inboxSearch?: T.Chat.InboxSearchInfo inboxSmallTeamsExpanded: boolean - teamIDToGeneralConvID: Map flipStatusMap: Map maybeMentionMap: Map blockButtonsMap: Map // Should we show block buttons for this team ID? @@ -272,7 +266,6 @@ type Store = T.Immutable<{ const initialStore: Store = { bigTeamBadgeCount: 0, blockButtonsMap: new Map(), - botPublicCommands: new Map(), createConversationError: undefined, flipStatusMap: new Map(), inboxAllowShowFloatingButton: false, @@ -283,18 +276,13 @@ const initialStore: Store = { inboxRows: [], inboxSearch: undefined, inboxSmallTeamsExpanded: false, - infoPanelSelectedTab: undefined, - infoPanelShowing: false, lastCoord: undefined, maybeMentionMap: new Map(), paymentStatusMap: new Map(), smallTeamBadgeCount: 0, smallTeamsExpanded: false, staticConfig: undefined, - teamIDToGeneralConvID: new Map(), trustedInboxHasLoaded: false, - userEmojis: undefined, - userEmojisForAutocomplete: undefined, userReacjis: defaultUserReacjis, } @@ -334,8 +322,6 @@ export type State = Store & { ) => void createConversation: (participants: ReadonlyArray, highlightMessageID?: T.Chat.MessageID) => void ensureWidgetMetas: () => void - findGeneralConvIDFromTeamID: (teamID: T.Teams.TeamID) => void - fetchUserEmoji: (conversationIDKey?: T.Chat.ConversationIDKey, onlyInTeam?: boolean) => void inboxRefresh: (reason: RefreshReason) => void inboxSearch: (query: string) => void inboxSearchMoveSelectedIndex: (increment: boolean) => void @@ -345,9 +331,7 @@ export type State = Store & { selectedIndex?: number ) => void loadStaticConfig: () => void - loadedUserEmoji: (results: T.RPCChat.UserEmojiRes) => void maybeChangeSelectedConv: () => void - messageSendByUsername: (username: string, text: string, waitingKey?: string) => void metasReceived: ( metas: ReadonlyArray, removals?: ReadonlyArray // convs to remove @@ -372,12 +356,10 @@ export type State = Store & { }) => void queueMetaToRequest: (ids: ReadonlyArray) => void queueMetaHandle: () => void - refreshBotPublicCommands: (username: string) => void resetConversationErrored: () => void resetState: () => void setMaybeMentionInfo: (name: string, info: T.RPCChat.UIMaybeMentionInfo) => void setTrustedInboxHasLoaded: () => void - setInfoPanelTab: (tab: 'settings' | 'members' | 'attachments' | 'bots' | undefined) => void setInboxNumSmallRows: (rows: number, ignoreWrite?: boolean) => void toggleInboxSearch: (enabled: boolean) => void toggleSmallTeamsExpanded: () => void @@ -389,7 +371,6 @@ export type State = Store & { updatedGregor: ( items: ReadonlyArray<{md: T.RPCGen.Gregor1.Metadata; item: T.RPCGen.Gregor1.Item}> ) => void - updateInfoPanel: (show: boolean, tab: 'settings' | 'members' | 'attachments' | 'bots' | undefined) => void } getBackCount: (conversationIDKey: T.Chat.ConversationIDKey) => number getBadgeHiddenCount: (ids: ReadonlySet) => { @@ -647,47 +628,6 @@ export const useChatState = Z.createZustand('chat', (set, get) => { } get().dispatch.unboxRows(missing, true) }, - fetchUserEmoji: (conversationIDKey, onlyInTeam) => { - const f = async () => { - const results = await T.RPCChat.localUserEmojisRpcPromise( - { - convID: - conversationIDKey && conversationIDKey !== T.Chat.noConversationIDKey - ? T.Chat.keyToConversationID(conversationIDKey) - : null, - opts: { - getAliases: true, - getCreationInfo: false, - onlyInTeam: onlyInTeam ?? false, - }, - }, - S.waitingKeyChatLoadingEmoji - ) - get().dispatch.loadedUserEmoji(results) - } - ignorePromise(f()) - }, - findGeneralConvIDFromTeamID: teamID => { - const f = async () => { - try { - const conv = await T.RPCChat.localFindGeneralConvFromTeamIDRpcPromise({teamID}) - const meta = Meta.inboxUIItemToConversationMeta(conv) - if (!meta) { - logger.info(`findGeneralConvIDFromTeamID: failed to convert to meta`) - return - } - get().dispatch.metasReceived([meta]) - set(s => { - s.teamIDToGeneralConvID.set(teamID, T.Chat.stringToConversationIDKey(conv.convID)) - }) - } catch (error) { - if (error instanceof RPCError) { - logger.info(`findGeneralConvIDFromTeamID: failed to get general conv: ${error.message}`) - } - } - } - ignorePromise(f()) - }, inboxRefresh: reason => { const f = async () => { const {username} = useCurrentUserState.getState() @@ -1006,16 +946,6 @@ export const useChatState = Z.createZustand('chat', (set, get) => { } ignorePromise(f()) }, - loadedUserEmoji: results => { - set(s => { - const newEmojis: Array = [] - results.emojis.emojis?.forEach(group => { - group.emojis?.forEach(e => newEmojis.push(e)) - }) - s.userEmojisForAutocomplete = newEmojis - s.userEmojis = T.castDraft(results.emojis.emojis) ?? [] - }) - }, maybeChangeSelectedConv: () => { const {inboxLayout} = get() const newConvID = inboxLayout?.reselectInfo?.newConvID @@ -1061,31 +991,6 @@ export const useChatState = Z.createZustand('chat', (set, get) => { storeRegistry.getConvoState(newConvID).dispatch.navigateToThread('findNewestConversation') } }, - messageSendByUsername: (username, text, waitingKey) => { - const f = async () => { - const tlfName = `${useCurrentUserState.getState().username},${username}` - try { - const result = await T.RPCChat.localNewConversationLocalRpcPromise( - { - identifyBehavior: T.RPCGen.TLFIdentifyBehavior.chatGui, - membersType: T.RPCChat.ConversationMembersType.impteamnative, - tlfName, - tlfVisibility: T.RPCGen.TLFVisibility.private, - topicType: T.RPCChat.TopicType.chat, - }, - waitingKey - ) - storeRegistry - .getConvoState(T.Chat.conversationIDToKey(result.conv.info.id)) - .dispatch.sendMessage(text) - } catch (error) { - if (error instanceof RPCError) { - logger.warn('Could not send in messageSendByUsernames', error.message) - } - } - } - ignorePromise(f()) - }, metasReceived: (metas, removals) => { removals?.forEach(r => { storeRegistry.getConvoState(r).dispatch.setMeta() @@ -1815,35 +1720,6 @@ export const useChatState = Z.createZustand('chat', (set, get) => { logger.info('skipping meta queue run, queue unchanged') } }, - refreshBotPublicCommands: username => { - set(s => { - s.botPublicCommands.delete(username) - }) - const f = async () => { - let res: T.RPCChat.ListBotCommandsLocalRes | undefined - try { - res = await T.RPCChat.localListPublicBotCommandsLocalRpcPromise({ - username, - }) - } catch (error) { - if (error instanceof RPCError) { - logger.info('refreshBotPublicCommands: failed to get public commands: ' + error.message) - set(s => { - s.botPublicCommands.set(username, {commands: [], loadError: true}) - }) - } - } - const commands = (res?.commands ?? []).reduce>((l, c) => { - l.push(c.name) - return l - }, []) - - set(s => { - s.botPublicCommands.set(username, {commands, loadError: false}) - }) - } - ignorePromise(f()) - }, resetConversationErrored: () => { set(s => { s.createConversationError = undefined @@ -1883,11 +1759,6 @@ export const useChatState = Z.createZustand('chat', (set, get) => { } ignorePromise(f()) }, - setInfoPanelTab: tab => { - set(s => { - s.infoPanelSelectedTab = tab - }) - }, setMaybeMentionInfo: (name, info) => { set(s => { const {maybeMentionMap} = s @@ -2022,12 +1893,6 @@ export const useChatState = Z.createZustand('chat', (set, get) => { } }) }, - updateInfoPanel: (show, tab) => { - set(s => { - s.infoPanelShowing = show - s.infoPanelSelectedTab = tab - }) - }, updateLastCoord: coord => { set(s => { s.lastCoord = coord diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index e3d3c8f698e8..25d30901a7f9 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -11,6 +11,7 @@ import { getVisibleScreen, getModalStack, navToThread, + setChatRootParams, } from '@/constants/router' import {isIOS} from '@/constants/platform' import {updateImmer} from '@/constants/utils' @@ -246,10 +247,6 @@ export interface ConvoState extends ConvoStore { ) => void chatResetConversationErrored: () => void chatUnboxRows: (convIDs: ReadonlyArray, force: boolean) => void - chatUpdateInfoPanel: ( - show: boolean, - tab: 'settings' | 'members' | 'attachments' | 'bots' | undefined - ) => void teamsGetMembers: (teamID: T.RPCGen.TeamID) => Promise usersGetBio: (username: string) => void } @@ -441,9 +438,6 @@ const stubDefer: ConvoState['dispatch']['defer'] = { chatUnboxRows: () => { throw new Error('convostate defer not initialized') }, - chatUpdateInfoPanel: () => { - throw new Error('convostate defer not initialized') - }, teamsGetMembers: () => { throw new Error('convostate defer not initialized') }, @@ -3145,22 +3139,24 @@ const createSlice = queueInboxRowUpdate(get().id) }, 1000), showInfoPanel: (show, tab) => { - get().dispatch.defer.chatUpdateInfoPanel(show, tab) const conversationIDKey = get().id if (Platform.isPhone) { const visibleScreen = getVisibleScreen() - if ((visibleScreen?.name === 'chatInfoPanel') !== show) { - if (show) { - navigateAppend({ + if (show) { + navigateAppend( + { name: 'chatInfoPanel', params: {conversationIDKey, tab}, - }) - } else { - navigateUp() - get().dispatch.clearAttachmentView() - } + }, + visibleScreen?.name === 'chatInfoPanel' + ) + } else if (visibleScreen?.name === 'chatInfoPanel') { + navigateUp() + get().dispatch.clearAttachmentView() } + return } + setChatRootParams({conversationIDKey, infoPanel: show ? {tab} : undefined}) }, tabSelected: () => { get().dispatch.loadMoreMessages({reason: 'tab selected'}) diff --git a/shared/stores/provision.tsx b/shared/stores/provision.tsx index c63877ce07da..7e8839abb12e 100644 --- a/shared/stores/provision.tsx +++ b/shared/stores/provision.tsx @@ -1,6 +1,6 @@ import * as T from '@/constants/types' import {ignorePromise, wrapErrors} from '@/constants/utils' -import {waitingKeyProvision, waitingKeyProvisionForgotUsername} from '@/constants/strings' +import {waitingKeyProvision} from '@/constants/strings' import * as Z from '@/util/zustand' import {RPCError} from '@/util/errors' import {isMobile} from '@/constants/platform' @@ -17,16 +17,12 @@ export type Device = { name: string type: T.Devices.DeviceType } - -const decodeForgotUsernameError = (error: RPCError) => { - switch (error.code) { - case T.RPCGen.StatusCode.scnotfound: - return "We couldn't find an account with that email address. Try again?" - case T.RPCGen.StatusCode.scinputerror: - return "That doesn't look like a valid email address. Try again?" - default: - return error.desc - } +export type ProvisionRouteError = { + code: number + desc: string + details: string + fields?: ReadonlyArray<{key?: string; value?: string}> + message: string } // Do NOT change this. These values are used by the daemon also so this way we can ignore it when they do it / when we do @@ -73,9 +69,6 @@ type Store = T.Immutable<{ deviceName: string devices: Array error: string - existingDevices: Array - finalError?: RPCError - forgotUsernameResult: string inlineError?: RPCError passphrase: string startProvisionTrigger: number @@ -88,9 +81,6 @@ const initialStore: Store = { deviceName: '', devices: [], error: '', - existingDevices: [], - finalError: undefined, - forgotUsernameResult: '', inlineError: undefined, passphrase: '', startProvisionTrigger: 0, @@ -108,7 +98,6 @@ export type State = Store & { submitTextCode?: (code: string) => void } addNewDevice: (otherDeviceType: 'desktop' | 'mobile') => void - forgotUsername: (phone?: string, email?: string) => void resetState: () => void restartProvisioning: () => void startProvision: (name?: string, fromReset?: boolean) => void @@ -285,55 +274,12 @@ export const useProvisionState = Z.createZustand('provision', (set, get) submitDeviceSelect: _submitDeviceSelect, submitTextCode: _submitTextCode, }, - forgotUsername: (phone, email) => { - const f = async () => { - if (email) { - try { - await T.RPCGen.accountRecoverUsernameWithEmailRpcPromise( - {email}, - waitingKeyProvisionForgotUsername - ) - set(s => { - s.forgotUsernameResult = 'success' - }) - } catch (error) { - if (error instanceof RPCError) { - const err = decodeForgotUsernameError(error) - set(s => { - s.forgotUsernameResult = err - }) - } - } - } - if (phone) { - try { - await T.RPCGen.accountRecoverUsernameWithPhoneRpcPromise( - {phone}, - waitingKeyProvisionForgotUsername - ) - set(s => { - s.forgotUsernameResult = 'success' - }) - } catch (error) { - if (error instanceof RPCError) { - const err = decodeForgotUsernameError(error) - set(s => { - s.forgotUsernameResult = err - }) - return - } - } - } - } - ignorePromise(f()) - }, resetState: () => { get().dispatch.dynamic.cancel?.(true) set(s => ({ ...s, ...initialStore, dispatch: s.dispatch, - finalError: s.finalError, inlineError: s.inlineError, })) }, @@ -388,11 +334,10 @@ export const useProvisionState = Z.createZustand('provision', (set, get) }, 'keybase.1.provisionUi.PromptNewDeviceName': (params, response) => { if (isCanceled(response)) return - const {errorMessage, existingDevices} = params + const {errorMessage} = params setupCancel(response) set(s => { s.error = errorMessage - s.existingDevices = T.castDraft(existingDevices ?? []) s.dispatch.dynamic.setDeviceName = wrapErrors((name: string) => { set(s => { s.dispatch.dynamic.setDeviceName = _setDeviceName @@ -509,11 +454,22 @@ export const useProvisionState = Z.createZustand('provision', (set, get) break default: if (!errorCausedByUsCanceling(finalError)) { - set(s => { - s.finalError = finalError - }) clearModals() - navigateAppend('error', true) + navigateAppend( + { + name: 'error', + params: { + error: { + code: finalError.code, + desc: finalError.desc, + details: finalError.details, + fields: finalError.fields as ReadonlyArray<{key?: string; value?: string}> | undefined, + message: finalError.message, + } satisfies ProvisionRouteError, + }, + }, + true + ) } break } diff --git a/shared/stores/tests/provision.test.ts b/shared/stores/tests/provision.test.ts index b0d2edffba08..69bc135e880f 100644 --- a/shared/stores/tests/provision.test.ts +++ b/shared/stores/tests/provision.test.ts @@ -55,9 +55,8 @@ test('restartProvisioning bails early when there is no username after canceling expect(useProvisionState.getState().startProvisionTrigger).toBe(0) }) -test('resetState preserves inline and final errors while clearing form state', () => { +test('resetState preserves inline errors while clearing form state', () => { const cancel = jest.fn() - const finalError = new RPCError('final error', 1) const inlineError = new RPCError('inline error', 2) useProvisionState.setState(s => ({ @@ -71,8 +70,6 @@ test('resetState preserves inline and final errors while clearing form state', ( cancel, }, }, - finalError, - forgotUsernameResult: 'success', inlineError, passphrase: 'hunter2', username: 'alice', @@ -84,9 +81,7 @@ test('resetState preserves inline and final errors while clearing form state', ( expect(cancel).toHaveBeenCalledWith(true) expect(state.autoSubmit).toEqual([]) expect(state.deviceName).toBe('') - expect(state.forgotUsernameResult).toBe('') expect(state.passphrase).toBe('') expect(state.username).toBe('') - expect(state.finalError).toBe(finalError) expect(state.inlineError).toBe(inlineError) }) diff --git a/shared/teams/team/rows/index.tsx b/shared/teams/team/rows/index.tsx index fa4a2e6695fa..98ac13a37666 100644 --- a/shared/teams/team/rows/index.tsx +++ b/shared/teams/team/rows/index.tsx @@ -1,4 +1,5 @@ import * as C from '@/constants' +import * as Meta from '@/constants/chat/meta' import * as Chat from '@/stores/chat' import * as T from '@/constants/types' import * as Teams from '@/stores/teams' @@ -274,17 +275,41 @@ export const useSubteamsSections = ( const useGeneralConversationIDKey = (teamID?: T.Teams.TeamID) => { const [conversationIDKey, setConversationIDKey] = React.useState() - const generalConvID = Chat.useChatState(s => (teamID ? s.teamIDToGeneralConvID.get(teamID) : undefined)) - const findGeneralConvIDFromTeamID = Chat.useChatState(s => s.dispatch.findGeneralConvIDFromTeamID) + const findGeneralConvIDFromTeamID = C.useRPC(T.RPCChat.localFindGeneralConvFromTeamIDRpcPromise) + const metasReceived = Chat.useChatState(s => s.dispatch.metasReceived) + const requestIDRef = React.useRef(0) + + React.useEffect(() => { + setConversationIDKey(undefined) + }, [teamID]) + React.useEffect(() => { - if (!conversationIDKey && teamID) { - if (!generalConvID) { - findGeneralConvIDFromTeamID(teamID) - } else { - setConversationIDKey(generalConvID) + requestIDRef.current += 1 + if (conversationIDKey || !teamID) { + return + } + const requestID = requestIDRef.current + findGeneralConvIDFromTeamID( + [{teamID}], + conv => { + if (requestIDRef.current !== requestID) { + return + } + const meta = Meta.inboxUIItemToConversationMeta(conv) + if (!meta) { + return + } + metasReceived([meta]) + setConversationIDKey(meta.conversationIDKey) + }, + () => {} + ) + return () => { + if (requestIDRef.current === requestID) { + requestIDRef.current += 1 } } - }, [conversationIDKey, findGeneralConvIDFromTeamID, generalConvID, teamID]) + }, [conversationIDKey, findGeneralConvIDFromTeamID, metasReceived, teamID]) return conversationIDKey } diff --git a/skill/zustand-store-pruning/references/store-checklist.md b/skill/zustand-store-pruning/references/store-checklist.md index 656fb34fd0ff..8555193f52cd 100644 --- a/skill/zustand-store-pruning/references/store-checklist.md +++ b/skill/zustand-store-pruning/references/store-checklist.md @@ -43,8 +43,8 @@ Status: ## Larger / More Global Stores -- [ ] `provision` -- [ ] `chat` +- [x] `provision` kept provisioning RPC/callback coordination, autosubmit restart state, and route-driven screen flow in store +- [-] `chat` in progress: moved info-panel open/tab UI into explicit `chatRoot` / `chatInfoPanel` route params, moved general-conversation lookup and bot public command loading into the owning chat/team components, pulled the wave-button direct-message RPC wrapper out of the store, and moved custom-emoji loading/cache into chat-local hooks used by the picker and suggestor - [x] `config` kept app/session/bootstrap state, engine plumbing, account/session coordination, startup routing, and window/app settings in store - [ ] `convostate` - [ ] `current-user`