diff --git a/AGENTS.md b/AGENTS.md index 69636d35905f..9fd715b5503d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,5 +7,7 @@ - When a component reads multiple adjacent values from the same store hook, prefer a consolidated selector with `C.useShallow(...)` instead of multiple separate subscriptions. - Keep types accurate. Do not use casts or misleading annotations to mask a real type mismatch just to get around an issue; fix the type or fix the implementation. - 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. +- Do not use `navigation.setOptions` for header state in this repo. Pass header-driving state through route params so `getOptions` can read it synchronously, or use [`shared/stores/modal-header.tsx`](/Users/ChrisNojima/SourceCode/go/src/github.com/keybase/client/shared/stores/modal-header.tsx) when the flow already uses the shared modal header mechanism. +- 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. - During refactors, do not delete existing guards, conditionals, or platform/test-specific behavior unless you have proven they are dead and the user asked for that behavior change. Port checks like `androidIsTestDevice` forward into the new code path instead of silently dropping them. - When addressing PR or review feedback, including bot or lint-style suggestions, do not apply it mechanically. Verify that the reported issue is real in this codebase and that the proposed fix is consistent with repo rules and improves correctness, behavior, or maintainability before making changes. diff --git a/shared/app/global-errors.tsx b/shared/app/global-errors.tsx index e81516cb8372..4b35e8b78f67 100644 --- a/shared/app/global-errors.tsx +++ b/shared/app/global-errors.tsx @@ -32,9 +32,9 @@ const useData = () => { setGlobalError() if (loggedIn) { clearModals() - navigateAppend(settingsFeedbackTab) + navigateAppend({name: settingsFeedbackTab, params: {}}) } else { - navigateAppend('feedback') + navigateAppend({name: 'feedback', params: {}}) } } const onDismiss = () => { diff --git a/shared/chat/conversation/bot/install.tsx b/shared/chat/conversation/bot/install.tsx index 6f124ea6d305..fe61338060e9 100644 --- a/shared/chat/conversation/bot/install.tsx +++ b/shared/chat/conversation/bot/install.tsx @@ -157,7 +157,7 @@ const InstallBotPopup = (props: Props) => { }) } const onFeedback = () => { - navigateAppend('feedback') + navigateAppend({name: 'feedback', params: {}}) } const refreshBotSettings = Chat.useChatContext(s => s.dispatch.refreshBotSettings) diff --git a/shared/chat/conversation/header-area/index.d.ts b/shared/chat/conversation/header-area/index.d.ts index 2196679d1e13..a367e95900fc 100644 --- a/shared/chat/conversation/header-area/index.d.ts +++ b/shared/chat/conversation/header-area/index.d.ts @@ -1,6 +1,6 @@ import type {GetOptionsRet} from '@/constants/types/router' declare function headerNavigationOptions(route: { - params: {conversationIDKey?: string} + params?: {conversationIDKey?: string} }): Partial export {headerNavigationOptions} diff --git a/shared/chat/conversation/input-area/normal/index.tsx b/shared/chat/conversation/input-area/normal/index.tsx index 8571056cd57e..29ce889c81a8 100644 --- a/shared/chat/conversation/input-area/normal/index.tsx +++ b/shared/chat/conversation/input-area/normal/index.tsx @@ -110,7 +110,8 @@ const doInjectText = (inputRef: React.RefObject, text: string, const ConnectedPlatformInput = function ConnectedPlatformInput() { const route = useRoute | RootRouteProps<'chatRoot'>>() - const infoPanelShowing = route.name === 'chatRoot' ? !!route.params?.infoPanel : false + const infoPanelShowing = + route.name === 'chatRoot' && 'infoPanel' in route.params ? !!route.params.infoPanel : false const data = Chat.useChatContext( C.useShallow(s => { const {meta, id: conversationIDKey, editing: editOrdinal, messageMap, unsentText} = s diff --git a/shared/chat/conversation/messages/text/coinflip/errors.tsx b/shared/chat/conversation/messages/text/coinflip/errors.tsx index 9a57f46b51ac..34be65bc968c 100644 --- a/shared/chat/conversation/messages/text/coinflip/errors.tsx +++ b/shared/chat/conversation/messages/text/coinflip/errors.tsx @@ -30,7 +30,7 @@ const CoinFlipError = (props: Props) => { const CoinFlipGenericError = () => { const navigateAppend = C.Router2.navigateAppend const sendFeedback = () => { - navigateAppend('modalFeedback') + navigateAppend({name: 'modalFeedback', params: {}}) } return ( diff --git a/shared/chat/inbox-and-conversation-header.tsx b/shared/chat/inbox-and-conversation-header.tsx index 250d3ee0e2ed..955b3fa7206c 100644 --- a/shared/chat/inbox-and-conversation-header.tsx +++ b/shared/chat/inbox-and-conversation-header.tsx @@ -4,31 +4,30 @@ import * as Kb from '@/common-adapters' import type {StyleOverride} from '@/common-adapters/markdown' import SearchRow from './inbox/search-row' import NewChatButton from './inbox/new-chat-button' -import {useRoute} from '@react-navigation/native' -import type {RootRouteProps} from '@/router-v2/route-params' +import {useRoute, type RouteProp} from '@react-navigation/native' import {useUsersState} from '@/stores/users' import {useCurrentUserState} from '@/stores/current-user' import * as Teams from '@/stores/teams' +type ChatRootParams = { + conversationIDKey?: string + infoPanel?: object +} +type ChatRootRoute = RouteProp<{chatRoot: ChatRootParams}, 'chatRoot'> + const Header = () => { - const {params} = useRoute>() + const {params} = useRoute() return ( - + ) } const Header2 = () => { - const {params} = useRoute>() + const {params} = useRoute() const username = useCurrentUserState(s => s.username) - const infoPanelShowing = !!params?.infoPanel + const infoPanelShowing = !!params.infoPanel const data = Chat.useChatContext( C.useShallow(s => { const {meta, id, dispatch} = s diff --git a/shared/chat/inbox-search/index.tsx b/shared/chat/inbox-search/index.tsx index c6dc510f0849..edef226c2db8 100644 --- a/shared/chat/inbox-search/index.tsx +++ b/shared/chat/inbox-search/index.tsx @@ -381,14 +381,14 @@ const OpenTeamRow = (p: OpenTeamProps) => { const [hovering, setHovering] = React.useState(false) const {name, description, memberCount, publicAdmins, inTeam, isSelected} = p const showingDueToSelect = React.useRef(false) - const {joinTeam, showTeamByName} = useTeamsState( + const {showTeamByName} = useTeamsState( C.useShallow(s => ({ - joinTeam: s.dispatch.joinTeam, showTeamByName: s.dispatch.showTeamByName, })) ) const clearModals = C.Router2.clearModals + const navigateAppend = C.Router2.navigateAppend const makePopup = (p: Kb.Popup2Parms) => { const {attachTo, hidePopup} = p return ( @@ -402,7 +402,7 @@ const OpenTeamRow = (p: OpenTeamProps) => { position="right center" onChat={undefined} onHidden={hidePopup} - onJoinTeam={() => joinTeam(name)} + onJoinTeam={() => navigateAppend({name: 'teamJoinTeamDialog', params: {initialTeamname: name}})} onViewTeam={() => { clearModals() showTeamByName(name) diff --git a/shared/chat/inbox/row/build-team.tsx b/shared/chat/inbox/row/build-team.tsx index 085471b04473..9c6e44c532d4 100644 --- a/shared/chat/inbox/row/build-team.tsx +++ b/shared/chat/inbox/row/build-team.tsx @@ -12,7 +12,7 @@ function BuildTeam() { launchNewTeamWizardOrModal() } const onJoinTeam = () => { - nav.safeNavigateAppend('teamJoinTeamDialog') + nav.safeNavigateAppend({name: 'teamJoinTeamDialog', params: {}}) } return ( diff --git a/shared/chat/new-team-dialog-container.tsx b/shared/chat/new-team-dialog-container.tsx index 45cf5c75fd4b..1cfe0ba2c5b5 100644 --- a/shared/chat/new-team-dialog-container.tsx +++ b/shared/chat/new-team-dialog-container.tsx @@ -2,27 +2,21 @@ import * as C from '@/constants' import * as Chat from '@/stores/chat' import {CreateNewTeam} from '../teams/new-team' import {useTeamsState} from '@/stores/teams' -import upperFirst from 'lodash/upperFirst' const NewTeamDialog = () => { const conversationIDKey = Chat.useChatContext(s => s.id) const baseTeam = '' - const errorText = useTeamsState(s => upperFirst(s.errorInTeamCreation)) const navigateUp = C.Router2.navigateUp const onCancel = () => { navigateUp() } - const resetErrorInTeamCreation = useTeamsState(s => s.dispatch.resetErrorInTeamCreation) const createNewTeamFromConversation = useTeamsState(s => s.dispatch.createNewTeamFromConversation) - const onClearError = resetErrorInTeamCreation const onSubmit = (teamname: string) => { createNewTeamFromConversation(conversationIDKey, teamname) } const props = { baseTeam, - errorText, onCancel, - onClearError, onSubmit, } return diff --git a/shared/chat/routes.tsx b/shared/chat/routes.tsx index 0776be9a3f76..0e5545d72a4a 100644 --- a/shared/chat/routes.tsx +++ b/shared/chat/routes.tsx @@ -11,8 +11,46 @@ import {useModalHeaderState} from '@/stores/modal-header' import {ModalTitle} from '@/teams/common' import inboxGetOptions from './inbox/get-options' import inboxAndConvoGetOptions from './inbox-and-conversation-get-options' +import {defineRouteMap} from '@/constants/types/router' +import type {BlockModalContext} from './blocking/block-modal' const Convo = React.lazy(async () => import('./conversation/container')) +type ChatAddToChannelRouteParams = { + conversationIDKey?: T.Chat.ConversationIDKey + teamID: T.Teams.TeamID +} +type ChatBlockingRouteParams = { + blockUserByDefault?: boolean + filterUserByDefault?: boolean + flagUserByDefault?: boolean + reportsUserByDefault?: boolean + context?: BlockModalContext + conversationIDKey?: T.Chat.ConversationIDKey + others?: Array + team?: string + username?: string +} +type ChatConfirmRemoveBotRouteParams = { + botUsername: string + teamID?: T.Teams.TeamID + conversationIDKey?: T.Chat.ConversationIDKey +} +type ChatInstallBotRouteParams = { + botUsername: string + conversationIDKey?: T.Chat.ConversationIDKey + teamID?: T.Teams.TeamID +} +type ChatSearchBotsRouteParams = { + teamID?: T.Teams.TeamID + conversationIDKey?: T.Chat.ConversationIDKey +} +type ChatShowNewTeamDialogRouteParams = { + conversationIDKey?: T.Chat.ConversationIDKey +} +const emptyChatBlockingRouteParams: ChatBlockingRouteParams = {} +const emptyChatSearchBotsRouteParams: ChatSearchBotsRouteParams = {} +const emptyChatShowNewTeamDialogRouteParams: ChatShowNewTeamDialogRouteParams = {} + const PDFShareButton = ({url}: {url?: string}) => { const showShareActionSheet = useConfigState(s => s.dispatch.defer.showShareActionSheet) return ( @@ -86,7 +124,7 @@ const SendToChatHeaderLeft = ({canBack}: {canBack?: boolean}) => { return Cancel } -export const newRoutes = { +export const newRoutes = defineRouteMap({ chatConversation: Chat.makeChatScreen(Convo, { canBeNullConvoID: true, getOptions: p => ({ @@ -98,17 +136,23 @@ export const newRoutes = { screen: React.lazy(async () => import('./conversation/rekey/enter-paper-key')), }, chatRoot: Chat.isSplit - ? Chat.makeChatScreen( - React.lazy(async () => import('./inbox-and-conversation')), - {getOptions: inboxAndConvoGetOptions, skipProvider: true} - ) - : Chat.makeChatScreen( - React.lazy(async () => import('./inbox/defer-loading')), - {getOptions: inboxGetOptions, skipProvider: true} - ), -} + ? { + ...Chat.makeChatScreen(React.lazy(async () => import('./inbox-and-conversation')), { + getOptions: inboxAndConvoGetOptions, + skipProvider: true, + }), + initialParams: {}, + } + : { + ...Chat.makeChatScreen(React.lazy(async () => import('./inbox/defer-loading')), { + getOptions: inboxGetOptions, + skipProvider: true, + }), + initialParams: {}, + }, +}) -export const newModalRoutes = { +export const newModalRoutes = defineRouteMap({ chatAddToChannel: Chat.makeChatScreen( React.lazy(async () => import('./conversation/info-panel/add-to-channel')), { @@ -134,9 +178,14 @@ export const newModalRoutes = { React.lazy(async () => import('./conversation/attachment-get-titles')), {getOptions: {modalStyle: {height: 660, maxHeight: 660}}} ), - chatBlockingModal: Chat.makeChatScreen(React.lazy(async () => import('./blocking/block-modal')), { - getOptions: {headerTitle: () => }, - }), + chatBlockingModal: { + ...Chat.makeChatScreen(React.lazy(async () => import('./blocking/block-modal')), { + getOptions: { + headerTitle: () => , + }, + }), + initialParams: emptyChatBlockingRouteParams, + }, chatChooseEmoji: Chat.makeChatScreen(React.lazy(async () => import('./emoji-picker/container')), { getOptions: {headerShown: false}, }), @@ -144,10 +193,7 @@ export const newModalRoutes = { React.lazy(async () => import('./punycode-link-warning')), {skipProvider: true} ), - chatConfirmRemoveBot: Chat.makeChatScreen( - React.lazy(async () => import('./conversation/bot/confirm')), - {canBeNullConvoID: true} - ), + chatConfirmRemoveBot: Chat.makeChatScreen(React.lazy(async () => import('./conversation/bot/confirm')), {canBeNullConvoID: true}), chatCreateChannel: Chat.makeChatScreen( React.lazy(async () => import('./create-channel')), {skipProvider: true} @@ -194,10 +240,13 @@ export const newModalRoutes = { overlayStyle: {alignSelf: 'stretch'}, }), }), - chatSearchBots: Chat.makeChatScreen( - React.lazy(async () => import('./conversation/bot/search')), - {canBeNullConvoID: true, getOptions: {title: 'Add a bot'}} - ), + chatSearchBots: { + ...Chat.makeChatScreen(React.lazy(async () => import('./conversation/bot/search')), { + canBeNullConvoID: true, + getOptions: {title: 'Add a bot'}, + }), + initialParams: emptyChatSearchBotsRouteParams, + }, chatSendToChat: Chat.makeChatScreen( React.lazy(async () => import('./send-to-chat')), { @@ -208,9 +257,12 @@ export const newModalRoutes = { skipProvider: true, } ), - chatShowNewTeamDialog: Chat.makeChatScreen(React.lazy(async () => import('./new-team-dialog-container'))), + chatShowNewTeamDialog: { + ...Chat.makeChatScreen(React.lazy(async () => import('./new-team-dialog-container'))), + initialParams: emptyChatShowNewTeamDialogRouteParams, + }, chatUnfurlMapPopup: Chat.makeChatScreen( React.lazy(async () => import('./conversation/messages/text/unfurl/unfurl-list/map-popup')), {getOptions: {title: 'Location'}} ), -} +}) diff --git a/shared/common-adapters/markdown/maybe-mention/team.tsx b/shared/common-adapters/markdown/maybe-mention/team.tsx index 1d1d91445992..d393efc0a3a0 100644 --- a/shared/common-adapters/markdown/maybe-mention/team.tsx +++ b/shared/common-adapters/markdown/maybe-mention/team.tsx @@ -39,12 +39,13 @@ const TeamMention = (ownProps: OwnProps) => { const previewConversation = Chat.useChatState(s => s.dispatch.previewConversation) const showTeamByName = useTeamsState(s => s.dispatch.showTeamByName) const clearModals = C.Router2.clearModals + const navigateAppend = C.Router2.navigateAppend const _onViewTeam = (teamname: string) => { clearModals() showTeamByName(teamname) } - const joinTeam = useTeamsState(s => s.dispatch.joinTeam) - const onJoinTeam = joinTeam + const onJoinTeam = (teamname: string) => + navigateAppend({name: 'teamJoinTeamDialog', params: {initialTeamname: teamname}}) const convID = _convID ? T.Chat.stringToConversationIDKey(_convID) : undefined const onChat = convID diff --git a/shared/common-adapters/reload.tsx b/shared/common-adapters/reload.tsx index d9c809e107da..c3731a5acb07 100644 --- a/shared/common-adapters/reload.tsx +++ b/shared/common-adapters/reload.tsx @@ -183,9 +183,9 @@ const ReloadContainer = (ownProps: OwnProps) => { const _onFeedback = (loggedIn: boolean) => { if (loggedIn) { navigateAppend(C.Tabs.settingsTab) - navigateAppend(settingsFeedbackTab) + navigateAppend({name: settingsFeedbackTab, params: {}}) } else { - navigateAppend('feedback') + navigateAppend({name: 'feedback', params: {}}) } } diff --git a/shared/common-adapters/team-with-popup.tsx b/shared/common-adapters/team-with-popup.tsx index 316daa039a67..973362d9ed52 100644 --- a/shared/common-adapters/team-with-popup.tsx +++ b/shared/common-adapters/team-with-popup.tsx @@ -114,8 +114,6 @@ const ConnectedTeamWithPopup = (ownProps: OwnProps) => { memberCount: meta.memberCount, teamID, } - const joinTeam = Teams.useTeamsState(s => s.dispatch.joinTeam) - const _onJoinTeam = joinTeam const clearModals = C.Router2.clearModals const navigateAppend = C.Router2.navigateAppend const _onViewTeam = (teamID: T.Teams.TeamID) => { @@ -129,7 +127,7 @@ const ConnectedTeamWithPopup = (ownProps: OwnProps) => { isMember: stateProps.isMember, isOpen: stateProps.isOpen, memberCount: stateProps.memberCount, - onJoinTeam: () => _onJoinTeam(ownProps.teamName), + onJoinTeam: () => navigateAppend({name: 'teamJoinTeamDialog', params: {initialTeamname: ownProps.teamName}}), onViewTeam: () => _onViewTeam(stateProps.teamID), prefix: ownProps.prefix, shouldLoadTeam: ownProps.shouldLoadTeam, diff --git a/shared/constants/deeplinks.tsx b/shared/constants/deeplinks.tsx index 666183c19a68..b0fe1b1c30f0 100644 --- a/shared/constants/deeplinks.tsx +++ b/shared/constants/deeplinks.tsx @@ -149,7 +149,7 @@ const handleKeybaseLink = (link: string) => { } break case 'team-invite-link': - useTeamsState.getState().dispatch.openInviteLink(parts[1] ?? '', parts[2] || '') + navigateAppend({name: 'teamInviteLinkJoin', params: {inviteID: parts[1] ?? '', inviteKey: parts[2] || ''}}) return default: break diff --git a/shared/constants/init/index.native.tsx b/shared/constants/init/index.native.tsx index 7e936f7b7926..ee6149d76aa2 100644 --- a/shared/constants/init/index.native.tsx +++ b/shared/constants/init/index.native.tsx @@ -275,7 +275,7 @@ export const initPlatformListener = () => { routeName = cur } const ap = getVisiblePath() - ap.some(r => { + ap.some((r: ReturnType[number]) => { if (r.name === 'chatConversation') { const rParams = r.params as undefined | {conversationIDKey?: T.Chat.ConversationIDKey} param = {selectedConversationIDKey: rParams?.conversationIDKey} diff --git a/shared/constants/router.tsx b/shared/constants/router.tsx index 5ff876cc819c..7e9c3935e11b 100644 --- a/shared/constants/router.tsx +++ b/shared/constants/router.tsx @@ -11,24 +11,44 @@ import { type NavigationState, } from '@react-navigation/core' import type {StaticScreenProps} from '@react-navigation/core' -import type {NavigateAppendType, RouteKeys, RootParamList as KBRootParamList} from '@/router-v2/route-params' -import type {GetOptionsRet} from './types/router' +import type { + NoParamRouteKeys, + ParamRouteKeys, + RouteKeys, + RootParamList as KBRootParamList, +} from '@/router-v2/route-params' +import type {GetOptionsRet, RouteDef} from './types/router' import {isSplit} from './chat/layout' import {isMobile} from './platform' import {shallowEqual} from './utils' import {registerDebugClear} from '@/util/debug' import {makeUUID} from '@/util/uuid' -type InferComponentProps = - T extends React.LazyExoticComponent< - React.ComponentType | undefined> - > - ? P - : T extends React.ComponentType | undefined> - ? P - : undefined +type IsExactlyRecord = [T] extends [Record] + ? [Record] extends [T] + ? true + : false + : false -export const navigationRef = createNavigationContainerRef() +type NavigatorParamsFromProps

= P extends Record + ? IsExactlyRecord

extends true + ? undefined + : keyof P extends never + ? undefined + : P + : undefined + +type LazyInnerComponent> = + COM extends React.LazyExoticComponent ? Inner : never + +type ScreenParams> = NavigatorParamsFromProps< + React.ComponentProps> +> +type ScreenComponent> = ( + p: StaticScreenProps> +) => React.ReactElement + +export const navigationRef = createNavigationContainerRef() registerDebugClear(() => { navigationRef.current = null @@ -37,7 +57,6 @@ registerDebugClear(() => { export type Route = NavigationState['routes'][0] // still a little paranoid about some things being missing in this type export type NavState = Partial -export type PathParam = NavigateAppendType export type Navigator = NavigationContainerRef const DEBUG_NAV = __DEV__ && (false as boolean) @@ -162,12 +181,24 @@ export const useSafeFocusEffect = (fn: () => void) => { export function makeScreen>( Component: COM, options?: { - getOptions?: GetOptionsRet | ((props: StaticScreenProps>) => GetOptionsRet) + getOptions?: GetOptionsRet | ((props: StaticScreenProps>) => GetOptionsRet) } -) { +): RouteDef, ScreenParams> { + const getOptionsOption = options?.getOptions + const getOptions = typeof getOptionsOption === 'function' + ? (p: StaticScreenProps>) => + getOptionsOption({ + ...p, + route: { + ...p.route, + params: (p.route.params ?? {}) as ScreenParams, + }, + }) + : getOptionsOption return { ...options, - screen: function Screen(p: StaticScreenProps>) { + getOptions, + screen: function Screen(p: StaticScreenProps>) { const Comp = Component as any return }, @@ -217,7 +248,15 @@ export const navUpToScreen = (name: RouteKeys) => { n.dispatch(StackActions.popTo(typeof name === 'string' ? name : String(name))) } -export const navigateAppend = (path: PathParam, replace?: boolean) => { +export function navigateAppend(path: RouteName, replace?: boolean): void +export function navigateAppend( + path: {name: RouteName; params: KBRootParamList[RouteName]}, + replace?: boolean +): void +export function navigateAppend( + path: RouteKeys | {name: RouteKeys; params: object | undefined}, + replace?: boolean +) { DEBUG_NAV && console.log('[Nav] navigateAppend', {path}) const n = _getNavigator() if (!n) { @@ -361,7 +400,7 @@ export const navToThread = (conversationIDKey: T.Chat.ConversationIDKey) => { { name: 'loggedIn', state: { - routes: [{name: Tabs.chatTab, state: {index: 0, routes: [{name: 'chatRoot'}]}}], + routes: [{name: Tabs.chatTab, state: {index: 0, routes: [{name: 'chatRoot', params: {}}]}}], }, }, {name: 'chatConversation', params: {conversationIDKey}}, diff --git a/shared/constants/strings.tsx b/shared/constants/strings.tsx index bd5c4925a05e..a272b074f8b2 100644 --- a/shared/constants/strings.tsx +++ b/shared/constants/strings.tsx @@ -65,6 +65,7 @@ export const waitingKeyTeamsDeleteTeam = (teamID: T.Teams.TeamID) => `teamDelete export const waitingKeyTeamsLeaveTeam = (teamname: T.Teams.Teamname) => `teamLeave:${teamname}` export const waitingKeyTeamsRename = 'teams:rename' export const waitingKeyTeamsLoadWelcomeMessage = (teamID: T.Teams.TeamID) => `loadWelcomeMessage:${teamID}` +export const waitingKeyTeamsSetRetentionPolicy = (teamID: T.Teams.TeamID) => `teamRetention:${teamID}` export const waitingKeyTeamsLoadTeamTreeActivity = (teamID: T.Teams.TeamID, username: string) => `loadTeamTreeActivity:${teamID};${username}` export const waitingKeyTeamsEditMembership = (teamID: T.Teams.TeamID, ...usernames: ReadonlyArray) => diff --git a/shared/constants/types/router.tsx b/shared/constants/types/router.tsx index 5e320597d845..96448c49db45 100644 --- a/shared/constants/types/router.tsx +++ b/shared/constants/types/router.tsx @@ -1,23 +1,33 @@ import type * as Styles from '@/styles' -import type {RootParamList as KBRootParamList} from '@/router-v2/route-params' import type {NativeStackNavigationProp, NativeStackNavigationOptions} from '@react-navigation/native-stack' -import type {RouteProp} from '@react-navigation/native' +import type {ParamListBase, RouteProp} from '@react-navigation/native' import type {HeaderBackButtonProps} from '@react-navigation/elements' -export type GetOptionsParams = { - navigation: NativeStackNavigationProp - route: RouteProp +type RouteNameFor = Extract + +export type GetOptionsParams< + ParamList extends ParamListBase = ParamListBase, + RouteName extends RouteNameFor = RouteNameFor, +> = { + navigation: NativeStackNavigationProp + route: RouteProp } // Type for screen components that receive navigation props -export type ScreenProps = { - navigation: NativeStackNavigationProp - route: RouteProp +export type ScreenProps< + ParamList extends ParamListBase = ParamListBase, + RouteName extends RouteNameFor = RouteNameFor, +> = { + navigation: NativeStackNavigationProp + route: RouteProp } -export type ScreenComponentProps = { - route: {params: any} - navigation: NativeStackNavigationProp +export type ScreenComponentProps< + ParamList extends ParamListBase = ParamListBase, + RouteName extends RouteNameFor = RouteNameFor, +> = { + route: RouteProp + navigation: NativeStackNavigationProp } // Properties consumed by our layout functions (not React Navigation) export type LayoutOptions = { @@ -62,9 +72,34 @@ export type GetOptionsRet = }) | undefined -export type GetOptions = GetOptionsRet | ((p: any) => GetOptionsRet) -export type RouteDef = { - getOptions?: GetOptions - screen: React.ComponentType +type AnyScreen = React.ComponentType +type ScreenRouteParams = + React.ComponentProps extends {route: {params: infer Params}} + ? Params + : React.ComponentProps extends {route: {params?: infer Params}} + ? Params + : undefined + +export type GetOptions = + | GetOptionsRet + | ((p: React.ComponentProps) => GetOptionsRet) + +export type RouteDef< + Screen extends AnyScreen = AnyScreen, + Params = ScreenRouteParams, +> = { + __routeParams?: Params + getOptions?: GetOptions + initialParams?: Params + screen: Screen } export type RouteMap = {[K in string]?: RouteDef} + +export const defineRouteMap = (routes: Routes) => routes + +// tsgo does not support partial type argument application: providing Params explicitly +// while letting Screen be inferred causes "Expected 2 type arguments, but got 1". +// Adding Screen = AnyScreen as a default fixes this. +export const withRouteParams = ( + route: RouteDef +): RouteDef => route as RouteDef diff --git a/shared/crypto/routes.tsx b/shared/crypto/routes.tsx index ee7517e072e2..782e2402ddab 100644 --- a/shared/crypto/routes.tsx +++ b/shared/crypto/routes.tsx @@ -4,6 +4,7 @@ import * as Crypto from '@/constants/crypto' import {HeaderLeftButton, type HeaderBackButtonProps} from '@/common-adapters/header-buttons' import cryptoTeamBuilder from '../team-building/page' import type {StaticScreenProps} from '@react-navigation/core' +import {defineRouteMap} from '@/constants/types/router' import type { CommonOutputRouteParams, CryptoInputRouteParams, @@ -82,7 +83,7 @@ const CryptoTeamBuilderScreen = React.lazy(async () => { } }) -export const newRoutes = { +export const newRoutes = defineRouteMap({ [Crypto.decryptTab]: { getOptions: {headerShown: true, title: 'Decrypt'}, screen: DecryptInputScreen, @@ -103,9 +104,9 @@ export const newRoutes = { getOptions: C.isMobile ? {title: 'Crypto'} : {title: 'Crypto tools'}, screen: React.lazy(async () => import('./sub-nav')), }, -} +}) -export const newModalRoutes = { +export const newModalRoutes = defineRouteMap({ [Crypto.decryptOutput]: { getOptions: { headerLeft: (p: HeaderBackButtonProps) => , @@ -138,4 +139,4 @@ export const newModalRoutes = { ...cryptoTeamBuilder, screen: CryptoTeamBuilderScreen, }, -} +}) diff --git a/shared/crypto/sub-nav/index.desktop.tsx b/shared/crypto/sub-nav/index.desktop.tsx index 89918e1cd57b..1fdfbddc7004 100644 --- a/shared/crypto/sub-nav/index.desktop.tsx +++ b/shared/crypto/sub-nav/index.desktop.tsx @@ -13,9 +13,10 @@ import type {TypedNavigator, NavigatorTypeBagBase} from '@react-navigation/nativ import {routeMapToScreenElements} from '@/router-v2/routes' import {makeLayout} from '@/router-v2/screen-layout.desktop' import type {RouteDef, GetOptionsParams} from '@/constants/types/router' +import {defineRouteMap} from '@/constants/types/router' /* Desktop SubNav */ -const cryptoSubRoutes = { +const cryptoSubRoutes = defineRouteMap({ [Crypto.decryptTab]: { screen: React.lazy(async () => { const {DecryptIO} = await import('../decrypt') @@ -41,7 +42,7 @@ const cryptoSubRoutes = { return {default: VerifyIO} }), }, -} +}) function LeftTabNavigator({ initialRouteName, children, diff --git a/shared/devices/index.tsx b/shared/devices/index.tsx index b865d8d36105..bacc816467d2 100644 --- a/shared/devices/index.tsx +++ b/shared/devices/index.tsx @@ -9,9 +9,10 @@ import {useLocalBadging} from '@/util/use-local-badging' import {useModalHeaderState} from '@/stores/modal-header' import {HeaderTitle} from './nav-header' import {useNavigation} from '@react-navigation/native' -import type {RootParamList} from '@/router-v2/route-params' import type {NativeStackNavigationProp} from '@react-navigation/native-stack' +type DevicesRootParamList = {devicesRoot: undefined} + const sortDevices = (a: T.Devices.Device, b: T.Devices.Device) => { if (a.currentDevice) return -1 if (b.currentDevice) return 1 @@ -44,7 +45,7 @@ const splitAndSortDevices = (devices: ReadonlyArray) => const itemHeight = {height: 48, type: 'fixed'} as const function ReloadableDevices() { - const navigation = useNavigation>() + const navigation = useNavigation>() const [devices, setDevices] = React.useState>([]) const waiting = C.Waiting.useAnyWaiting(C.waitingKeyDevices) const loadDevicesRPC = C.useRPC(T.RPCGen.deviceDeviceHistoryListRpcPromise) diff --git a/shared/devices/nav-header.tsx b/shared/devices/nav-header.tsx index ac0b795cac62..6c6e415ee807 100644 --- a/shared/devices/nav-header.tsx +++ b/shared/devices/nav-header.tsx @@ -14,7 +14,7 @@ export const HeaderTitle = ({activeCount, revokedCount}: {activeCount: number; r export const HeaderRightActions = () => { const navigateAppend = C.Router2.navigateAppend - const onAdd = () => navigateAppend('deviceAdd') + const onAdd = () => navigateAppend({name: 'deviceAdd', params: {}}) return ( { const cancel = useProvisionState(s => s.dispatch.dynamic.cancel) @@ -22,7 +23,7 @@ const AddDeviceCancelButton = () => { ) } -export const newRoutes = { +export const newRoutes = defineRouteMap({ devicePage: C.makeScreen( React.lazy(async () => import('./device-page')), {getOptions: {title: ''}} @@ -46,9 +47,9 @@ export const newRoutes = { }, screen: React.lazy(async () => import('.')), }, -} +}) -export const newModalRoutes = { +export const newModalRoutes = defineRouteMap({ ...provisionNewRoutes, deviceAdd: C.makeScreen(React.lazy(async () => import('./add-device')), { getOptions: { @@ -61,4 +62,4 @@ export const newModalRoutes = { getOptions: {gestureEnabled: false, overlayNoClose: true}, screen: React.lazy(async () => import('./paper-key')), }, -} +}) diff --git a/shared/fs/routes.tsx b/shared/fs/routes.tsx index ce6d818c5767..710214aa8cfa 100644 --- a/shared/fs/routes.tsx +++ b/shared/fs/routes.tsx @@ -6,6 +6,7 @@ import * as FS from '@/stores/fs' import {Actions, MainBanner, MobileHeader, Title} from './nav-header' import {Filename, ItemIcon} from './common' import {OriginalOrCompressedButton} from '@/incoming-share' +import {defineRouteMap} from '@/constants/types/router' const FsRoot = React.lazy(async () => import('.')) @@ -66,7 +67,7 @@ const destPickerDesktopHeaderStyle = Kb.Styles.padding( ) const noShrinkStyle = {flexShrink: 0} as const -export const newRoutes = { +export const newRoutes = defineRouteMap({ fsRoot: C.makeScreen(FsRoot, { getOptions: (ownProps?) => { // strange edge case where the root can actually have no params @@ -84,9 +85,9 @@ export const newRoutes = { } }, }), -} +}) -export const newModalRoutes = { +export const newModalRoutes = defineRouteMap({ barePreview: C.makeScreen( React.lazy(async () => { const {BarePreview} = await import('./filepreview') @@ -114,4 +115,4 @@ export const newModalRoutes = { async () => import('./banner/system-file-manager-integration-banner/kext-permission-popup') ), }, -} +}) diff --git a/shared/git/routes.tsx b/shared/git/routes.tsx index e208eca3a922..f2f655a2d272 100644 --- a/shared/git/routes.tsx +++ b/shared/git/routes.tsx @@ -1,8 +1,9 @@ import * as React from 'react' import * as C from '@/constants' import type * as NavHeader from './nav-header' +import {defineRouteMap} from '@/constants/types/router' -export const newRoutes = { +export const newRoutes = defineRouteMap({ gitRoot: C.makeScreen( React.lazy(async () => import('.')), { @@ -18,9 +19,9 @@ export const newRoutes = { }, } ), -} +}) -export const newModalRoutes = { +export const newModalRoutes = defineRouteMap({ gitDeleteRepo: C.makeScreen(React.lazy(async () => import('./delete-repo')), { getOptions: {title: 'Delete repo?'}, }), @@ -28,4 +29,4 @@ export const newModalRoutes = { getOptions: {title: 'New repository'}, }), gitSelectChannel: C.makeScreen(React.lazy(async () => import('./select-channel'))), -} +}) diff --git a/shared/incoming-share/routes.tsx b/shared/incoming-share/routes.tsx index 3ff9d7a88ae3..f64791156230 100644 --- a/shared/incoming-share/routes.tsx +++ b/shared/incoming-share/routes.tsx @@ -4,6 +4,7 @@ import * as Kb from '@/common-adapters' import type * as T from '@/constants/types' import {useModalHeaderState} from '@/stores/modal-header' import {OriginalOrCompressedButton} from '.' +import {defineRouteMap} from '@/constants/types/router' const IncomingShareHeaderLeft = () => { const onAction = useModalHeaderState(s => s.onAction) @@ -35,7 +36,7 @@ const IncomingShareHeaderTitle = () => { ) } -export const newModalRoutes = { +export const newModalRoutes = defineRouteMap({ incomingShareNew: C.makeScreen(React.lazy(async () => import('.')), { getOptions: { headerLeft: () => , @@ -43,4 +44,4 @@ export const newModalRoutes = { headerTitle: () => , }, }), -} +}) diff --git a/shared/login/loading.tsx b/shared/login/loading.tsx index c2fa815d8d5c..26dc83ad97cf 100644 --- a/shared/login/loading.tsx +++ b/shared/login/loading.tsx @@ -25,7 +25,7 @@ const SplashContainer = () => { const onFeedback = C.isMobile ? () => { - navigateAppend('feedback') + navigateAppend({name: 'feedback', params: {}}) } : undefined const onRetry = retriesLeft === 0 ? startHandshake : undefined diff --git a/shared/login/routes.tsx b/shared/login/routes.tsx index fad6ede4f86c..4c7a0224d832 100644 --- a/shared/login/routes.tsx +++ b/shared/login/routes.tsx @@ -6,6 +6,7 @@ import {newRoutes as provisionRoutes} from '../provision/routes-sub' import {sharedNewRoutes as settingsRoutes} from '../settings/routes' import {newRoutes as signupRoutes} from './signup/routes' import {settingsFeedbackTab} from '@/constants/settings' +import {defineRouteMap} from '@/constants/types/router' const recoverPasswordStyles = Kb.Styles.styleSheetCreate(() => ({ questionBox: Kb.Styles.padding(Kb.Styles.globalMargins.tiny, Kb.Styles.globalMargins.tiny, 0), @@ -23,7 +24,7 @@ const recoverPasswordGetOptions = { title: 'Recover password', } -export const newRoutes = { +export const newRoutes = defineRouteMap({ feedback: settingsRoutes[settingsFeedbackTab], login: {getOptions: {headerShown: false}, screen: React.lazy(async () => import('.'))}, recoverPasswordDeviceSelector: { @@ -71,8 +72,8 @@ export const newRoutes = { }), ...provisionRoutes, ...signupRoutes, -} -export const newModalRoutes = { +}) +export const newModalRoutes = defineRouteMap({ proxySettingsModal: { getOptions: {title: 'Proxy settings'}, screen: React.lazy(async () => import('../settings/proxy')), @@ -85,4 +86,4 @@ export const newModalRoutes = { getOptions: {gestureEnabled: false, title: 'Set password'}, screen: React.lazy(async () => import('./recover-password/password')), }, -} +}) diff --git a/shared/login/signup/routes.tsx b/shared/login/signup/routes.tsx index 069b2ea04170..3643872c31ed 100644 --- a/shared/login/signup/routes.tsx +++ b/shared/login/signup/routes.tsx @@ -1,5 +1,6 @@ import * as React from 'react' +import {defineRouteMap} from '@/constants/types/router' -export const newRoutes = { +export const newRoutes = defineRouteMap({ signupError: {getOptions: {headerLeft: undefined, title: 'Error'}, screen: React.lazy(async () => import('./error'))}, -} +}) diff --git a/shared/people/routes.tsx b/shared/people/routes.tsx index ae522940fd4c..21a986557ff1 100644 --- a/shared/people/routes.tsx +++ b/shared/people/routes.tsx @@ -5,6 +5,7 @@ import peopleTeamBuilder from '../team-building/page' import ProfileSearch from '../profile/search' import {useCurrentUserState} from '@/stores/current-user' import {settingsLogOutTab} from '@/constants/settings' +import {defineRouteMap} from '@/constants/types/router' const HeaderAvatar = () => { const myUsername = useCurrentUserState(s => s.username) @@ -13,7 +14,7 @@ const HeaderAvatar = () => { return } -export const newRoutes = { +export const newRoutes = defineRouteMap({ peopleRoot: { getOptions: { headerRight: Kb.Styles.isMobile ? () => : undefined, @@ -21,7 +22,7 @@ export const newRoutes = { }, screen: React.lazy(async () => import('./container')), }, -} +}) const AccountSignOutButton = () => { const navigateAppend = C.Router2.navigateAppend @@ -36,10 +37,10 @@ const AccountSignOutButton = () => { ) } -export const newModalRoutes = { +export const newModalRoutes = defineRouteMap({ accountSwitcher: { getOptions: {headerRight: () => }, screen: React.lazy(async () => import('../router-v2/account-switcher')), }, peopleTeamBuilder, -} +}) diff --git a/shared/profile/add-to-team.tsx b/shared/profile/add-to-team.tsx index 3c8de4099e82..0c613764355c 100644 --- a/shared/profile/add-to-team.tsx +++ b/shared/profile/add-to-team.tsx @@ -1,10 +1,11 @@ import * as C from '@/constants' import * as React from 'react' import * as Teams from '@/stores/teams' -import type * as T from '@/constants/types' +import * as T from '@/constants/types' import {FloatingRolePicker, sendNotificationFooter} from '@/teams/role-picker' import * as Kb from '@/common-adapters' import {InlineDropdown} from '@/common-adapters/dropdown' +import logger from '@/logger' const getOwnerDisabledReason = ( selected: Set, @@ -22,29 +23,56 @@ const getOwnerDisabledReason = ( .find(v => !!v) } +const makeAddUserToTeamsResult = ( + user: string, + teamsAddedTo: ReadonlyArray, + errorAddingTo: ReadonlyArray +) => { + let result = '' + if (teamsAddedTo.length) { + result += `${user} was added to ` + if (teamsAddedTo.length > 3) { + result += `${teamsAddedTo[0]}, ${teamsAddedTo[1]}, and ${teamsAddedTo.length - 2} teams.` + } else if (teamsAddedTo.length === 3) { + result += `${teamsAddedTo[0]}, ${teamsAddedTo[1]}, and ${teamsAddedTo[2]}.` + } else if (teamsAddedTo.length === 2) { + result += `${teamsAddedTo[0]} and ${teamsAddedTo[1]}.` + } else { + result += `${teamsAddedTo[0]}.` + } + } + + if (errorAddingTo.length) { + result += result.length > 0 ? ' But we ' : 'We ' + result += `were unable to add ${user} to ${errorAddingTo.join(', ')}.` + } + + return result +} + type OwnProps = {username: string} const Container = (ownProps: OwnProps) => { const {username: them} = ownProps const roles = Teams.useTeamsState(s => s.teamRoleMap.roles) const teams = Teams.useTeamsState(s => s.teamMeta) - const addUserToTeamsResults = Teams.useTeamsState(s => s.addUserToTeamsResults) - const addUserToTeamsState = Teams.useTeamsState(s => s.addUserToTeamsState) - const clearAddUserToTeamsResults = Teams.useTeamsState(s => s.dispatch.clearAddUserToTeamsResults) - const addUserToTeams = Teams.useTeamsState(s => s.dispatch.addUserToTeams) - const teamProfileAddList = Teams.useTeamsState(s => s.teamProfileAddList) + const teamNameToID = Teams.useTeamsState(s => s.teamNameToID) const waiting = C.Waiting.useAnyWaiting(C.waitingKeyTeamsProfileAddList) - const _onAddToTeams = addUserToTeams - const getTeamProfileAddList = Teams.useTeamsState(s => s.dispatch.getTeamProfileAddList) - const resetTeamProfileAddList = Teams.useTeamsState(s => s.dispatch.resetTeamProfileAddList) const clearModals = C.Router2.clearModals const navigateUp = C.Router2.navigateUp + const loadTeamProfileAddList = C.useRPC(T.RPCGen.teamsTeamProfileAddListRpcPromise) + const addUserToTeam = C.useRPC(T.RPCGen.teamsTeamAddMemberRpcPromise) + const [teamProfileAddList, setTeamProfileAddList] = React.useState>([]) + const [addUserToTeamsResults, setAddUserToTeamsResults] = React.useState('') + const [addUserToTeamsState, setAddUserToTeamsState] = + React.useState('notStarted') + const teamListRequestID = React.useRef(0) + const submitRequestID = React.useRef(0) // TODO Y2K-1086 use team ID given in teamProfileAddList to avoid this mapping const _teamNameToRole = [...teams.values()].reduce( (res, curr) => res.set(curr.teamname, roles.get(curr.id)?.role || 'none'), new Map() ) - const onAddToTeams = (role: T.Teams.TeamRoleType, teams: Array) => _onAddToTeams(role, teams, them) const [selectedTeams, setSelectedTeams] = React.useState(new Set()) const [rolePickerOpen, setRolePickerOpen] = React.useState(false) const [selectedRole, setSelectedRole] = React.useState('writer') @@ -53,17 +81,119 @@ const Container = (ownProps: OwnProps) => { const ownerDisabledReason = getOwnerDisabledReason(selectedTeams, _teamNameToRole) React.useEffect(() => { - clearAddUserToTeamsResults() - getTeamProfileAddList(them) - }, [clearAddUserToTeamsResults, getTeamProfileAddList, them]) + return () => { + teamListRequestID.current += 1 + submitRequestID.current += 1 + } + }, []) + + const loadTeamList = React.useEffectEvent(() => { + const requestID = teamListRequestID.current + 1 + teamListRequestID.current = requestID + loadTeamProfileAddList( + [{username: them}, C.waitingKeyTeamsProfileAddList], + result => { + if (teamListRequestID.current !== requestID) { + return + } + const teamlist = (result ?? []).map(team => ({ + disabledReason: team.disabledReason, + open: team.open, + teamName: team.teamName.parts ? team.teamName.parts.join('.') : '', + })) + teamlist.sort((a, b) => a.teamName.localeCompare(b.teamName)) + setTeamProfileAddList(teamlist) + }, + error => { + if (teamListRequestID.current !== requestID) { + return + } + logger.info(`Failed to load profile add-to-team list for ${them}: ${error.message}`) + setTeamProfileAddList([]) + } + ) + }) + + const onAddToTeams = React.useEffectEvent( + async (role: T.Teams.TeamRoleType, teams: Array, sendChatNotification: boolean) => { + const requestID = submitRequestID.current + 1 + submitRequestID.current = requestID + setAddUserToTeamsResults('') + setAddUserToTeamsState('notStarted') + + const teamsAddedTo: Array = [] + const errorAddingTo: Array = [] + + for (const team of teams) { + const teamID = teamNameToID.get(team) + if (!teamID) { + logger.warn(`no team ID found for ${team}`) + errorAddingTo.push(team) + continue + } + + const added = await new Promise(resolve => { + addUserToTeam( + [ + { + email: '', + phone: '', + role: T.RPCGen.TeamRole[role], + sendChatNotification, + teamID, + username: them, + }, + [C.waitingKeyTeamsTeam(teamID), C.waitingKeyTeamsAddUserToTeams(them)], + ], + () => resolve(true), + _ => resolve(false) + ) + }) + + if (submitRequestID.current !== requestID) { + return + } + + if (added) { + teamsAddedTo.push(team) + } else { + errorAddingTo.push(team) + } + } + + if (submitRequestID.current !== requestID) { + return + } + + const result = makeAddUserToTeamsResult(them, teamsAddedTo, errorAddingTo) + setAddUserToTeamsResults(result) + if (errorAddingTo.length > 0) { + setAddUserToTeamsState('failed') + loadTeamList() + } else { + setAddUserToTeamsState('succeeded') + clearModals() + } + } + ) + + React.useEffect(() => { + setAddUserToTeamsResults('') + setAddUserToTeamsState('notStarted') + setSelectedTeams(new Set()) + setRolePickerOpen(false) + setSelectedRole('writer') + setSendNotification(true) + setTeamProfileAddList([]) + loadTeamList() + }, [them]) const onBack = () => { navigateUp() - resetTeamProfileAddList() } const onSave = () => { - onAddToTeams(selectedRole, [...selectedTeams]) + void onAddToTeams(selectedRole, [...selectedTeams], sendNotification) } const toggleTeamSelected = (teamName: string, selected: boolean) => { @@ -102,15 +232,6 @@ const Container = (ownProps: OwnProps) => { } const onToggle = toggleTeamSelected - React.useEffect(() => { - if (addUserToTeamsState === 'succeeded') { - clearModals() - resetTeamProfileAddList() - } else if (addUserToTeamsState === 'failed') { - getTeamProfileAddList(them) - } - }, [addUserToTeamsState, clearModals, resetTeamProfileAddList, getTeamProfileAddList, them]) - const selectedTeamCount = selectedTeams.size return ( @@ -223,24 +344,6 @@ type RowProps = { them: string } -// This state is handled by the state wrapper in the container -export type ComponentState = { - selectedTeams: Set - onSave: () => void - onToggle: (teamName: string, selected: boolean) => void -} - -export type AddToTeamProps = { - title: string - addUserToTeamsResults: string - addUserToTeamsState: T.Teams.AddUserToTeamsState - loadTeamList: () => void - onBack: () => void - teamProfileAddList: ReadonlyArray - them: string - waiting: boolean -} - const TeamRow = (props: RowProps) => { return ( props.onCheck(!props.checked) : undefined}> diff --git a/shared/profile/routes.tsx b/shared/profile/routes.tsx index 45659c35c355..afd009898cb7 100644 --- a/shared/profile/routes.tsx +++ b/shared/profile/routes.tsx @@ -5,6 +5,7 @@ import * as Teams from '@/stores/teams' import {HeaderLeftButton} from '@/common-adapters/header-buttons' import {ModalTitle} from '@/teams/common' import {useModalHeaderState} from '@/stores/modal-header' +import {defineRouteMap} from '@/constants/types/router' const Title = React.lazy(async () => import('./search')) @@ -48,7 +49,7 @@ const EditAvatarHeaderTitle = ({teamID, wizard}: {teamID?: string; wizard?: bool return Upload an avatar } -export const newRoutes = { +export const newRoutes = defineRouteMap({ profile: C.makeScreen( React.lazy(async () => import('./user')), { @@ -64,9 +65,9 @@ export const newRoutes = { }, } ), -} +}) -export const newModalRoutes = { +export const newModalRoutes = defineRouteMap({ profileAddToTeam: C.makeScreen( React.lazy(async () => import('./add-to-team')), { @@ -82,9 +83,13 @@ export const newModalRoutes = { }), profileEditAvatar: C.makeScreen(React.lazy(async () => import('./edit-avatar')), { getOptions: ({route}) => ({ - headerLeft: () => , + headerLeft: () => ( + + ), headerRight: () => , - headerTitle: () => , + headerTitle: () => ( + + ), }), }), profileImport: C.makeScreen(React.lazy(async () => import('./pgp/import'))), @@ -103,4 +108,4 @@ export const newModalRoutes = { profileShowcaseTeamOffer: C.makeScreen(React.lazy(async () => import('./showcase-team-offer')), { getOptions: {modalStyle: {maxHeight: 600, maxWidth: 600}, title: 'Feature your teams'}, }), -} +}) diff --git a/shared/profile/user/hooks.tsx b/shared/profile/user/hooks.tsx index 997128b71c98..2d124adf0d61 100644 --- a/shared/profile/user/hooks.tsx +++ b/shared/profile/user/hooks.tsx @@ -147,7 +147,7 @@ const useUserData = (username: string) => { } const {navigateAppend, navigateUp} = C.Router2 const onAddIdentity = () => { - navigateAppend('profileProofsList') + navigateAppend({name: 'profileProofsList', params: {}}) } const onBack = () => { navigateUp() diff --git a/shared/profile/user/teams/index.tsx b/shared/profile/user/teams/index.tsx index 78b231bfd35e..9995ef83133b 100644 --- a/shared/profile/user/teams/index.tsx +++ b/shared/profile/user/teams/index.tsx @@ -19,18 +19,17 @@ const Container = (ownProps: OwnProps) => { _roles: s.teamRoleMap.roles, _teamNameToID: s.teamNameToID, _youAreInTeams: s.teamnames.size > 0, - joinTeam: s.dispatch.joinTeam, showTeamByName: s.dispatch.showTeamByName, })) ) - const {joinTeam, showTeamByName, _roles} = teamsState + const {showTeamByName, _roles} = teamsState const {_teamNameToID, _youAreInTeams} = teamsState const teamShowcase = d.teamShowcase || noTeams const {clearModals, navigateAppend} = C.Router2 const _onEdit = () => { navigateAppend('profileShowcaseTeamOffer') } - const onJoinTeam = joinTeam + const onJoinTeam = (teamname: string) => navigateAppend({name: 'teamJoinTeamDialog', params: {initialTeamname: teamname}}) const onViewTeam = (teamname: string) => { clearModals() showTeamByName(teamname) diff --git a/shared/router-v2/common.native.tsx b/shared/router-v2/common.native.tsx index 94a1c3bb7bd8..36d399998fbc 100644 --- a/shared/router-v2/common.native.tsx +++ b/shared/router-v2/common.native.tsx @@ -87,7 +87,8 @@ const styles = Kb.Styles.styleSheetCreate(() => ({ export const useSubnavTabAction = (navigation: NavigationContainerRef, state: NavState) => { const onSelectTab = (tab: string) => { - const route = state?.routes?.find(r => r.name === tab) + const routes = state && 'routes' in state ? state.routes : undefined + const route = routes?.find((r: {name?: string; key?: string}) => r.name === tab) const event = route ? navigation.emit({ canPreventDefault: true, diff --git a/shared/router-v2/linking.tsx b/shared/router-v2/linking.tsx index fa2a1041267b..a5ea7992bfee 100644 --- a/shared/router-v2/linking.tsx +++ b/shared/router-v2/linking.tsx @@ -103,7 +103,7 @@ export const makeChatConversationState = (conversationIDKey: string): PartialNav name: 'loggedIn', state: { index: 0, - routes: [{name: Tabs.chatTab, state: {index: 0, routes: [{name: 'chatRoot'}]}}], + routes: [{name: Tabs.chatTab, state: {index: 0, routes: [{name: 'chatRoot', params: {}}]}}], }, }, {name: 'chatConversation', params: {conversationIDKey}}, diff --git a/shared/router-v2/route-params.tsx b/shared/router-v2/route-params.tsx index 6137c4343727..f88f72328f7b 100644 --- a/shared/router-v2/route-params.tsx +++ b/shared/router-v2/route-params.tsx @@ -1,6 +1,5 @@ import type {RouteProp} from '@react-navigation/native' import type {NativeStackNavigationProp} from '@react-navigation/native-stack' -// import type {StaticParamList} from '@react-navigation/core' import type {routes, modalRoutes, loggedOutRoutes} from './routes' // tsgo bug: StaticParamList is the idiomatic React Navigation equivalent of _ExtractParams, @@ -8,6 +7,10 @@ import type {routes, modalRoutes, loggedOutRoutes} from './routes' // Once tsgo fixes re-exported generic type aliases, replace _ExtractParams: // type _SyntheticConfig = {readonly config: {readonly screens: _AllScreens}} // export type RootParamList = StaticParamList<_SyntheticConfig> & Tabs & {...} +// +// Similarly, avoid matching on RouteDef — tsgo fails to infer Params +// through conditional types in RouteDef's field definitions. Instead, extract params +// directly from the screen function's route prop, which tsgo handles correctly. type _ExtractParams = { [K in keyof T]: T[K] extends {screen: infer U} ? U extends (args: infer V) => any @@ -33,49 +36,22 @@ type Tabs = { 'tabs.walletsTab': undefined } -type TabRoots = - | 'peopleRoot' - | 'chatRoot' - | 'cryptoRoot' - | 'fsRoot' - | 'teamsRoot' - | 'walletsRoot' - | 'gitRoot' - | 'devicesRoot' - | 'settingsRoot' - type _AllScreens = typeof routes & typeof modalRoutes & typeof loggedOutRoutes export type RootParamList = _ExtractParams<_AllScreens> & Tabs & {loading: undefined; loggedOut: undefined; loggedIn: undefined} export type RouteKeys = keyof RootParamList -type AllOptional = { - [K in keyof T]-?: undefined extends T[K] ? true : false -}[keyof T] extends true - ? true - : false -type Distribute = U extends RouteKeys - ? RootParamList[U] extends undefined - ? U - : AllOptional extends true - ? {name: U; params: RootParamList[U]} | U - : {name: U; params: RootParamList[U]} - : never -export type NavigateAppendType = Distribute - -type MaybeMissingParamsRouteProp = Omit< - RouteProp, - 'params' -> & { - params?: RootParamList[RouteName] -} - -export type RootRouteProps = RouteName extends TabRoots - ? MaybeMissingParamsRouteProp - : RouteProp +export type NoParamRouteKeys = { + [K in RouteKeys]: RootParamList[K] extends undefined ? K : never +}[RouteKeys] +export type ParamRouteKeys = Exclude +export type NavigateAppendArg = RootParamList[RouteName] extends undefined + ? RouteName + : {name: RouteName; params: RootParamList[RouteName]} +export type NavigateAppendType = NavigateAppendArg +export type RootRouteProps = RouteProp -// Tab roots can mount before any params object exists, even when the screen prop type is an object. export type RouteProps2 = { route: RootRouteProps navigation: NativeStackNavigationProp diff --git a/shared/router-v2/router.native.tsx b/shared/router-v2/router.native.tsx index 68725aaeffb3..afa6840ba243 100644 --- a/shared/router-v2/router.native.tsx +++ b/shared/router-v2/router.native.tsx @@ -22,6 +22,7 @@ import type {SFSymbol} from 'sf-symbols-typescript' import {makeLayout} from './screen-layout.native' import {useRootKey} from './hooks.native' import {createLinkingConfig} from './linking' +import type {RootParamList} from './route-params' import {handleAppLink} from '@/constants/deeplinks' import {useDaemonState} from '@/stores/daemon' import {useNotifState} from '@/stores/notifications' @@ -220,7 +221,7 @@ const rootStackScreenOptions = {headerBackButtonDisplayMode: 'minimal'} satisfie const modalScreenOptions = ({ navigation, }: { - navigation: NavigationProp + navigation: NavigationProp }): NativeStackNavigationOptions => { const cancelItem: NativeStackNavigationOptions = Platform.OS === 'ios' diff --git a/shared/router-v2/routes.tsx b/shared/router-v2/routes.tsx index dcc08c870689..d2b85612aa81 100644 --- a/shared/router-v2/routes.tsx +++ b/shared/router-v2/routes.tsx @@ -13,14 +13,14 @@ import {newModalRoutes as walletsNewModalRoutes} from '../wallets/routes' import {newModalRoutes as incomingShareNewModalRoutes} from '../incoming-share/routes' import type * as React from 'react' import * as Tabs from '@/constants/tabs' -import type {GetOptions, GetOptionsParams, GetOptionsRet, RouteDef, RouteMap} from '@/constants/types/router' -import type {RootParamList as KBRootParamList} from '@/router-v2/route-params' +import {defineRouteMap} from '@/constants/types/router' +import type {GetOptions, GetOptionsParams, GetOptionsRet, RouteDef} from '@/constants/types/router' import type {NativeStackNavigationOptions} from '@react-navigation/native-stack' // We have normal routes, modal routes, and logged out routes. // We also end up using existence of a nameToTab value for a route as a test // of whether we're on a loggedIn route: loggedOut routes have no selected tab. -export const routes = { +export const routes = defineRouteMap({ ...deviceNewRoutes, ...chatNewRoutes, ...cryptoNewRoutes, @@ -30,7 +30,7 @@ export const routes = { ...settingsNewRoutes, ...teamsNewRoutes, ...gitNewRoutes, -} satisfies RouteMap +}) if (__DEV__) { const allRouteKeys = [ @@ -65,7 +65,7 @@ export const tabRoots = { [Tabs.searchTab]: '', } as const -export const modalRoutes = { +export const modalRoutes = defineRouteMap({ ...chatNewModalRoutes, ...cryptoNewModalRoutes, ...deviceNewModalRoutes, @@ -79,7 +79,7 @@ export const modalRoutes = { ...teamsNewModalRoutes, ...walletsNewModalRoutes, ...incomingShareNewModalRoutes, -} satisfies RouteMap +}) if (__DEV__) { const allModalKeys = [ @@ -104,7 +104,7 @@ if (__DEV__) { } } -export const loggedOutRoutes = {..._loggedOutRoutes, ...signupNewRoutes} satisfies RouteMap +export const loggedOutRoutes = defineRouteMap({..._loggedOutRoutes, ...signupNewRoutes}) type LayoutFn = (props: { children: React.ReactNode @@ -118,14 +118,15 @@ type MakeLayoutFn = ( getOptions?: GetOptions ) => LayoutFn type MakeOptionsFn = (rd: RouteDef) => (params: GetOptionsParams) => GetOptionsRet +type CheckedRouteEntry> = Routes[keyof Routes] function toNavOptions(opts: GetOptionsRet): NativeStackNavigationOptions { if (!opts) return {} return opts as NativeStackNavigationOptions } -export function routeMapToStaticScreens( - rs: RouteMap, +export function routeMapToStaticScreens>( + rs: RS, makeLayoutFn: MakeLayoutFn, isModal: boolean, isLoggedOut: boolean, @@ -134,14 +135,15 @@ export function routeMapToStaticScreens( const result: Record< string, { + initialParams?: object layout: (props: any) => React.ReactElement options: (p: {route: any; navigation: any}) => NativeStackNavigationOptions screen: React.ComponentType } > = {} - for (const [name, rd] of Object.entries(rs)) { - if (!rd) continue + for (const [name, rd] of Object.entries(rs) as Array<[string, CheckedRouteEntry]>) { result[name] = { + ...(rd.initialParams === undefined ? {} : {initialParams: rd.initialParams as object}), // Layout functions return JSX (ReactElement) and accept any route/navigation. // Cast bridges our specific KBRootParamList types to RN's generic ParamListBase. layout: makeLayoutFn(isModal, isLoggedOut, isTabScreen, rd.getOptions) as (props: any) => React.ReactElement, @@ -156,8 +158,8 @@ export function routeMapToStaticScreens( return result } -export function routeMapToScreenElements( - rs: RouteMap, +export function routeMapToScreenElements>( + rs: RS, Screen: React.ComponentType, makeLayoutFn: MakeLayoutFn, makeOptionsFn: MakeOptionsFn, @@ -165,14 +167,14 @@ export function routeMapToScreenElements( isLoggedOut: boolean, isTabScreen: boolean ) { - return (Object.keys(rs) as Array).flatMap(name => { - const rd = rs[name as string] - if (!rd) return [] + return (Object.keys(rs) as Array).flatMap(name => { + const rd = rs[name] as CheckedRouteEntry return [ , diff --git a/shared/router-v2/screen-layout.desktop.tsx b/shared/router-v2/screen-layout.desktop.tsx index 0d0ced67265c..973d070408cc 100644 --- a/shared/router-v2/screen-layout.desktop.tsx +++ b/shared/router-v2/screen-layout.desktop.tsx @@ -2,7 +2,7 @@ import * as Kb from '@/common-adapters' import * as React from 'react' import * as C from '@/constants' import type {GetOptions, GetOptionsParams, GetOptionsRet} from '@/constants/types/router' -import type {RootParamList as KBRootParamList} from '@/router-v2/route-params' +import type {ParamListBase} from '@react-navigation/native' import type {NativeStackNavigationProp} from '@react-navigation/native-stack' type ModalHeaderProps = { @@ -36,7 +36,7 @@ const ModalHeader = (props: ModalHeaderProps) => ( const mouseResetValue = -9999 const mouseDistanceThreshold = 5 -const useMouseClick = (navigation: NativeStackNavigationProp, noClose?: boolean) => { +const useMouseClick = (navigation: NativeStackNavigationProp, noClose?: boolean) => { const backgroundRef = React.useRef(null) // we keep track of mouse down/up to determine if we should call it a 'click'. We don't want dragging the // window around to count @@ -71,7 +71,7 @@ const useMouseClick = (navigation: NativeStackNavigationProp, n type ModalWrapperProps = { children: React.ReactNode navigationOptions?: GetOptionsRet - navigation: NativeStackNavigationProp + navigation: NativeStackNavigationProp } const ModalWrapper = (p: ModalWrapperProps) => { diff --git a/shared/settings/root-desktop-tablet.tsx b/shared/settings/root-desktop-tablet.tsx index 0360f83b73c6..9603bf4c48e7 100644 --- a/shared/settings/root-desktop-tablet.tsx +++ b/shared/settings/root-desktop-tablet.tsx @@ -7,7 +7,6 @@ import type {RouteDef, GetOptionsParams} from '@/constants/types/router' import LeftNav from './sub-nav/left-nav' import {useNavigationBuilder, TabRouter, createNavigatorFactory} from '@react-navigation/core' import type {TypedNavigator, NavigatorTypeBagBase} from '@react-navigation/native' -import type {RootParamList} from '@/router-v2/route-params' import {settingsDesktopTabRoutes} from './routes' import {settingsAccountTab} from '@/constants/settings' @@ -73,7 +72,7 @@ const styles = Kb.Styles.styleSheetCreate(() => ({ })) type NavType = NavigatorTypeBagBase & { - ParamList: Pick + ParamList: {[K in keyof typeof settingsDesktopTabRoutes]: undefined} } export const createLeftTabNavigator = createNavigatorFactory(LeftTabNavigator) as unknown as () => TypedNavigator diff --git a/shared/settings/routes.tsx b/shared/settings/routes.tsx index 4a1938645740..b0d66a250bff 100644 --- a/shared/settings/routes.tsx +++ b/shared/settings/routes.tsx @@ -5,11 +5,11 @@ import {newRoutes as devicesRoutes} from '../devices/routes' import {newRoutes as gitRoutes} from '../git/routes' import {newRoutes as walletsRoutes} from '../wallets/routes' import * as Settings from '@/constants/settings' +import {defineRouteMap} from '@/constants/types/router' import {usePushState} from '@/stores/push' import {usePWState} from '@/stores/settings-password' import {e164ToDisplay} from '@/util/phone-numbers' -import {useRoute} from '@react-navigation/native' -import type {RootRouteProps} from '@/router-v2/route-params' +import type {Props as FeedbackRouteParams} from './feedback/container' const PushPromptSkipButton = () => { const rejectPermissions = usePushState(s => s.dispatch.rejectPermissions) @@ -47,9 +47,8 @@ const CheckPassphraseCancelButton = () => { ) } -const VerifyPhoneHeaderTitle = () => { - const {params} = useRoute>() - const displayPhone = e164ToDisplay(params.phoneNumber) +const VerifyPhoneHeaderTitle = ({phoneNumber}: {phoneNumber?: string}) => { + const displayPhone = e164ToDisplay(phoneNumber ?? '') return ( {displayPhone || 'Unknown number'} @@ -70,13 +69,19 @@ const VerifyPhoneHeaderLeft = () => { } const SettingsRootDesktop = React.lazy(async () => import('./root-desktop-tablet')) +const EmptySettingsScreen = () => <> +const ManageContactsScreen: React.ComponentType = + C.isMobile ? React.lazy(async () => import('./manage-contacts')) : EmptySettingsScreen +const emptyFeedbackParams: FeedbackRouteParams = {} -const feedback = C.makeScreen( - React.lazy(async () => import('./feedback/container')), - {getOptions: C.isMobile ? {headerShown: true, title: 'Feedback'} : {}} -) +const feedback = { + ...C.makeScreen(React.lazy(async () => import('./feedback/container')), { + getOptions: C.isMobile ? {headerShown: true, title: 'Feedback'} : {}, + }), + initialParams: emptyFeedbackParams, +} -export const sharedNewRoutes = { +export const sharedNewRoutes = defineRouteMap({ [Settings.settingsAboutTab]: { getOptions: {title: 'About'}, screen: React.lazy(async () => import('./about')), @@ -127,9 +132,9 @@ export const sharedNewRoutes = { }, keybaseLinkError: {screen: React.lazy(async () => import('../deeplinks/error'))}, makeIcons: {screen: React.lazy(async () => import('./make-icons.page'))}, -} +}) -export const settingsDesktopTabRoutes = { +export const settingsDesktopTabRoutes = defineRouteMap({ [Settings.settingsAboutTab]: sharedNewRoutes[Settings.settingsAboutTab], [Settings.settingsAccountTab]: sharedNewRoutes[Settings.settingsAccountTab], [Settings.settingsAdvancedTab]: sharedNewRoutes[Settings.settingsAdvancedTab], @@ -144,7 +149,7 @@ export const settingsDesktopTabRoutes = { [Settings.settingsNotificationsTab]: sharedNewRoutes[Settings.settingsNotificationsTab], [Settings.settingsScreenprotectorTab]: sharedNewRoutes[Settings.settingsScreenprotectorTab], [Settings.settingsWalletsTab]: sharedNewRoutes[Settings.settingsWalletsTab], -} +}) const sharedNewModalRoutes = { [Settings.settingsLogOutTab]: C.makeScreen(React.lazy(async () => import('./logout')), { @@ -178,18 +183,18 @@ const sharedNewModalRoutes = { return {default: VerifyPhone} }), { - getOptions: { + getOptions: ({route}) => ({ headerLeft: Kb.Styles.isMobile ? () => : undefined, headerStyle: {backgroundColor: Kb.Styles.globalColors.blue}, - headerTitle: () => , - }, + headerTitle: () => , + }), } ), } const WebLinks = React.lazy(async () => import('./web-links')) -export const newRoutes = { +export const newRoutes = defineRouteMap({ settingsRoot: C.isMobile ? C.isPhone ? {getOptions: {title: 'More'}, screen: React.lazy(async () => import('./root-phone'))} @@ -198,7 +203,7 @@ export const newRoutes = { ...sharedNewRoutes, [Settings.settingsContactsTab]: { getOptions: {header: undefined, title: 'Contacts'}, - screen: C.isMobile ? React.lazy(async () => import('./manage-contacts')) : () => <>, + screen: ManageContactsScreen, }, webLinks: C.makeScreen(WebLinks, { getOptions: ({route}) => ({ @@ -206,9 +211,9 @@ export const newRoutes = { title: route.params.title, }), }), -} +}) -export const newModalRoutes = { +export const newModalRoutes = defineRouteMap({ ...sharedNewModalRoutes, checkPassphraseBeforeDeleteAccount: C.makeScreen( React.lazy(async () => import('./delete-confirm/check-passphrase')), @@ -230,4 +235,4 @@ export const newModalRoutes = { }, }) : {screen: () => <>}, -} +}) diff --git a/shared/signup/routes.tsx b/shared/signup/routes.tsx index 51556418c18c..c5cfe50fde68 100644 --- a/shared/signup/routes.tsx +++ b/shared/signup/routes.tsx @@ -4,6 +4,7 @@ import * as Kb from '@/common-adapters' import {InfoIcon} from './common' import {usePushState} from '@/stores/push' import {setSignupEmail} from '@/people/signup-email' +import {defineRouteMap} from '@/constants/types/router' const EmailSkipButton = () => { const showPushPrompt = usePushState(s => C.isMobile && !s.hasPermissions && s.showPushPrompt) @@ -36,7 +37,7 @@ const PhoneSkipButton = () => { ) } -export const newRoutes = { +export const newRoutes = defineRouteMap({ signupEnterDevicename: { getOptions: {title: 'Name this device'}, screen: React.lazy(async () => import('./device-name')), @@ -61,10 +62,10 @@ export const newRoutes = { getOptions: {title: 'Send feedback'}, screen: React.lazy(async () => import('./feedback')), }, -} +}) // Some screens in signup show up after we've actually signed up -export const newModalRoutes = { +export const newModalRoutes = defineRouteMap({ signupEnterEmail: { getOptions: {headerLeft: () => null, headerRight: () => , title: 'Your email address'}, screen: React.lazy(async () => import('./email')), @@ -81,4 +82,4 @@ export const newModalRoutes = { getOptions: {title: 'Verify phone number'}, screen: React.lazy(async () => import('./phone-number/verify')), }, -} +}) diff --git a/shared/stores/chat.tsx b/shared/stores/chat.tsx index e76d87a8c69c..3962a89f0bb9 100644 --- a/shared/stores/chat.tsx +++ b/shared/stores/chat.tsx @@ -11,8 +11,8 @@ import isEqual from 'lodash/isEqual' import logger from '@/logger' import type {State as DaemonState} from '@/stores/daemon' import type * as Router2 from '@/constants/router' -import {type ChatProviderProps, ProviderScreen} from '@/stores/convostate' -import type {GetOptionsRet} from '@/constants/types/router' +import {ProviderScreen} from '@/stores/convostate' +import type {GetOptionsRet, RouteDef} from '@/constants/types/router' import {RPCError} from '@/util/errors' import {bodyToJSON} from '@/constants/rpc-utils' import {clearChatStores, chatStores} from '@/stores/convostate' @@ -2014,34 +2014,69 @@ export const useChatState = Z.createZustand('chat', (set, get) => { } }) -type InferComponentProps = - T extends React.LazyExoticComponent< - React.ComponentType | undefined> - > - ? P - : T extends React.ComponentType | undefined> - ? P - : undefined +type IsExactlyRecord = [T] extends [Record] + ? [Record] extends [T] + ? true + : false + : false + +type NavigatorParamsFromProps

= P extends Record + ? IsExactlyRecord

extends true + ? undefined + : keyof P extends never + ? undefined + : P + : undefined + +type AddConversationIDKey

= P extends Record + ? Omit & {conversationIDKey?: T.Chat.ConversationIDKey} + : {conversationIDKey?: T.Chat.ConversationIDKey} + +type LazyInnerComponent> = + COM extends React.LazyExoticComponent ? Inner : never + +type ChatScreenParams> = NavigatorParamsFromProps< + AddConversationIDKey>> +> + +type ChatScreenProps> = StaticScreenProps> +type ChatScreenComponent> = ( + p: ChatScreenProps +) => React.ReactElement export function makeChatScreen>( Component: COM, options?: { getOptions?: | GetOptionsRet - | ((props: ChatProviderProps>>) => GetOptionsRet) + | ((props: ChatScreenProps) => GetOptionsRet) skipProvider?: boolean canBeNullConvoID?: boolean } -) { +): RouteDef, ChatScreenParams> { + const getOptionsOption = options?.getOptions + const getOptions = typeof getOptionsOption === 'function' + ? (p: ChatScreenProps) => + // getOptions can run before params are materialized on the route object. + getOptionsOption({ + ...p, + route: { + ...p.route, + params: (((p.route as {params?: ChatScreenParams}).params ?? {}) as ChatScreenParams), + }, + }) + : getOptionsOption return { ...options, - screen: function Screen(p: ChatProviderProps>>) { + getOptions, + screen: function Screen(p: ChatScreenProps) { const Comp = Component as any + const params = (((p.route as {params?: ChatScreenParams}).params ?? {}) as ChatScreenParams) return options?.skipProvider ? ( - + ) : ( - + ) }, diff --git a/shared/stores/convostate.tsx b/shared/stores/convostate.tsx index 25d30901a7f9..5c1a168f1d30 100644 --- a/shared/stores/convostate.tsx +++ b/shared/stores/convostate.tsx @@ -3619,23 +3619,37 @@ export function useConvoState(id: T.Chat.ConversationIDKey, selector: (state: return useStore(store, selector) } -export type ChatProviderProps = T & {route: {params: {conversationIDKey?: T.Chat.ConversationIDKey}}} +type ChatRouteParams = {conversationIDKey?: T.Chat.ConversationIDKey} type RouteParams = { - route: {params: {conversationIDKey?: T.Chat.ConversationIDKey}} + route: {params?: ChatRouteParams} } export function ProviderScreen(p: {children: React.ReactNode; rp: RouteParams; canBeNull?: boolean}) { return ( - + {p.children} ) } -import type {NavigateAppendType} from '@/router-v2/route-params' +import type {NoParamRouteKeys, ParamRouteKeys, RootParamList} from '@/router-v2/route-params' export const useChatNavigateAppend = () => { const cid = useChatContext(s => s.id) - return (makePath: (cid: T.Chat.ConversationIDKey) => NavigateAppendType, replace?: boolean) => { - navigateAppend(makePath(cid), replace) + function chatNavigateAppend( + makePath: (cid: T.Chat.ConversationIDKey) => RouteName, + replace?: boolean + ): void + function chatNavigateAppend( + makePath: (cid: T.Chat.ConversationIDKey) => {name: RouteName; params: RootParamList[RouteName]}, + replace?: boolean + ): void + function chatNavigateAppend( + makePath: + | ((cid: T.Chat.ConversationIDKey) => NoParamRouteKeys) + | ((cid: T.Chat.ConversationIDKey) => {name: ParamRouteKeys; params: object | undefined}), + replace?: boolean + ) { + navigateAppend(makePath(cid) as never, replace) } + return chatNavigateAppend } diff --git a/shared/stores/teams.tsx b/shared/stores/teams.tsx index 56ab146d06c8..2e70dd303772 100644 --- a/shared/stores/teams.tsx +++ b/shared/stores/teams.tsx @@ -1,5 +1,5 @@ import * as S from '@/constants/strings' -import {ignorePromise, wrapErrors} from '@/constants/utils' +import {ignorePromise} from '@/constants/utils' import * as T from '@/constants/types' import type * as EngineGen from '@/constants/rpc' import { @@ -753,23 +753,18 @@ export const maybeGetMostRecentValidInviteLink = (inviteLinks: ReadonlyArray> channelSelectedMembers: Map> deletedTeams: Array errorInAddToTeam: string - errorInEditDescription: string errorInEditMember: {error: string; teamID: T.Teams.TeamID; username: string} errorInEditWelcomeMessage: string errorInEmailInvite: T.Teams.EmailInviteError - errorInSettings: string newTeamRequests: Map> newTeams: Set teamIDToResetUsers: Map> teamIDToWelcomeMessage: Map teamNameToLoadingInvites: Map> - errorInTeamCreation: string teamNameToID: Map teamMetaSubscribeCount: number // if >0 we are eagerly reloading team list teamnames: Set // TODO remove @@ -784,40 +779,26 @@ type Store = T.Immutable<{ teamSelectedChannels: Map> teamSelectedMembers: Map> teamAccessRequestsPending: Set - teamListFilter: string - teamListSort: T.Teams.TeamListSort newTeamWizard: T.Teams.NewTeamWizardState addMembersWizard: T.Teams.AddMembersWizardState - errorInTeamJoin: string - teamInviteDetails: T.Teams.TeamInviteState - teamJoinSuccess: boolean - teamJoinSuccessOpen: boolean - teamJoinSuccessTeamName: string teamVersion: Map teamIDToMembers: Map> // Used by chat sidebar until team loading gets easier teamIDToRetentionPolicy: Map treeLoaderTeamIDToSparseMemberInfos: Map> teamMemberToTreeMemberships: Map> teamMemberToLastActivity: Map> - teamProfileAddList: Array }> const initialStore: Store = { activityLevels: {channels: new Map(), loaded: false, teams: new Map()}, addMembersWizard: addMembersWizardEmptyState, - addUserToTeamsResults: '', - addUserToTeamsState: 'notStarted', channelInfo: new Map(), channelSelectedMembers: new Map(), deletedTeams: [], errorInAddToTeam: '', - errorInEditDescription: '', errorInEditMember: emptyErrorInEditMember, errorInEditWelcomeMessage: '', errorInEmailInvite: emptyEmailInviteError, - errorInSettings: '', - errorInTeamCreation: '', - errorInTeamJoin: '', newTeamRequests: new Map(), newTeamWizard: newTeamWizardEmptyState, newTeams: new Set(), @@ -830,12 +811,6 @@ const initialStore: Store = { teamIDToResetUsers: new Map(), teamIDToRetentionPolicy: new Map(), teamIDToWelcomeMessage: new Map(), - teamInviteDetails: {inviteID: '', inviteKey: ''}, - teamJoinSuccess: false, - teamJoinSuccessOpen: false, - teamJoinSuccessTeamName: '', - teamListFilter: '', - teamListSort: 'role', teamMemberToLastActivity: new Map(), teamMemberToTreeMemberships: new Map(), teamMeta: new Map(), @@ -843,7 +818,6 @@ const initialStore: Store = { teamMetaSubscribeCount: 0, teamNameToID: new Map(), teamNameToLoadingInvites: new Map(), - teamProfileAddList: [], teamRoleMap: {latestKnownVersion: -1, loadedVersion: -1, roles: new Map()}, teamSelectedChannels: new Map(), teamSelectedMembers: new Map(), @@ -862,9 +836,6 @@ export type State = Store & { ) => void onUsersUpdates?: (updates: ReadonlyArray<{name: string; info: Partial}>) => void } - dynamic: { - respondToInviteLink?: (accept: boolean) => void - } addMembersWizardPushMembers: (members: Array) => void addMembersWizardRemoveMember: (assertion: string) => void addMembersWizardSetDefaultChannels: ( @@ -878,7 +849,6 @@ export type State = Store & { sendChatNotification: boolean, fromTeamBuilder?: boolean ) => void - addUserToTeams: (role: T.Teams.TeamRoleType, teams: Array, user: string) => void cancelAddMembersWizard: () => void channelSetMemberSelected: ( conversationIDKey: T.Chat.ConversationIDKey, @@ -887,7 +857,6 @@ export type State = Store & { clearAll?: boolean ) => void checkRequestedAccess: (teamname: string) => void - clearAddUserToTeamsResults: () => void clearNavBadges: () => void createNewTeam: ( teamname: string, @@ -905,14 +874,12 @@ export type State = Store & { deleteTeam: (teamID: T.Teams.TeamID) => void eagerLoadTeams: () => void editMembership: (teamID: T.Teams.TeamID, usernames: Array, role: T.Teams.TeamRoleType) => void - editTeamDescription: (teamID: T.Teams.TeamID, description: string) => void finishNewTeamWizard: () => void finishedAddMembersWizard: () => void getActivityForTeams: () => void getMembers: (teamID: T.Teams.TeamID) => Promise getTeamRetentionPolicy: (teamID: T.Teams.TeamID) => void getTeams: (subscribe?: boolean, forceReload?: boolean) => void - getTeamProfileAddList: (username: string) => void ignoreRequest: (teamID: T.Teams.TeamID, teamname: string, username: string) => void inviteToTeamByEmail: ( invitees: string, @@ -929,7 +896,6 @@ export type State = Store & { fullName: string, loadingKey?: string ) => void - joinTeam: (teamname: string, deeplink?: boolean) => void launchNewTeamWizardOrModal: (subteamOf?: T.Teams.TeamID) => void leaveTeam: (teamname: string, permanent: boolean, context: 'teams' | 'chat') => void loadTeam: (teamID: T.Teams.TeamID, _subscribe?: boolean) => void @@ -942,21 +908,15 @@ export type State = Store & { notifyTreeMembershipsPartial: (membership: T.RPCChat.Keybase1.TeamTreeMembership) => void notifyTeamTeamRoleMapChanged: (newVersion: number) => void onEngineIncomingImpl: (action: EngineGen.Actions) => void - openInviteLink: (inviteID: string, inviteKey: string) => void onGregorPushState: (gs: Array<{md: T.RPCGen.Gregor1.Metadata; item: T.RPCGen.Gregor1.Item}>) => void reAddToTeam: (teamID: T.Teams.TeamID, username: string) => void refreshTeamRoleMap: () => void removeMember: (teamID: T.Teams.TeamID, username: string) => void removePendingInvite: (teamID: T.Teams.TeamID, inviteID: string) => void renameTeam: (oldName: string, newName: string) => void - requestInviteLinkDetails: () => void resetErrorInEmailInvite: () => void - resetErrorInSettings: () => void - resetErrorInTeamCreation: () => void resetState: () => void - resetTeamJoin: () => void resetTeamMetaStale: () => void - resetTeamProfileAddList: () => void saveChannelMembership: ( teamID: T.Teams.TeamID, oldChannelState: T.Teams.ChannelMembershipState, @@ -985,8 +945,6 @@ export type State = Store & { ) => void setNewTeamRequests: (newTeamRequests: Map>) => void setPublicity: (teamID: T.Teams.TeamID, settings: T.Teams.PublicitySettings) => void - setTeamListFilter: (filter: string) => void - setTeamListSort: (sortOrder: T.Teams.TeamListSort) => void setTeamRetentionPolicy: (teamID: T.Teams.TeamID, policy: T.Retention.RetentionPolicy) => void setTeamRoleMapLatestKnownVersion: (version: number) => void setTeamSawChatBanner: () => void @@ -1256,66 +1214,6 @@ export const useTeamsState = Z.createZustand('teams', (set, get) => { } ignorePromise(f()) }, - addUserToTeams: (role, teams, user) => { - const f = async () => { - const teamsAddedTo: Array = [] - const errorAddingTo: Array = [] - for (const team of teams) { - try { - const teamID = getTeamID(get(), team) - if (teamID === T.Teams.noTeamID) { - logger.warn(`no team ID found for ${team}`) - errorAddingTo.push(team) - continue - } - await T.RPCGen.teamsTeamAddMemberRpcPromise( - { - email: '', - phone: '', - role: T.RPCGen.TeamRole[role], - sendChatNotification: true, - teamID, - username: user, - }, - [S.waitingKeyTeamsTeam(teamID), S.waitingKeyTeamsAddUserToTeams(user)] - ) - teamsAddedTo.push(team) - } catch { - errorAddingTo.push(team) - } - } - - // TODO: We should split these results into two messages, showing one in green and - // the other in red instead of lumping them together. - let result = '' - if (teamsAddedTo.length) { - result += `${user} was added to ` - if (teamsAddedTo.length > 3) { - result += `${teamsAddedTo[0]}, ${teamsAddedTo[1]}, and ${teamsAddedTo.length - 2} teams.` - } else if (teamsAddedTo.length === 3) { - result += `${teamsAddedTo[0]}, ${teamsAddedTo[1]}, and ${teamsAddedTo[2]}.` - } else if (teamsAddedTo.length === 2) { - result += `${teamsAddedTo[0]} and ${teamsAddedTo[1]}.` - } else { - result += `${teamsAddedTo[0]}.` - } - } - - if (errorAddingTo.length) { - if (result.length > 0) { - result += ' But we ' - } else { - result += 'We ' - } - result += `were unable to add ${user} to ${errorAddingTo.join(', ')}.` - } - set(s => { - s.addUserToTeamsResults = result - s.addUserToTeamsState = errorAddingTo.length > 0 ? 'failed' : 'succeeded' - }) - } - ignorePromise(f()) - }, cancelAddMembersWizard: () => { set(s => { s.addMembersWizard = T.castDraft({...addMembersWizardEmptyState}) @@ -1348,12 +1246,6 @@ export const useTeamsState = Z.createZustand('teams', (set, get) => { } ignorePromise(f()) }, - clearAddUserToTeamsResults: () => { - set(s => { - s.addUserToTeamsResults = '' - s.addUserToTeamsState = 'notStarted' - }) - }, clearNavBadges: () => { const f = async () => { try { @@ -1366,9 +1258,6 @@ export const useTeamsState = Z.createZustand('teams', (set, get) => { ignorePromise(f()) }, createNewTeam: (teamname, joinSubteam, fromChat, thenAddMembers) => { - set(s => { - s.errorInTeamCreation = '' - }) const f = async () => { try { const {teamID} = await T.RPCGen.teamsTeamCreateRpcPromise( @@ -1397,20 +1286,11 @@ export const useTeamsState = Z.createZustand('teams', (set, get) => { navigateAppend({name: 'profileEditAvatar', params: {createdTeam: true, teamID}}) } } - } catch (error) { - set(s => { - if (error instanceof RPCError) { - s.errorInTeamCreation = error.desc - } - }) - } + } catch {} } ignorePromise(f()) }, createNewTeamFromConversation: (conversationIDKey, teamname) => { - set(s => { - s.errorInTeamCreation = '' - }) const me = useCurrentUserState.getState().username const participantInfo = storeRegistry.getConvoState(conversationIDKey).participants // exclude bots from the newly created team, they can be added back later. @@ -1492,9 +1372,6 @@ export const useTeamsState = Z.createZustand('teams', (set, get) => { } ignorePromise(f()) }, - dynamic: { - respondToInviteLink: undefined, - }, eagerLoadTeams: () => { if (get().teamMetaSubscribeCount > 0) { logger.info('eagerly reloading') @@ -1529,23 +1406,6 @@ export const useTeamsState = Z.createZustand('teams', (set, get) => { } ignorePromise(f()) }, - editTeamDescription: (teamID, description) => { - set(s => { - s.errorInEditDescription = '' - }) - const f = async () => { - try { - await T.RPCGen.teamsSetTeamShowcaseRpcPromise({description, teamID}, S.waitingKeyTeamsTeam(teamID)) - } catch (error) { - set(s => { - if (error instanceof RPCError) { - s.errorInEditDescription = error.message - } - }) - } - } - ignorePromise(f()) - }, finishNewTeamWizard: () => { set(s => { s.newTeamWizard.error = undefined @@ -1647,23 +1507,6 @@ export const useTeamsState = Z.createZustand('teams', (set, get) => { } return }, - getTeamProfileAddList: username => { - const f = async () => { - const res = - (await T.RPCGen.teamsTeamProfileAddListRpcPromise({username}, S.waitingKeyTeamsProfileAddList)) ?? - [] - const teamlist = res.map(team => ({ - disabledReason: team.disabledReason, - open: team.open, - teamName: team.teamName.parts ? team.teamName.parts.join('.') : '', - })) - teamlist.sort((a, b) => a.teamName.localeCompare(b.teamName)) - set(s => { - s.teamProfileAddList = teamlist - }) - } - ignorePromise(f()) - }, getTeamRetentionPolicy: teamID => { const f = async () => { let retentionPolicy = Util.makeRetentionPolicy() @@ -1848,69 +1691,6 @@ export const useTeamsState = Z.createZustand('teams', (set, get) => { } ignorePromise(f()) }, - joinTeam: (teamname, deeplink) => { - set(s => { - s.teamInviteDetails.inviteDetails = undefined - }) - - const f = async () => { - // In the deeplink flow, a modal is displayed which runs `joinTeam` (or an - // alternative flow, but we're not concerned with that here). In that case, - // we can fully manage the UX from inside of this handler. - // In the "Join team" flow, user pastes their link into the input box, which - // then calls `joinTeam` on its own. Since we need to switch to another modal, - // we simply plumb `deeplink` into the `promptInviteLinkJoin` handler and - // do the nav in the modal. - get().dispatch.resetTeamJoin() - try { - const result = await T.RPCGen.teamsTeamAcceptInviteOrRequestAccessRpcListener({ - customResponseIncomingCallMap: { - 'keybase.1.teamsUi.confirmInviteLinkAccept': (params, response) => { - set(s => { - s.teamInviteDetails.inviteDetails = T.castDraft(params.details) - }) - if (!deeplink) { - navigateAppend('teamInviteLinkJoin', true) - } - set(s => { - s.dispatch.dynamic.respondToInviteLink = wrapErrors((accept: boolean) => { - set(s => { - s.dispatch.dynamic.respondToInviteLink = undefined - }) - response.result(accept) - }) - }) - }, - }, - incomingCallMap: {}, - params: {tokenOrName: teamname}, - waitingKey: S.waitingKeyTeamsJoinTeam, - }) - set(s => { - s.teamJoinSuccess = true - s.teamJoinSuccessOpen = result.wasOpenTeam - s.teamJoinSuccessTeamName = result.wasTeamName ? teamname : '' - }) - } catch (error) { - if (error instanceof RPCError) { - const desc = - error.code === T.RPCGen.StatusCode.scteaminvitebadtoken - ? 'Sorry, that team name or token is not valid.' - : error.code === T.RPCGen.StatusCode.scnotfound - ? 'This invitation is no longer valid, or has expired.' - : error.desc - set(s => { - s.errorInTeamJoin = desc - }) - } - } finally { - set(s => { - s.dispatch.dynamic.respondToInviteLink = undefined - }) - } - } - ignorePromise(f()) - }, launchNewTeamWizardOrModal: subteamOf => { set(s => { s.newTeamWizard = T.castDraft({ @@ -2052,12 +1832,9 @@ export const useTeamsState = Z.createZustand('teams', (set, get) => { s.teamIDToWelcomeMessage.set(teamID, message) }) } catch (error) { - set(s => { - if (error instanceof RPCError) { - logger.error(error) - s.errorInSettings = error.desc - } - }) + if (error instanceof RPCError) { + logger.error(error) + } } } ignorePromise(f()) @@ -2269,14 +2046,6 @@ export const useTeamsState = Z.createZustand('teams', (set, get) => { new Set(bodyToJSON(chosenChannels?.item.body) as Array) ) }, - openInviteLink: (inviteID, inviteKey) => { - set(s => { - s.teamInviteDetails.inviteDetails = undefined - s.teamInviteDetails.inviteID = inviteID - s.teamInviteDetails.inviteKey = inviteKey - }) - navigateAppend('teamInviteLinkJoin') - }, reAddToTeam: (teamID, username) => { const f = async () => { try { @@ -2362,66 +2131,18 @@ export const useTeamsState = Z.createZustand('teams', (set, get) => { } ignorePromise(f()) }, - requestInviteLinkDetails: () => { - const f = async () => { - try { - const details = await T.RPCGen.teamsGetInviteLinkDetailsRpcPromise({ - inviteID: get().teamInviteDetails.inviteID, - }) - set(s => { - s.teamInviteDetails.inviteDetails = T.castDraft(details) - }) - } catch (error) { - if (error instanceof RPCError) { - const desc = - error.code === T.RPCGen.StatusCode.scteaminvitebadtoken - ? 'Sorry, that invite token is not valid.' - : error.code === T.RPCGen.StatusCode.scnotfound - ? 'This invitation is no longer valid, or has expired.' - : error.desc - set(s => { - s.errorInTeamJoin = desc - }) - } - } - } - ignorePromise(f()) - }, resetErrorInEmailInvite: () => { set(s => { s.errorInEmailInvite.message = '' s.errorInEmailInvite.malformed = new Set() }) }, - resetErrorInSettings: () => { - set(s => { - s.errorInSettings = '' - }) - }, - resetErrorInTeamCreation: () => { - set(s => { - s.errorInTeamCreation = '' - }) - }, resetState: Z.defaultReset, - resetTeamJoin: () => { - set(s => { - s.errorInTeamJoin = '' - s.teamJoinSuccess = false - s.teamJoinSuccessOpen = false - s.teamJoinSuccessTeamName = '' - }) - }, resetTeamMetaStale: () => { set(s => { s.teamMetaStale = true }) }, - resetTeamProfileAddList: () => { - set(s => { - s.teamProfileAddList = [] - }) - }, saveChannelMembership: (teamID, oldChannelState, newChannelState) => { const f = async () => { const waitingKey = S.waitingKeyTeamsTeam(teamID) @@ -2496,11 +2217,9 @@ export const useTeamsState = Z.createZustand('teams', (set, get) => { ]) get().dispatch.getTeams(false) } catch (error) { - set(s => { - if (error instanceof RPCError) { - s.errorInSettings = error.desc - } - }) + if (error instanceof RPCError) { + logger.info(error.message) + } } } ignorePromise(f()) @@ -2589,30 +2308,17 @@ export const useTeamsState = Z.createZustand('teams', (set, get) => { } ignorePromise(f()) }, - setTeamListFilter: filter => { - set(s => { - s.teamListFilter = filter - }) - }, - setTeamListSort: (sortOrder: T.Teams.TeamListSort) => { - set(s => { - s.teamListSort = sortOrder - }) - }, setTeamRetentionPolicy: (teamID, policy) => { const f = async () => { try { const servicePolicy = Util.retentionPolicyToServiceRetentionPolicy(policy) await T.RPCChat.localSetTeamRetentionLocalRpcPromise({policy: servicePolicy, teamID}, [ - S.waitingKeyTeamsTeam(teamID), + S.waitingKeyTeamsSetRetentionPolicy(teamID), ]) } catch (error) { - set(s => { - if (error instanceof RPCError) { - logger.error(error.message) - s.errorInSettings = error.desc - } - }) + if (error instanceof RPCError) { + logger.error(error.message) + } } } ignorePromise(f()) diff --git a/shared/stores/tests/teams.test.ts b/shared/stores/tests/teams.test.ts index 493046f68d5c..e44f9820c0af 100644 --- a/shared/stores/tests/teams.test.ts +++ b/shared/stores/tests/teams.test.ts @@ -37,15 +37,6 @@ test('channel and member selection can add, remove, and clear all choices', () = expect(useTeamsState.getState().teamSelectedMembers.has(parentTeamID)).toBe(false) }) -test('team list controls update store-local view state', () => { - const state = useTeamsState.getState() - - state.dispatch.setTeamListFilter('acme') - state.dispatch.setTeamListSort('activity') - expect(useTeamsState.getState().teamListFilter).toBe('acme') - expect(useTeamsState.getState().teamListSort).toBe('activity') -}) - test('add members role updates synchronize top-level and per-member roles', () => { useTeamsState.setState({ addMembersWizard: { diff --git a/shared/teams/container.tsx b/shared/teams/container.tsx index 6d6f60555d68..973b80d4d787 100644 --- a/shared/teams/container.tsx +++ b/shared/teams/container.tsx @@ -6,6 +6,15 @@ import Main from './main' import {useTeamsSubscribe} from './subscriber' import {useActivityLevels} from './common' import {useSafeNavigation} from '@/util/safe-navigation' +import {useNavigation} from '@react-navigation/native' +import type {NativeStackNavigationProp} from '@react-navigation/native-stack' + +type TeamsRootParamList = { + teamsRoot: { + filter?: string + sort?: T.Teams.TeamListSort + } +} const orderTeams = ( teams: ReadonlyMap, @@ -42,11 +51,16 @@ const orderTeams = ( }) } -const Connected = () => { +type Props = { + filter?: string + sort?: T.Teams.TeamListSort +} + +const Connected = ({filter = '', sort = 'role'}: Props) => { const data = Teams.useTeamsState( C.useShallow(s => { - const {deletedTeams, activityLevels, teamMeta, teamListFilter, dispatch} = s - const {newTeamRequests, newTeams, teamListSort, teamIDToResetUsers} = s + const {deletedTeams, activityLevels, teamMeta, dispatch} = s + const {newTeamRequests, newTeams, teamIDToResetUsers} = s const {getTeams, launchNewTeamWizardOrModal} = dispatch return { activityLevels, @@ -56,17 +70,15 @@ const Connected = () => { newTeamRequests, newTeams, teamIDToResetUsers, - teamListFilter, - teamListSort, teamMeta, } }) ) const {activityLevels, deletedTeams, newTeamRequests, newTeams} = data - const {teamIDToResetUsers, teamListFilter: filter, teamListSort: sortOrder, teamMeta: _teams} = data + const {teamIDToResetUsers, teamMeta: _teams} = data const {getTeams, launchNewTeamWizardOrModal} = data - const teams = orderTeams(_teams, newTeamRequests, teamIDToResetUsers, newTeams, sortOrder, activityLevels, filter) + const teams = orderTeams(_teams, newTeamRequests, teamIDToResetUsers, newTeams, sort, activityLevels, filter) // subscribe to teams changes useTeamsSubscribe() @@ -74,8 +86,9 @@ const Connected = () => { useActivityLevels(true) const nav = useSafeNavigation() + const navigation = useNavigation>() const onCreateTeam = () => launchNewTeamWizardOrModal() - const onJoinTeam = () => nav.safeNavigateAppend('teamJoinTeamDialog') + const onJoinTeam = () => nav.safeNavigateAppend({name: 'teamJoinTeamDialog', params: {}}) return ( @@ -83,6 +96,8 @@ const Connected = () => { onCreateTeam={onCreateTeam} onJoinTeam={onJoinTeam} deletedTeams={deletedTeams} + onChangeSort={sortOrder => navigation.setParams({filter, sort: sortOrder})} + sortOrder={sort} teams={teams} /> diff --git a/shared/teams/edit-team-description.tsx b/shared/teams/edit-team-description.tsx index 38ead0f44cf9..3756267f5684 100644 --- a/shared/teams/edit-team-description.tsx +++ b/shared/teams/edit-team-description.tsx @@ -12,8 +12,8 @@ const EditTeamDescription = (props: Props) => { const teamname = Teams.useTeamsState(s => Teams.getTeamNameFromID(s, teamID)) const waitingKey = C.waitingKeyTeamsTeam(teamID) const waiting = C.Waiting.useAnyWaiting(waitingKey) - const error = Teams.useTeamsState(s => s.errorInEditDescription) const origDescription = Teams.useTeamsState(s => s.teamDetails.get(teamID))?.description ?? '' + const editTeamDescription = C.useRPC(T.RPCGen.teamsSetTeamShowcaseRpcPromise) if (teamID === T.Teams.noTeamID || teamname === undefined) { throw new Error( @@ -22,10 +22,17 @@ const EditTeamDescription = (props: Props) => { } const [description, setDescription] = React.useState(origDescription) - const editTeamDescription = Teams.useTeamsState(s => s.dispatch.editTeamDescription) + const [error, setError] = React.useState('') const navigateUp = C.Router2.navigateUp - const onSave = () => editTeamDescription(teamID, description) + const onSave = () => { + setError('') + editTeamDescription( + [{description, teamID}, waitingKey], + () => {}, + error => setError(error.message) + ) + } const onClose = () => navigateUp() const wasWaitingRef = React.useRef(waiting) @@ -96,4 +103,3 @@ const styles = Kb.Styles.styleSheetCreate(() => ({ })) export default EditTeamDescription - diff --git a/shared/teams/get-options.tsx b/shared/teams/get-options.tsx index f0cf90c88b0f..147e3884ad64 100644 --- a/shared/teams/get-options.tsx +++ b/shared/teams/get-options.tsx @@ -1,21 +1,34 @@ import * as Kb from '@/common-adapters' +import type * as T from '@/constants/types' import {HeaderRightActions} from './main/header' import {useSafeNavigation} from '@/util/safe-navigation' import {useTeamsState} from '@/stores/teams' +import {useNavigation, useRoute} from '@react-navigation/native' +import type {RouteProp} from '@react-navigation/native' +import type {NativeStackNavigationProp} from '@react-navigation/native-stack' + +type TeamsRootParams = { + filter?: string + sort?: T.Teams.TeamListSort +} +type TeamsRootParamList = {teamsRoot: TeamsRootParams} const useHeaderActions = () => { const nav = useSafeNavigation() const launchNewTeamWizardOrModal = useTeamsState(s => s.dispatch.launchNewTeamWizardOrModal) return { onCreateTeam: () => launchNewTeamWizardOrModal(), - onJoinTeam: () => nav.safeNavigateAppend('teamJoinTeamDialog'), + onJoinTeam: () => nav.safeNavigateAppend({name: 'teamJoinTeamDialog', params: {}}), } } const TeamsFilter = () => { - const filterValue = useTeamsState(s => s.teamListFilter) + const route = useRoute>() + const params = route.params + const navigation = useNavigation>() + const filterValue = params.filter ?? '' const numTeams = useTeamsState(s => s.teamMeta.size) - const setFilter = useTeamsState(s => s.dispatch.setTeamListFilter) + const setFilter = (filter: string) => navigation.setParams({...params, filter}) return numTeams >= 20 ? ( { - const initialTeamname = ownProps.initialTeamname - const errorText = useTeamsState(s => upperFirst(s.errorInTeamJoin)) - const open = useTeamsState(s => s.teamJoinSuccessOpen) - const success = useTeamsState(s => s.teamJoinSuccess) - const successTeamName = useTeamsState(s => s.teamJoinSuccessTeamName) - const navigateUp = C.Router2.navigateUp - const onBack = () => { - navigateUp() +const getJoinTeamError = (error: unknown) => { + if (error instanceof RPCError) { + return ( + error.code === T.RPCGen.StatusCode.scteaminvitebadtoken + ? 'Sorry, that team name or token is not valid.' + : error.code === T.RPCGen.StatusCode.scnotfound + ? 'This invitation is no longer valid, or has expired.' + : error.desc + ) } - const joinTeam = useTeamsState(s => s.dispatch.joinTeam) - const onJoinTeam = joinTeam + return error instanceof Error ? error.message : 'Something went wrong.' +} +const Container = ({initialTeamname, success: successParam}: OwnProps) => { + const [errorText, setErrorText] = React.useState('') + const [open, setOpen] = React.useState(false) + const [successTeamName, setSuccessTeamName] = React.useState('') const [name, _setName] = React.useState(initialTeamname ?? '') + const joinTeam = C.useRPC(T.RPCGen.teamsTeamAcceptInviteOrRequestAccessRpcListener) + const navigation = useNavigation< + NativeStackNavigationProp + >() + const navigateUp = C.Router2.navigateUp + const success = !!successParam + const handoffToInviteRef = React.useRef(false) + const setName = (n: string) => _setName(n.toLowerCase()) - const resetTeamJoin = useTeamsState(s => s.dispatch.resetTeamJoin) + const onBack = () => navigateUp() + React.useEffect(() => { - resetTeamJoin() - }, [resetTeamJoin]) + _setName(initialTeamname ?? '') + setErrorText('') + setOpen(false) + setSuccessTeamName('') + if (successParam) { + navigation.setParams({initialTeamname, success: false}) + } + }, [initialTeamname, navigation, successParam]) const onSubmit = () => { - onJoinTeam(name) + setErrorText('') + setOpen(false) + setSuccessTeamName('') + joinTeam( + [ + { + customResponseIncomingCallMap: { + 'keybase.1.teamsUi.confirmInviteLinkAccept': (params, response) => { + handoffToInviteRef.current = true + C.Router2.navigateAppend( + { + name: 'teamInviteLinkJoin', + params: { + inviteDetails: params.details, + inviteKey: name, + }, + }, + true + ) + response.result(false) + }, + }, + incomingCallMap: {}, + params: {tokenOrName: name}, + waitingKey: C.waitingKeyTeamsJoinTeam, + }, + ], + result => { + setOpen(result.wasOpenTeam) + setSuccessTeamName(result.wasTeamName ? name : '') + navigation.setParams({initialTeamname, success: true}) + }, + error => { + if (handoffToInviteRef.current) { + handoffToInviteRef.current = false + return + } + setErrorText(upperFirst(getJoinTeamError(error))) + } + ) } return ( @@ -74,15 +138,15 @@ const Container = (ownProps: OwnProps) => { )} - - - + + + ) diff --git a/shared/teams/join-team/join-from-invite.tsx b/shared/teams/join-team/join-from-invite.tsx index 04b1b9fbf6be..a24ce3593b00 100644 --- a/shared/teams/join-team/join-from-invite.tsx +++ b/shared/teams/join-team/join-from-invite.tsx @@ -1,94 +1,120 @@ import * as C from '@/constants' -import * as React from 'react' -import {useTeamsState} from '@/stores/teams' +import * as T from '@/constants/types' import * as Kb from '@/common-adapters' -import {Success} from './container' +import {RPCError} from '@/util/errors' +import * as React from 'react' import {useSafeNavigation} from '@/util/safe-navigation' +import {Success} from './container' -const JoinFromInvite = () => { - const {inviteID: id, inviteKey: key, inviteDetails: details} = useTeamsState(s => s.teamInviteDetails) - const error = useTeamsState(s => s.errorInTeamJoin) +type Props = { + inviteDetails?: T.RPCGen.InviteLinkDetails + inviteID?: string + inviteKey?: string +} + +const getInviteError = (error: unknown, missingKey: boolean) => { + if (error instanceof RPCError) { + return ( + error.code === T.RPCGen.StatusCode.scteaminvitebadtoken + ? missingKey + ? 'Sorry, that invite token is not valid.' + : 'Sorry, that team name or token is not valid.' + : error.code === T.RPCGen.StatusCode.scnotfound + ? 'This invitation is no longer valid, or has expired.' + : error.desc + ) + } + return error instanceof Error ? error.message : 'Something went wrong.' +} + +const JoinFromInvite = ({inviteDetails: initialInviteDetails, inviteID = '', inviteKey = ''}: Props) => { + const [details, setDetails] = React.useState(initialInviteDetails) + const [error, setError] = React.useState('') const loaded = details !== undefined || !!error + const canLoadDetails = details === undefined && !error && !!inviteID + const canJoin = !!inviteKey + const missingInviteKeyError = details !== undefined && !canJoin ? 'Sorry, that invite token is not valid.' : '' + const joinTeam = C.useRPC(T.RPCGen.teamsTeamAcceptInviteOrRequestAccessRpcListener) + const requestInviteLinkDetails = C.useRPC(T.RPCGen.teamsGetInviteLinkDetailsRpcPromise) + const [clickedJoin, setClickedJoin] = React.useState(false) + const [showSuccess, setShowSuccess] = React.useState(false) + const rpcWaiting = C.Waiting.useAnyWaiting(C.waitingKeyTeamsJoinTeam) + const waiting = rpcWaiting && clickedJoin - const joinTeam = useTeamsState(s => s.dispatch.joinTeam) - const requestInviteLinkDetails = useTeamsState(s => s.dispatch.requestInviteLinkDetails) + React.useEffect(() => { + setDetails(initialInviteDetails) + setError('') + setClickedJoin(false) + setShowSuccess(false) + }, [initialInviteDetails, inviteID, inviteKey]) React.useEffect(() => { - if (loaded) { - return - } - if (key === '') { - // If we're missing the key, we want the user to paste the whole link again - requestInviteLinkDetails() + if (!canLoadDetails) { return } + requestInviteLinkDetails( + [{inviteID}], + result => { + setDetails(result) + setError('') + }, + rpcError => { + setError(getInviteError(rpcError, true)) + } + ) + }, [canLoadDetails, inviteID, requestInviteLinkDetails]) - // Otherwise we're reusing the join flow, so that we don't look up the invite id twice - // (the invite id is derived from the key). - joinTeam(key, true) - }, [requestInviteLinkDetails, joinTeam, loaded, key, id]) - - const [clickedJoin, setClickedJoin] = React.useState(false) const nav = useSafeNavigation() const onNavUp = () => nav.safeNavigateUp() - const respondToInviteLink = useTeamsState(s => s.dispatch.dynamic.respondToInviteLink) const onJoinTeam = () => { + if (!canJoin) { + return + } setClickedJoin(true) - respondToInviteLink?.(true) - } - const onClose = () => { - respondToInviteLink?.(true) - onNavUp() + setError('') + joinTeam( + [ + { + customResponseIncomingCallMap: { + 'keybase.1.teamsUi.confirmInviteLinkAccept': (params, response) => { + setDetails(params.details) + response.result(true) + }, + }, + incomingCallMap: {}, + params: {tokenOrName: inviteKey}, + waitingKey: C.waitingKeyTeamsJoinTeam, + }, + ], + () => { + setClickedJoin(false) + setShowSuccess(true) + }, + rpcError => { + setClickedJoin(false) + setError(getInviteError(rpcError, false)) + } + ) } - - const rpcWaiting = C.Waiting.useAnyWaiting(C.waitingKeyTeamsJoinTeam) - const waiting = rpcWaiting && clickedJoin - const wasWaitingRef = React.useRef(waiting) - React.useEffect(() => { - wasWaitingRef.current = waiting - }, [waiting]) - - const [showSuccess, setShowSuccess] = React.useState(false) - - React.useEffect(() => { - setShowSuccess(wasWaitingRef.current && !waiting && !error) - }, [waiting, error]) + const onClose = () => onNavUp() const teamname = (details?.teamName.parts || []).join('.') const body = details === undefined ? ( loaded ? ( - + ERROR: {error} ) : ( - + Loading... ) ) : showSuccess ? ( - + @@ -136,11 +162,12 @@ const JoinFromInvite = () => { label="Join team" onClick={onJoinTeam} style={styles.button} + disabled={!canJoin} waiting={waiting} /> - {!!error && {error}} + {!!(error || missingInviteKeyError) && {error || missingInviteKeyError}} diff --git a/shared/teams/main/index.tsx b/shared/teams/main/index.tsx index ef06e197004a..2545a3d00cd4 100644 --- a/shared/teams/main/index.tsx +++ b/shared/teams/main/index.tsx @@ -2,7 +2,6 @@ import * as Kb from '@/common-adapters' import type * as T from '@/constants/types' import TeamsFooter from './footer' import TeamRowNew from './team-row' -import {useTeamsState} from '@/stores/teams' import {PerfProfiler} from '@/perf/react-profiler' type DeletedTeam = { @@ -12,8 +11,10 @@ type DeletedTeam = { export type Props = { deletedTeams: ReadonlyArray + onChangeSort: (sortOrder: T.Teams.TeamListSort) => void onCreateTeam: () => void onJoinTeam: () => void + sortOrder: T.Teams.TeamListSort teams: ReadonlyArray } @@ -56,8 +57,7 @@ const sortOrderToTitle = { alphabetical: 'Alphabetical', role: 'Your role', } -const SortHeader = () => { - const onChangeSort = useTeamsState(s => s.dispatch.setTeamListSort) +const SortHeader = ({onChangeSort, sortOrder}: {onChangeSort: Props['onChangeSort']; sortOrder: Props['sortOrder']}) => { const makePopup = (p: Kb.Popup2Parms) => { const {attachTo, hidePopup} = p return ( @@ -85,7 +85,6 @@ const SortHeader = () => { } const {popup, showPopup, popupAnchor} = Kb.usePopup2(makePopup) - const sortOrder = useTeamsState(s => s.teamListSort) return ( @@ -105,12 +104,12 @@ const teamRowItemHeight = {height: teamRowHeight, type: 'fixed' as const} type TeamItem = T.Teams.TeamMeta const Teams = function Teams(p: Props) { - const {deletedTeams, teams, onCreateTeam, onJoinTeam} = p + const {deletedTeams, teams, onCreateTeam, onJoinTeam, onChangeSort, sortOrder} = p const listHeader = ( <> - + {deletedTeams.map(dt => ( openUrl('https://book.keybase.io/docs/teams/design type Props = { baseTeam?: string // if set we're creating a subteam of this teamname - errorText: string onCancel: () => void - onClearError: () => void onSubmit: (fullName: string, joinSubteam: boolean) => void } @@ -21,16 +19,19 @@ export const CreateNewTeam = (props: Props) => { const [name, setName] = React.useState('') const [joinSubteam, setJoinSubteam] = React.useState(true) const waiting = C.Waiting.useAnyWaiting(C.waitingKeyTeamsCreation) + const errorText = upperFirst(C.Waiting.useAnyErrors(C.waitingKeyTeamsCreation)?.message ?? '') + const dispatchClearWaiting = C.Waiting.useDispatchClearWaiting() const {baseTeam, onSubmit} = props const isSubteam = !!baseTeam - const onSubmitCb = () => (isSubteam ? onSubmit(baseTeam + '.' + name, joinSubteam) : onSubmit(name, false)) + const onSubmitCb = () => { + dispatchClearWaiting(C.waitingKeyTeamsCreation) + return isSubteam ? onSubmit(baseTeam + '.' + name, joinSubteam) : onSubmit(name, false) + } const disabled = name.length < 2 - // clear error we may have hit on unmount - const {onClearError} = props - React.useEffect(() => () => onClearError(), [onClearError]) + React.useEffect(() => () => dispatchClearWaiting(C.waitingKeyTeamsCreation), [dispatchClearWaiting]) return ( <> @@ -51,7 +52,7 @@ export const CreateNewTeam = (props: Props) => { /> ) : null} - {props.errorText ? {props.errorText} : null} + {errorText ? {errorText} : null} { const subteamOf = ownProps.subteamOf ?? T.Teams.noTeamID const baseTeam = Teams.useTeamsState(s => Teams.getTeamMeta(s, subteamOf).teamname) - const errorText = Teams.useTeamsState(s => upperFirst(s.errorInTeamCreation)) const navigateUp = C.Router2.navigateUp const onCancel = () => { navigateUp() } - const resetErrorInTeamCreation = Teams.useTeamsState(s => s.dispatch.resetErrorInTeamCreation) const createNewTeam = Teams.useTeamsState(s => s.dispatch.createNewTeam) - const onClearError = resetErrorInTeamCreation const onSubmit = (teamname: string, joinSubteam: boolean) => { createNewTeam(teamname, joinSubteam) } const props = { baseTeam, - errorText, onCancel, - onClearError, onSubmit, } return diff --git a/shared/teams/routes.tsx b/shared/teams/routes.tsx index 6860a557c57b..2d140b0446c4 100644 --- a/shared/teams/routes.tsx +++ b/shared/teams/routes.tsx @@ -10,6 +10,12 @@ import contactRestricted from '../team-building/contact-restricted.page' import teamsTeamBuilder from '../team-building/page' import {useModalHeaderState} from '@/stores/modal-header' import teamsRootGetOptions from './get-options' +import {defineRouteMap} from '@/constants/types/router' + +type TeamRouteParams = { + teamID: T.Teams.TeamID + initialTab?: T.Teams.TabKey +} const AddToChannelsHeaderTitle = ({teamID}: {teamID: T.Teams.TeamID}) => { const title = useModalHeaderState(s => s.title) @@ -155,16 +161,9 @@ const AddFromWhereHeaderTitle = () => { return } -const JoinTeamHeaderTitle = () => { - const success = Teams.useTeamsState(s => s.teamJoinSuccess) - return <>{success ? 'Request sent' : 'Join a team'} -} +const JoinTeamHeaderTitle = ({success}: {success?: boolean}) => <>{success ? 'Request sent' : 'Join a team'} -const JoinTeamHeaderLeft = () => { - const success = Teams.useTeamsState(s => s.teamJoinSuccess) - if (success) return null - return -} +const JoinTeamHeaderLeft = ({success}: {success?: boolean}) => (success ? null : ) const NewTeamInfoHeaderTitle = () => { const {teamType, parentTeamID} = Teams.useTeamsState( @@ -192,7 +191,7 @@ const NewTeamInfoHeaderLeft = () => { return } -export const newRoutes = { +export const newRoutes = defineRouteMap({ team: C.makeScreen( React.lazy(async () => import('./team')), {getOptions: {headerShadowVisible: false, headerTitle: ''}} @@ -217,12 +216,14 @@ export const newRoutes = { {getOptions: {headerShadowVisible: false, headerTitle: ''}} ), teamsRoot: { - getOptions: teamsRootGetOptions, - screen: React.lazy(async () => import('./container')), + ...C.makeScreen(React.lazy(async () => import('./container')), { + getOptions: teamsRootGetOptions, + }), + initialParams: {}, }, -} +}) -export const newModalRoutes = { +export const newModalRoutes = defineRouteMap({ contactRestricted, openTeamWarning: C.makeScreen(React.lazy(async () => import('./team/settings-tab/open-team-warning'))), retentionWarning: C.makeScreen(React.lazy(async () => import('./team/settings-tab/retention/warning'))), @@ -299,10 +300,10 @@ export const newModalRoutes = { teamInviteByEmail: C.makeScreen(React.lazy(async () => import('./invite-by-email'))), teamInviteLinkJoin: C.makeScreen(React.lazy(async () => import('./join-team/join-from-invite'))), teamJoinTeamDialog: C.makeScreen(React.lazy(async () => import('./join-team/container')), { - getOptions: { - headerLeft: () => , - headerTitle: () => , - }, + getOptions: ({route}) => ({ + headerLeft: () => , + headerTitle: () => , + }), }), teamNewTeamDialog: C.makeScreen(React.lazy(async () => import('./new-team')), { getOptions: {title: 'Create a team'}, @@ -350,4 +351,4 @@ export const newModalRoutes = { }, }), teamsTeamBuilder, -} +}) diff --git a/shared/teams/team/index.tsx b/shared/teams/team/index.tsx index 30c700f072d4..46d75ea57e51 100644 --- a/shared/teams/team/index.tsx +++ b/shared/teams/team/index.tsx @@ -28,6 +28,13 @@ type Props = { const lastSelectedTabs = new Map() const defaultTab: T.Teams.TabKey = 'members' +const getSettingsErrorWaitingKeys = (teamID: T.Teams.TeamID) => + [ + C.waitingKeyTeamsLoadWelcomeMessage(teamID), + C.waitingKeyTeamsSetMemberPublicity(teamID), + C.waitingKeyTeamsSetRetentionPolicy(teamID), + ] as const + const useTabsState = ( teamID: T.Teams.TeamID, providedTab?: T.Teams.TabKey @@ -35,11 +42,11 @@ const useTabsState = ( const loadTeamChannelList = Teams.useTeamsState(s => s.dispatch.loadTeamChannelList) const defaultSelectedTab = lastSelectedTabs.get(teamID) ?? providedTab ?? defaultTab const [selectedTab, _setSelectedTab] = React.useState(defaultSelectedTab) - const resetErrorInSettings = Teams.useTeamsState(s => s.dispatch.resetErrorInSettings) + const dispatchClearWaiting = C.Waiting.useDispatchClearWaiting() const setSelectedTab = (t: T.Teams.TabKey) => { lastSelectedTabs.set(teamID, t) if (selectedTab !== 'settings' && t === 'settings') { - resetErrorInSettings() + dispatchClearWaiting(getSettingsErrorWaitingKeys(teamID)) } if (selectedTab !== 'channels' && t === 'channels') { loadTeamChannelList(teamID) @@ -54,14 +61,14 @@ const useTabsState = ( prevTeamIDRef.current = teamID lastSelectedTabs.set(teamID, defaultSelectedTab) if (defaultSelectedTab === 'settings') { - resetErrorInSettings() + dispatchClearWaiting(getSettingsErrorWaitingKeys(teamID)) } if (defaultSelectedTab === 'channels') { loadTeamChannelList(teamID) } _setSelectedTab(defaultSelectedTab) } - }, [teamID, defaultSelectedTab, resetErrorInSettings, loadTeamChannelList]) + }, [teamID, defaultSelectedTab, dispatchClearWaiting, loadTeamChannelList]) return [selectedTab, setSelectedTab] } diff --git a/shared/teams/team/settings-tab/index.tsx b/shared/teams/team/settings-tab/index.tsx index e16856da8c6a..d0513bf3d879 100644 --- a/shared/teams/team/settings-tab/index.tsx +++ b/shared/teams/team/settings-tab/index.tsx @@ -186,8 +186,8 @@ const IgnoreAccessRequests = (props: { } export const Settings = (p: Props) => { - const {savePublicity, isBigTeam, teamID, yourOperations, teamname, showOpenTeamWarning} = p - const {canShowcase, error, allowOpenTrigger} = p + const {error, savePublicity, isBigTeam, teamID, yourOperations, teamname, showOpenTeamWarning} = p + const {canShowcase, allowOpenTrigger} = p const [newPublicityAnyMember, setNewPublicityAnyMember] = React.useState(p.publicityAnyMember) const [newPublicityTeam, setNewPublicityTeam] = React.useState(p.publicityTeam) @@ -338,8 +338,6 @@ const Container = (ownProps: OwnProps) => { const teamDetails = s.teamDetails.get(teamID) ?? Teams.emptyTeamDetails return { _loadWelcomeMessage: s.dispatch.loadWelcomeMessage, - error: s.errorInSettings, - resetErrorInSettings: s.dispatch.resetErrorInSettings, setPublicity: s.dispatch.setPublicity, teamDetails, teamMeta, @@ -348,7 +346,7 @@ const Container = (ownProps: OwnProps) => { } }) ) - const {error, _loadWelcomeMessage, resetErrorInSettings, setPublicity, teamDetails} = teamsState + const {_loadWelcomeMessage, setPublicity, teamDetails} = teamsState const {teamMeta, welcomeMessage, yourOperations} = teamsState const publicityAnyMember = teamMeta.allowPromote const publicityMember = teamMeta.showcasing @@ -361,23 +359,26 @@ const Container = (ownProps: OwnProps) => { const openTeamRole = teamDetails.settings.openJoinAs const teamname = teamMeta.teamname const waitingForWelcomeMessage = C.Waiting.useAnyWaiting(C.waitingKeyTeamsLoadWelcomeMessage(teamID)) - const clearError = resetErrorInSettings + const error = C.Waiting.useAnyErrors([ + C.waitingKeyTeamsLoadWelcomeMessage(teamID), + C.waitingKeyTeamsSetMemberPublicity(teamID), + C.waitingKeyTeamsSetRetentionPolicy(teamID), + ])?.message const loadWelcomeMessage = () => { _loadWelcomeMessage(teamID) } const navigateAppend = C.Router2.navigateAppend const _savePublicity = (settings: T.Teams.PublicitySettings) => { - setPublicity(teamID, settings) - } + setPublicity(teamID, settings) + } const showOpenTeamWarning = (isOpenTeam: boolean, teamname: string) => { - navigateAppend({name: 'openTeamWarning', params: {isOpenTeam, teamname}}) - } + navigateAppend({name: 'openTeamWarning', params: {isOpenTeam, teamname}}) + } const allowOpenTrigger = useSettingsTabState(s => s.allowOpenTrigger) const savePublicity = (settings: T.Teams.PublicitySettings) => { - _savePublicity(settings) - clearError() - } + _savePublicity(settings) + } // reset if incoming props change on us const [key, setKey] = React.useState(0) diff --git a/shared/teams/team/team-info.tsx b/shared/teams/team/team-info.tsx index f7ed9f96a451..fc46bf4c6de0 100644 --- a/shared/teams/team/team-info.tsx +++ b/shared/teams/team/team-info.tsx @@ -2,7 +2,7 @@ import * as C from '@/constants' import * as React from 'react' import * as Teams from '@/stores/teams' import * as Kb from '@/common-adapters' -import type * as T from '@/constants/types' +import * as T from '@/constants/types' type Props = {teamID: T.Teams.TeamID} @@ -19,24 +19,30 @@ const TeamInfo = (props: Props) => { 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 [descError, setDescError] = React.useState('') const saveDisabled = (description === teamDetails?.description && newName === _leafName) || newName.length < 3 const waiting = C.Waiting.useAnyWaiting([C.waitingKeyTeamsTeam(teamID), C.waitingKeyTeamsRename]) + const editTeamDescription = C.useRPC(T.RPCGen.teamsSetTeamShowcaseRpcPromise) const errors = { - desc: Teams.useTeamsState(s => s.errorInEditDescription), + desc: descError, rename: C.Waiting.useAnyErrors(C.waitingKeyTeamsRename)?.message, } - const editTeamDescription = Teams.useTeamsState(s => s.dispatch.editTeamDescription) const renameTeam = Teams.useTeamsState(s => s.dispatch.renameTeam) const onSave = () => { if (newName !== _leafName) { renameTeam(teamname, parentTeamNameWithDot + newName) } if (description !== teamDetails?.description) { - editTeamDescription(teamID, description) + setDescError('') + editTeamDescription( + [{description, teamID}, C.waitingKeyTeamsTeam(teamID)], + () => {}, + error => setDescError(error.message) + ) } } const navigateAppend = C.Router2.navigateAppend diff --git a/shared/util/safe-navigation.tsx b/shared/util/safe-navigation.tsx index 4fa865772be2..cdb510ebdddb 100644 --- a/shared/util/safe-navigation.tsx +++ b/shared/util/safe-navigation.tsx @@ -1,14 +1,24 @@ import * as C from '@/constants' import {useIsFocused} from '@react-navigation/core' -import type {NavigateAppendType} from '@/router-v2/route-params' +import type {NoParamRouteKeys, ParamRouteKeys, RootParamList} from '@/router-v2/route-params' export const useSafeNavigation = () => { const isFocused = useIsFocused() const navigateUp = C.Router2.navigateUp const navigateAppend = C.Router2.navigateAppend + function safeNavigateAppend(path: RouteName, replace?: boolean): void + function safeNavigateAppend( + path: {name: RouteName; params: RootParamList[RouteName]}, + replace?: boolean + ): void + function safeNavigateAppend( + path: NoParamRouteKeys | {name: ParamRouteKeys; params: object | undefined}, + replace?: boolean + ) { + return isFocused && navigateAppend(path as never, replace) + } return { - safeNavigateAppend: (path: NavigateAppendType, replace?: boolean) => - isFocused && navigateAppend(path, replace), + safeNavigateAppend, safeNavigateUp: () => isFocused && navigateUp(), } } diff --git a/shared/wallets/routes.tsx b/shared/wallets/routes.tsx index 2991aa0ac14a..ef9728f16487 100644 --- a/shared/wallets/routes.tsx +++ b/shared/wallets/routes.tsx @@ -1,14 +1,15 @@ import * as React from 'react' import * as C from '@/constants' +import {defineRouteMap} from '@/constants/types/router' -export const newRoutes = { +export const newRoutes = defineRouteMap({ walletsRoot: { getOptions: {title: 'Wallet'}, screen: React.lazy(async () => import('.')), }, -} +}) -export const newModalRoutes = { +export const newModalRoutes = defineRouteMap({ reallyRemoveAccount: C.makeScreen(React.lazy(async () => import('./really-remove-account'))), removeAccount: C.makeScreen(React.lazy(async () => import('./remove-account'))), -} +})