diff --git a/AGENTS.md b/AGENTS.md index fd283654fdb1..8a29cb615d48 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,6 @@ # Repo Notes - This repo uses React Compiler. Assume React Compiler patterns are enabled when editing React code, and avoid adding `useMemo`/`useCallback` by default unless they are clearly needed for correctness or compatibility with existing code. +- Treat React mount/unmount effects as Strict-Mode-safe. Do not assume a component only mounts once; route-driven async startup and cleanup logic must be idempotent and must not leave refs or guards stuck false after a dev remount. - When a component reads multiple adjacent values from the same store hook, prefer a consolidated selector with `C.useShallow(...)` instead of multiple separate subscriptions. - 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. diff --git a/shared/chat/blocking/invitation-to-block.tsx b/shared/chat/blocking/invitation-to-block.tsx index 7b5c9236c956..b0bffe4097dd 100644 --- a/shared/chat/blocking/invitation-to-block.tsx +++ b/shared/chat/blocking/invitation-to-block.tsx @@ -1,8 +1,8 @@ import * as Chat from '@/stores/chat' -import {useProfileState} from '@/stores/profile' import * as Kb from '@/common-adapters' import {useSafeNavigation} from '@/util/safe-navigation' import {useCurrentUserState} from '@/stores/current-user' +import {navToProfile} from '@/constants/router' const BlockButtons = () => { const nav = useSafeNavigation() @@ -16,7 +16,6 @@ const BlockButtons = () => { }) const participantInfo = Chat.useChatContext(s => s.participants) const currentUser = useCurrentUserState(s => s.username) - const showUserProfile = useProfileState(s => s.dispatch.showUserProfile) const dismissBlockButtons = Chat.useChatContext(s => s.dispatch.dismissBlockButtons) if (!blockButtonInfo) { return null @@ -26,7 +25,7 @@ const BlockButtons = () => { person => person !== currentUser && person !== adder && !Chat.isAssertion(person) ) - const onViewProfile = () => showUserProfile(adder) + const onViewProfile = () => navToProfile(adder) const onViewTeam = () => nav.safeNavigateAppend({name: 'team', params: {teamID}}) const onBlock = () => nav.safeNavigateAppend({ diff --git a/shared/chat/conversation/header-area/index.native.tsx b/shared/chat/conversation/header-area/index.native.tsx index c1ab4b8031c1..8b789d79f511 100644 --- a/shared/chat/conversation/header-area/index.native.tsx +++ b/shared/chat/conversation/header-area/index.native.tsx @@ -1,6 +1,5 @@ import * as C from '@/constants' import * as Chat from '@/stores/chat' -import {useProfileState} from '@/stores/profile' import * as Kb from '@/common-adapters' import type {HeaderBackButtonProps} from '@react-navigation/elements' import {HeaderLeftButton} from '@/common-adapters/header-buttons' @@ -10,6 +9,7 @@ import {assertionToDisplay} from '@/common-adapters/usernames' import {useSafeAreaFrame} from 'react-native-safe-area-context' import {useUsersState} from '@/stores/users' import {useCurrentUserState} from '@/stores/current-user' +import {navToProfile} from '@/constants/router' export const HeaderAreaRight = () => { const conversationIDKey = Chat.useChatContext(s => s.id) @@ -221,9 +221,8 @@ const UsernameHeader = () => { return {participants, theirFullname} }) ) - const showUserProfile = useProfileState(s => s.dispatch.showUserProfile) const onShowProfile = (username: string) => { - showUserProfile(username) + navToProfile(username) } const maxWidthStyle = useMaxWidthStyle() diff --git a/shared/chat/conversation/info-panel/members.tsx b/shared/chat/conversation/info-panel/members.tsx index f838a6cd6f93..946dec8a5491 100644 --- a/shared/chat/conversation/info-panel/members.tsx +++ b/shared/chat/conversation/info-panel/members.tsx @@ -1,12 +1,12 @@ import * as C from '@/constants' import * as Chat from '@/stores/chat' -import {useProfileState} from '@/stores/profile' import * as Teams from '@/stores/teams' import * as React from 'react' import * as Kb from '@/common-adapters' import * as T from '@/constants/types' import Participant from './participant' import {useUsersState} from '@/stores/users' +import {navToProfile} from '@/constants/router' type Props = { commonSections: ReadonlyArray
@@ -88,8 +88,7 @@ const MembersTab = (props: Props) => { return l.username.localeCompare(r.username) }) - const showUserProfile = useProfileState(s => s.dispatch.showUserProfile) - const onShowProfile = showUserProfile + const onShowProfile = navToProfile const participantSection: Section = { data: showSpinner diff --git a/shared/chat/conversation/messages/message-popup/exploding-header.tsx b/shared/chat/conversation/messages/message-popup/exploding-header.tsx index e5e5ee74d769..d14c28d6e823 100644 --- a/shared/chat/conversation/messages/message-popup/exploding-header.tsx +++ b/shared/chat/conversation/messages/message-popup/exploding-header.tsx @@ -1,8 +1,8 @@ import * as React from 'react' -import {useProfileState} from '@/stores/profile' import * as Kb from '@/common-adapters' import {formatTimeForPopup, formatTimeForRevoked, msToDHMS} from '@/util/timestamp' import {addTicker, removeTicker} from '@/util/second-timer' +import {navToProfile} from '@/constants/router' type Props = { explodesAt: number @@ -36,9 +36,8 @@ const ExplodingPopupHeader = (props: Props) => { } }, [explodesAt]) - const showUserProfile = useProfileState(s => s.dispatch.showUserProfile) const onUsernameClicked = (user: string) => { - showUserProfile(user) + navToProfile(user) onHidden() } diff --git a/shared/chat/conversation/messages/message-popup/header.tsx b/shared/chat/conversation/messages/message-popup/header.tsx index 96633abe52d1..33c3a8d821e4 100644 --- a/shared/chat/conversation/messages/message-popup/header.tsx +++ b/shared/chat/conversation/messages/message-popup/header.tsx @@ -1,7 +1,7 @@ import * as Kb from '@/common-adapters' -import {useProfileState} from '@/stores/profile' import {formatTimeForPopup, formatTimeForRevoked} from '@/util/timestamp' import type * as T from '@/constants/types' +import {navToProfile} from '@/constants/router' const iconNameForDeviceType = Kb.Styles.isMobile ? (deviceType: string, isRevoked: boolean, isLocation: boolean): Kb.IconType => { @@ -53,9 +53,8 @@ const MessagePopupHeader = (props: Props) => { const iconName = iconNameForDeviceType(deviceType, !!deviceRevokedAt, isLocation) const whoRevoked = yourMessage ? 'You' : author - const showUserProfile = useProfileState(s => s.dispatch.showUserProfile) const onUsernameClicked = (user: string) => { - showUserProfile(user) + navToProfile(user) onHidden() } diff --git a/shared/chat/conversation/messages/message-popup/hooks.tsx b/shared/chat/conversation/messages/message-popup/hooks.tsx index 158ef2fecf8a..57a53030e9da 100644 --- a/shared/chat/conversation/messages/message-popup/hooks.tsx +++ b/shared/chat/conversation/messages/message-popup/hooks.tsx @@ -3,13 +3,13 @@ import * as C from '@/constants' import * as Chat from '@/stores/chat' import * as Teams from '@/stores/teams' import {useConfigState} from '@/stores/config' -import {useProfileState} from '@/stores/profile' import {useCurrentUserState} from '@/stores/current-user' import {linkFromConvAndMessage} from '@/constants/deeplinks' import ReactionItem from './reactionitem' import MessagePopupHeader from './header' import ExplodingPopupHeader from './exploding-header' import {formatTimeForPopup, formatTimeForRevoked} from '@/util/timestamp' +import {navToProfile} from '@/constants/router' const emptyText = Chat.makeMessageText({}) @@ -232,11 +232,10 @@ export const useItems = (ordinal: T.Chat.Ordinal, onHidden: () => void) => { ] as const) : [] - const _showUserProfile = useProfileState(s => s.dispatch.showUserProfile) - const showUserProfile = () => { - _showUserProfile(author) + const onShowProfile = () => { + navToProfile(author) } - const onViewProfile = author && !yourMessage ? showUserProfile : undefined + const onViewProfile = author && !yourMessage ? onShowProfile : undefined const profileSubtitle = `${deviceName} ${deviceRevokedAt ? 'REVOKED at' : '-'} ${ deviceRevokedAt ? `${formatTimeForRevoked(deviceRevokedAt)}` : formatTimeForPopup(timestamp) }` diff --git a/shared/chat/conversation/messages/reset-user.tsx b/shared/chat/conversation/messages/reset-user.tsx index 3e34471286ed..315d02340302 100644 --- a/shared/chat/conversation/messages/reset-user.tsx +++ b/shared/chat/conversation/messages/reset-user.tsx @@ -1,6 +1,6 @@ import * as Chat from '@/stores/chat' -import {useProfileState} from '@/stores/profile' import * as Kb from '@/common-adapters' +import {navToProfile} from '@/constants/router' const ResetUser = () => { const meta = Chat.useChatContext(s => s.meta) @@ -9,8 +9,7 @@ const ResetUser = () => { const resetLetThemIn = Chat.useChatContext(s => s.dispatch.resetLetThemIn) const _participants = participantInfo.all const _resetParticipants = meta.resetParticipants - const showUserProfile = useProfileState(s => s.dispatch.showUserProfile) - const _viewProfile = showUserProfile + const _viewProfile = navToProfile const username = [..._resetParticipants][0] || '' const nonResetUsers = new Set(_participants) _resetParticipants.forEach(r => nonResetUsers.delete(r)) diff --git a/shared/chat/conversation/messages/separator.tsx b/shared/chat/conversation/messages/separator.tsx index 03cc5ff09d90..c41ee092e677 100644 --- a/shared/chat/conversation/messages/separator.tsx +++ b/shared/chat/conversation/messages/separator.tsx @@ -7,7 +7,7 @@ import * as T from '@/constants/types' import {formatTimeForConversationList, formatTimeForChat} from '@/util/timestamp' import {OrangeLineContext} from '../orange-line-context' import {useTrackerState} from '@/stores/tracker' -import {useProfileState} from '@/stores/profile' +import {navToProfile} from '@/constants/router' const missingMessage = Chat.makeMessageDeleted({}) @@ -86,18 +86,17 @@ type AuthorProps = { showUsername: string } -// Separate component so useTeamsState/useProfileState/useTrackerState only +// Separate component so useTeamsState/useTrackerState only // subscribe when there's actually an author to show. function AuthorSection(p: AuthorProps) { const {author, botAlias, isAdhocBot, teamID, teamType, teamname, timestamp, showUsername} = p const authorRoleInTeam = useTeamsState(s => s.teamIDToMembers.get(teamID)?.get(author)?.type) - const showUserProfile = useProfileState(s => s.dispatch.showUserProfile) const showUser = useTrackerState(s => s.dispatch.showUser) const onAuthorClick = () => { if (C.isMobile) { - showUserProfile(showUsername) + navToProfile(showUsername) } else { showUser(showUsername, true) } diff --git a/shared/chat/conversation/rekey/container.tsx b/shared/chat/conversation/rekey/container.tsx index 7340687e5322..682b3b895b8f 100644 --- a/shared/chat/conversation/rekey/container.tsx +++ b/shared/chat/conversation/rekey/container.tsx @@ -1,10 +1,10 @@ import * as C from '@/constants' import * as Chat from '@/stores/chat' -import {useProfileState} from '@/stores/profile' import {useCurrentUserState} from '@/stores/current-user' import * as T from '@/constants/types' import ParticipantRekey from './participant-rekey' import YouRekey from './you-rekey' +import {navToProfile} from '@/constants/router' const Container = () => { const _you = useCurrentUserState(s => s.username) @@ -23,7 +23,7 @@ const Container = () => { ) } - const onShowProfile = useProfileState(s => s.dispatch.showUserProfile) + const onShowProfile = navToProfile return rekeyers.has(_you) ? ( diff --git a/shared/common-adapters/mention-container.tsx b/shared/common-adapters/mention-container.tsx index 3ee0e41e5709..ba3fdc19b0de 100644 --- a/shared/common-adapters/mention-container.tsx +++ b/shared/common-adapters/mention-container.tsx @@ -2,9 +2,9 @@ import * as C from '@/constants' import * as Chat from '@/stores/chat' import Mention, {type OwnProps} from './mention' import {useTrackerState} from '@/stores/tracker' -import {useProfileState} from '@/stores/profile' import {useFollowerState} from '@/stores/followers' import {useCurrentUserState} from '@/stores/current-user' +import {navToProfile} from '@/constants/router' const Container = (ownProps: OwnProps) => { let {username} = ownProps @@ -24,11 +24,10 @@ const Container = (ownProps: OwnProps) => { } })() - const showUserProfile = useProfileState(s => s.dispatch.showUserProfile) const showUser = useTrackerState(s => s.dispatch.showUser) const _onClick = () => { if (C.isMobile) { - showUserProfile(username) + navToProfile(username) } else { showUser(username, true) } diff --git a/shared/common-adapters/name-with-icon.tsx b/shared/common-adapters/name-with-icon.tsx index 45b61ae81cf4..7566a6c99924 100644 --- a/shared/common-adapters/name-with-icon.tsx +++ b/shared/common-adapters/name-with-icon.tsx @@ -13,8 +13,8 @@ import Text from './text' import type {TextType, StylesTextCrossPlatform, AllowedColors, TextTypeBold} from './text.shared' import ConnectedUsernames from './usernames' import {useTrackerState} from '@/stores/tracker' -import {useProfileState} from '@/stores/profile' import {useFollowerState} from '@/stores/followers' +import {navToProfile} from '@/constants/router' type AvatarSize = 128 | 96 | 64 | 48 | 32 | 24 | 16 @@ -430,9 +430,8 @@ const ConnectedNameWithIcon = (p: OwnProps) => { const onOpenTracker = () => { username && showUser(username, true) } - const showUserProfile = useProfileState(s => s.dispatch.showUserProfile) const onOpenUserProfile = () => { - username && showUserProfile(username) + username && navToProfile(username) } let functionOnClick: NameWithIconProps['onClick'] diff --git a/shared/common-adapters/profile-card.tsx b/shared/common-adapters/profile-card.tsx index 82458f3b6cf1..559ff605e32e 100644 --- a/shared/common-adapters/profile-card.tsx +++ b/shared/common-adapters/profile-card.tsx @@ -12,7 +12,6 @@ import {_setWithProfileCardPopup} from './usernames' import FloatingMenu from './floating-menu' import Icon from './icon' import Meta from './meta' -import {useProfileState} from '@/stores/profile' import {useFollowerState} from '@/stores/followers' import {useCurrentUserState} from '@/stores/current-user' import ProgressIndicator from './progress-indicator' @@ -23,6 +22,7 @@ import {type default as FollowButtonType} from '../profile/user/actions/follow-b import type ChatButtonType from '../chat/chat-button' import {useTrackerState} from '@/stores/tracker' import type {MeasureRef} from './measure-ref' +import {navToProfile} from '@/constants/router' const positionFallbacks = ['top center', 'bottom center'] as const @@ -178,9 +178,8 @@ const ProfileCard = ({ const changeFollow = useTrackerState(s => s.dispatch.changeFollow) const _changeFollow = (follow: boolean) => changeFollow(userDetails.guiID, follow) - const showUserProfile = useProfileState(s => s.dispatch.showUserProfile) const openProfile = () => { - showUserProfile(username) + navToProfile(username) onHide?.() } diff --git a/shared/common-adapters/proof-broken-banner.tsx b/shared/common-adapters/proof-broken-banner.tsx index f85b34efd6e5..8f94cdae7358 100644 --- a/shared/common-adapters/proof-broken-banner.tsx +++ b/shared/common-adapters/proof-broken-banner.tsx @@ -1,18 +1,17 @@ import * as C from '@/constants' import {Banner, BannerParagraph} from './banner' import {useTrackerState} from '@/stores/tracker' -import {useProfileState} from '@/stores/profile' +import {navToProfile} from '@/constants/router' const Kb = {Banner} type Props = {users?: Array} type ProofBrokenBannerNonEmptyProps = {users: Array} const ProofBrokenBannerNonEmpty = (props: ProofBrokenBannerNonEmptyProps) => { - const showUserProfile = useProfileState(s => s.dispatch.showUserProfile) const showUser = useTrackerState(s => s.dispatch.showUser) const onClickUsername = (username: string) => { if (C.isMobile) { - showUserProfile(username) + navToProfile(username) } else { showUser(username, true) } diff --git a/shared/common-adapters/usernames.tsx b/shared/common-adapters/usernames.tsx index 7467460fe405..2ed26104db17 100644 --- a/shared/common-adapters/usernames.tsx +++ b/shared/common-adapters/usernames.tsx @@ -6,9 +6,9 @@ import type {TextType, Background, StylesTextCrossPlatform, AllowedColors, LineC import type {e164ToDisplay as e164ToDisplayType} from '@/util/phone-numbers' import {useTrackerState} from '@/stores/tracker' import {useUsersState} from '@/stores/users' -import {useProfileState} from '@/stores/profile' import {useFollowerState} from '@/stores/followers' import {useCurrentUserState} from '@/stores/current-user' +import {navToProfile} from '@/constants/router' export type User = { username: string @@ -99,7 +99,7 @@ function Username(p: UsernameProps) { } else if (onUsernameClicked === 'profile') { onClicked = (evt?: React.BaseSyntheticEvent) => { evt?.stopPropagation() - useProfileState.getState().dispatch.showUserProfile(username) + navToProfile(username) } } else if (typeof onUsernameClicked === 'function') { onClicked = () => onUsernameClicked(username) diff --git a/shared/constants/deeplinks.tsx b/shared/constants/deeplinks.tsx index 79a383ada879..666183c19a68 100644 --- a/shared/constants/deeplinks.tsx +++ b/shared/constants/deeplinks.tsx @@ -1,9 +1,8 @@ import logger from '@/logger' import * as T from '@/constants/types' -import {navigateAppend, navToThread, switchTab} from './router' +import {navigateAppend, navToProfile, navToThread, switchTab} from './router' import * as Tabs from './tabs' import {useChatState} from '@/stores/chat' -import {useProfileState} from '@/stores/profile' import {useTeamsState} from '@/stores/teams' const prefix = 'keybase://' @@ -69,12 +68,12 @@ const handleKeybaseLink = (link: string) => { case 'profile': if (parts[1] === 'show' && parts[2]) { switchTab(Tabs.peopleTab) - useProfileState.getState().dispatch.showUserProfile(parts[2]) + navToProfile(parts[2]) return } if (parts[1] === 'new-proof' && (parts.length === 3 || parts.length === 4)) { - parts.length === 4 && parts[3] && useProfileState.getState().dispatch.showUserProfile(parts[3]) - useProfileState.getState().dispatch.addProof(parts[2]!, 'appLink') + parts.length === 4 && parts[3] && navToProfile(parts[3]) + navigateAppend({name: 'profileProofsList', params: {platform: parts[2]!, reason: 'appLink'}}) return } break diff --git a/shared/constants/init/index.desktop.tsx b/shared/constants/init/index.desktop.tsx index 2b8e4f929246..02aed5d0d896 100644 --- a/shared/constants/init/index.desktop.tsx +++ b/shared/constants/init/index.desktop.tsx @@ -5,8 +5,6 @@ import {useConfigState} from '@/stores/config' import * as ConfigConstants from '@/stores/config' import {useDaemonState} from '@/stores/daemon' import {useFSState} from '@/stores/fs' -import {useProfileState} from '@/stores/profile' -import {useRouterState} from '@/stores/router' import type * as EngineGen from '@/constants/rpc' import * as T from '@/constants/types' import InputMonitor from '@/util/platform-specific/input-monitor.desktop' @@ -410,14 +408,6 @@ export const initPlatformListener = () => { } })) - useProfileState.setState(s => { - s.dispatch.editAvatar = () => { - useRouterState - .getState() - .dispatch.navigateAppend({name: 'profileEditAvatar', params: {image: undefined}}) - } - }) - useDaemonState.setState(s => { s.dispatch.onRestartHandshakeNative = () => { const {handshakeFailedReason} = useDaemonState.getState() diff --git a/shared/constants/init/index.native.tsx b/shared/constants/init/index.native.tsx index 14a145c5aee8..4a172dd7468a 100644 --- a/shared/constants/init/index.native.tsx +++ b/shared/constants/init/index.native.tsx @@ -6,7 +6,6 @@ import {useCurrentUserState} from '@/stores/current-user' import {useDaemonState} from '@/stores/daemon' import {useDarkModeState} from '@/stores/darkmode' import {useFSState} from '@/stores/fs' -import {useProfileState} from '@/stores/profile' import {useRouterState} from '@/stores/router' import {useSettingsContactsState} from '@/stores/settings-contacts' import * as T from '@/constants/types' @@ -35,7 +34,6 @@ import { } from 'react-native-kb' import {initPushListener, getStartupDetailsFromInitialPush} from './push-listener.native' import {initSharedSubscriptions, _onEngineIncoming} from './shared' -import type {ImageInfo} from '@/util/expo-image-picker.native' import {noConversationIDKey} from '../types/chat/common' import {getSelectedConversation} from '../chat/common' import {getConvoState} from '@/stores/convostate' @@ -398,30 +396,6 @@ export const initPlatformListener = () => { }) }) - useProfileState.setState(s => { - s.dispatch.editAvatar = () => { - const f = async () => { - try { - const result = await launchImageLibraryAsync('photo') - const first = result.assets?.reduce((acc, a) => { - if (!acc && (a.type === 'image' || a.type === 'video')) { - return a as ImageInfo - } - return acc - }, undefined) - if (!result.canceled && first) { - useRouterState - .getState() - .dispatch.navigateAppend({name: 'profileEditAvatar', params: {image: first}}) - } - } catch (error) { - useConfigState.getState().dispatch.filePickerError(new Error(String(error))) - } - } - ignorePromise(f()) - } - }) - useConfigState.subscribe((s, old) => { if (s.loggedIn === old.loggedIn) return const f = async () => { @@ -552,9 +526,9 @@ export const initPlatformListener = () => { try { const result = await launchImageLibraryAsync(type, true, true) if (result.canceled) return - result.assets.map(r => - useFSState.getState().dispatch.upload(parentPath, Styles.unnormalizePath(r.uri)) - ) + for (const asset of result.assets) { + useFSState.getState().dispatch.upload(parentPath, Styles.unnormalizePath(asset.uri)) + } } catch (e) { errorToActionOrThrow(e) } diff --git a/shared/constants/init/shared.tsx b/shared/constants/init/shared.tsx index 59861c130905..b58948c28048 100644 --- a/shared/constants/init/shared.tsx +++ b/shared/constants/init/shared.tsx @@ -3,6 +3,7 @@ import * as T from '../types' import isEqual from 'lodash/isEqual' import logger from '@/logger' import * as Tabs from '@/constants/tabs' +import {navToProfile} from '@/constants/router' declare global { var __hmr_startupOnce: boolean | undefined @@ -50,7 +51,6 @@ import {useFSState} from '@/stores/fs' import {useFollowerState} from '@/stores/followers' import {useModalHeaderState} from '@/stores/modal-header' import {useNotifState} from '@/stores/notifications' -import {useProfileState} from '@/stores/profile' import {useProvisionState} from '@/stores/provision' import {usePushState} from '@/stores/push' import {useSettingsContactsState} from '@/stores/settings-contacts' @@ -138,7 +138,7 @@ export const initTeamBuildingCallbacks = () => { return useSettingsContactsState.getState().userCountryCode }, onShowUserProfile: (username: string) => { - useProfileState.getState().dispatch.showUserProfile(username) + navToProfile(username) }, onUsersGetBlockState: (usernames: ReadonlyArray) => { useUsersState.getState().dispatch.getBlockState(usernames) @@ -294,32 +294,6 @@ export const initNotificationsCallbacks = () => { }) } -export const initProfileCallbacks = () => { - const currentState = useProfileState.getState() - useProfileState.setState({ - dispatch: { - ...currentState.dispatch, - defer: { - ...currentState.dispatch.defer, - onTracker2GetDetails: (username: string) => { - return useTrackerState.getState().getDetails(username) - }, - onTracker2Load: ( - params: Parameters['dispatch']['load']>[0] - ) => { - useTrackerState.getState().dispatch.load(params) - }, - onTracker2ShowUser: (username: string, asTracker: boolean, skipNav?: boolean) => { - useTrackerState.getState().dispatch.showUser(username, asTracker, skipNav) - }, - onTracker2UpdateResult: (guiID: string, result: T.Tracker.DetailsState, reason?: string) => { - useTrackerState.getState().dispatch.updateResult(guiID, result, reason) - }, - }, - }, - }) -} - export const initPushCallbacks = () => { const currentState = usePushState.getState() usePushState.setState({ @@ -379,7 +353,7 @@ export const initTracker2Callbacks = () => { defer: { ...currentState.dispatch.defer, onShowUserProfile: (username: string) => { - useProfileState.getState().dispatch.showUserProfile(username) + navToProfile(username) }, onUsersUpdates: (updates: ReadonlyArray<{name: string; info: Partial}>) => { useUsersState.getState().dispatch.updates(updates) @@ -704,7 +678,6 @@ export const initSharedSubscriptions = () => { initTeamsCallbacks() initFSCallbacks() initNotificationsCallbacks() - initProfileCallbacks() initPushCallbacks() initRecoverPasswordCallbacks() initSettingsCallbacks() diff --git a/shared/fs/banner/reset-banner.tsx b/shared/fs/banner/reset-banner.tsx index a488c0c58b57..f0939d5dcfb7 100644 --- a/shared/fs/banner/reset-banner.tsx +++ b/shared/fs/banner/reset-banner.tsx @@ -6,7 +6,7 @@ import * as RowTypes from '@/fs/browser/rows/types' import {useTrackerState} from '@/stores/tracker' import {useFSState} from '@/stores/fs' import * as FS from '@/stores/fs' -import {useProfileState} from '@/stores/profile' +import {navToProfile} from '@/constants/router' type OwnProps = {path: T.FS.Path} @@ -24,11 +24,10 @@ const ConnectedBanner = (ownProps: OwnProps) => { const _onReAddToTeam = (id: T.RPCGen.TeamID, username: string) => { letResetUserBackIn(id, username) } - const showUserProfile = useProfileState(s => s.dispatch.showUserProfile) const showUser = useTrackerState(s => s.dispatch.showUser) const onViewProfile = (username: string) => () => { - C.isMobile ? showUserProfile(username) : showUser(username, true) + C.isMobile ? navToProfile(username) : showUser(username, true) } const onOpenWithoutResetUsers = () => _onOpenWithoutResetUsers( diff --git a/shared/people/container.tsx b/shared/people/container.tsx index 1861d50b75c4..e56f23293068 100644 --- a/shared/people/container.tsx +++ b/shared/people/container.tsx @@ -12,10 +12,10 @@ import People from '.' import * as T from '@/constants/types' import {useFollowerState} from '@/stores/followers' import {useSignupState} from '@/stores/signup' -import {useProfileState} from '@/stores/profile' import {usePeopleState} from '@/stores/people' import {useCurrentUserState} from '@/stores/current-user' import type {e164ToDisplay as e164ToDisplayType} from '@/util/phone-numbers' +import {navToProfile} from '@/constants/router' const getPeopleDataWaitingKey = 'getPeopleData' const waitToRefresh = 1000 * 60 * 5 @@ -426,9 +426,7 @@ const PeopleReloadable = () => { } }, []) - const showUserProfile = useProfileState(s => s.dispatch.showUserProfile) - - const onClickUser = (username: string) => showUserProfile(username) + const onClickUser = (username: string) => navToProfile(username) const onReload = (isRetry?: boolean) => getData(false, isRetry === true || !followSuggestions.length) diff --git a/shared/people/todo.tsx b/shared/people/todo.tsx index 822416a819c0..8574a300d96d 100644 --- a/shared/people/todo.tsx +++ b/shared/people/todo.tsx @@ -1,7 +1,7 @@ import * as C from '@/constants' import {useTeamsState} from '@/stores/teams' import * as React from 'react' -import {openURL} from '@/util/misc' +import {editAvatar, openURL} from '@/util/misc' import type * as T from '@/constants/types' import type {IconType} from '@/common-adapters/icon.constants-gen' import PeopleItem, {type TaskButton} from './item' @@ -9,8 +9,8 @@ import * as Kb from '@/common-adapters' import {useSettingsEmailState} from '@/stores/settings-email' import {settingsAccountTab, settingsGitTab} from '@/constants/settings' import {useTrackerState} from '@/stores/tracker' -import {useProfileState} from '@/stores/profile' import {useCurrentUserState} from '@/stores/current-user' +import {navToProfile} from '@/constants/router' const todoTypes: {[K in T.People.TodoType]: T.People.TodoType} = { addEmail: 'addEmail', @@ -114,7 +114,6 @@ const AvatarTeamConnector = (props: TodoOwnProps) => { } const AvatarUserConnector = (props: TodoOwnProps) => { - const editAvatar = useProfileState(s => s.dispatch.editAvatar) const onConfirm = editAvatar const buttons = makeDefaultButtons(onConfirm, props.confirmLabel) return @@ -133,8 +132,7 @@ const BioConnector = (props: TodoOwnProps) => { const ProofConnector = (props: TodoOwnProps) => { const myUsername = useCurrentUserState(s => s.username) - const showUserProfile = useProfileState(s => s.dispatch.showUserProfile) - const onConfirm = showUserProfile + const onConfirm = navToProfile const onDismiss = useOnSkipTodo(props.skipTodo, 'proof') const buttons = makeDefaultButtons(() => onConfirm(myUsername), props.confirmLabel, onDismiss) return diff --git a/shared/profile/confirm-or-pending.tsx b/shared/profile/confirm-or-pending.tsx deleted file mode 100644 index f33f9325224e..000000000000 --- a/shared/profile/confirm-or-pending.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import {useProfileState} from '@/stores/profile' -import * as T from '@/constants/types' -import * as Kb from '@/common-adapters' -import {subtitle} from '@/util/platforms' -import Modal from './modal' - -const ConfirmOrPending = () => { - const proofFound = useProfileState(s => s.proofFound) - const proofStatus = useProfileState(s => s.proofStatus) - const platform = useProfileState(s => s.platform) - const username = useProfileState(s => s.username) - const backToProfile = useProfileState(s => s.dispatch.backToProfile) - - const isGood = proofFound && proofStatus === T.RPCGen.ProofStatus.ok - const isPending = - !isGood && !proofFound && !!proofStatus && proofStatus <= T.RPCGen.ProofStatus.baseHardError - - if (!platform) { - throw new Error('No platform passed to confirm or pending container') - } - - const platformIconOverlayColor = isGood ? Kb.Styles.globalColors.green : Kb.Styles.globalColors.greyDark - const onCancel = backToProfile - - const message = - messageMap.get(platform) || - (isPending - ? 'Some proofs can take a few hours to recognize. Check back later.' - : 'Leave your proof up so other users can identify you!') - const platformIconOverlay = isPending ? 'icon-proof-pending' : 'icon-proof-success' - const platformSubtitle = subtitle(platform) - const title = isPending ? 'Your proof is pending.' : 'Verified!' - - return ( - - - - {title} - - - <> - - {username} - - {platformSubtitle && ( - - {platformSubtitle} - - )} - - <> - - {message} - - {platform === 'http' && ( - - Note: {username} doesn't load over https. If you get a real SSL certificate (not - self-signed) in the future, please replace this proof with a fresh one. - - )} - - - - - ) -} - -const styles = Kb.Styles.styleSheetCreate(() => ({ - blue: Kb.Styles.platformStyles({ - common: {color: Kb.Styles.globalColors.blueDark}, - isElectron: {wordBreak: 'break-all'}, - }), - center: {alignSelf: 'center'}, - grey: {color: Kb.Styles.globalColors.black_20}, -})) - -const messageMap = new Map([ - ['btc', 'Your Bitcoin address has now been signed onto your profile.'], - ['dns', 'DNS proofs can take a few hours to recognize. Check back later.'], - [ - 'hackernews', - 'Hacker News caches its bios, so it might be a few hours before you can verify your proof. Check back later.', - ], - ['zcash', 'Your Zcash address has now been signed onto your profile.'], -]) - -export default ConfirmOrPending diff --git a/shared/profile/edit-avatar/hooks.tsx b/shared/profile/edit-avatar/hooks.tsx index 9e5778f69af5..b817e4a88c9c 100644 --- a/shared/profile/edit-avatar/hooks.tsx +++ b/shared/profile/edit-avatar/hooks.tsx @@ -1,11 +1,11 @@ import * as React from 'react' import * as C from '@/constants' -import {useProfileState} from '@/stores/profile' import * as Teams from '@/stores/teams' import * as Kb from '@/common-adapters' import * as T from '@/constants/types' import type {Props} from '.' import type {ImageInfo} from '@/util/expo-image-picker.native' +import {fixCrop} from '@/util/crop' type TeamProps = { createdTeam?: boolean @@ -77,11 +77,11 @@ export default (ownProps: Props): Ret => { uploadTeamAvatar(teamname, filename, sendChatNotification, crop) } - const uploadAvatar = useProfileState(s => s.dispatch.uploadAvatar) + const uploadAvatar = C.useRPC(T.RPCGen.userUploadUserAvatarRpcPromise) const onSaveUserAvatar = (_filename: string, crop?: T.RPCGen.ImageCropRect) => { const filename = Kb.Styles.unnormalizePath(_filename) - uploadAvatar(filename, crop) + uploadAvatar([{crop: fixCrop(crop), filename}, C.waitingKeyProfileUploadAvatar], () => navigateUp(), () => {}) } const setTeamWizardAvatar = Teams.useTeamsState(s => s.dispatch.setTeamWizardAvatar) const onSaveWizardAvatar = (_filename: string, crop?: T.Teams.AvatarCrop) => { diff --git a/shared/profile/edit-profile.tsx b/shared/profile/edit-profile.tsx index f0decc39167a..fc767ff823cc 100644 --- a/shared/profile/edit-profile.tsx +++ b/shared/profile/edit-profile.tsx @@ -2,22 +2,29 @@ import * as C from '@/constants' import * as Kb from '@/common-adapters' import * as React from 'react' import {useTrackerState} from '@/stores/tracker' -import {useProfileState} from '@/stores/profile' import {useCurrentUserState} from '@/stores/current-user' +import {generateGUIID} from '@/constants/utils' +import * as T from '@/constants/types' const Container = () => { const username = useCurrentUserState(s => s.username) const d = useTrackerState(s => s.getDetails(username)) + const loadProfile = useTrackerState(s => s.dispatch.load) const _bio = d.bio || '' const _fullname = d.fullname || '' const _location = d.location || '' const navigateUp = C.useRouterState(s => s.dispatch.navigateUp) - - const editProfile = useProfileState(s => s.dispatch.editProfile) + const editProfile = C.useRPC(T.RPCGen.userProfileEditRpcPromise) const onSubmit = (bio: string, fullname: string, location: string) => { - editProfile(bio, fullname, location) - navigateUp() + editProfile( + [{bio, fullName: fullname, location}, C.waitingKeyTracker], + () => { + loadProfile({assertion: username, guiID: generateGUIID(), inTracker: false, reason: ''}) + navigateUp() + }, + () => {} + ) } const [bio, setBio] = React.useState(_bio) diff --git a/shared/profile/generic/enter-username.tsx b/shared/profile/generic/enter-username.tsx deleted file mode 100644 index a5e585332046..000000000000 --- a/shared/profile/generic/enter-username.tsx +++ /dev/null @@ -1,296 +0,0 @@ -import * as C from '@/constants' -import {useProfileState} from '@/stores/profile' -import {openURL} from '@/util/misc' -import * as React from 'react' -import * as Kb from '@/common-adapters' -import {SiteIcon} from './shared' -import type * as T from '@/constants/types' - -const ConnectedEnterUsername = () => { - const {platformGenericChecking, platformGenericParams, platformGenericURL, username} = useProfileState( - C.useShallow(s => { - const {platformGenericChecking, platformGenericParams, platformGenericURL, username} = s - return {platformGenericChecking, platformGenericParams, platformGenericURL, username} - }) - ) - const errorText = useProfileState(s => s.errorText) - const _platformURL = platformGenericURL - const error = errorText - const serviceIcon = platformGenericParams?.logoBlack ?? [] - const serviceIconFull = platformGenericParams?.logoFull ?? [] - const serviceName = platformGenericParams?.title ?? '' - const serviceSub = platformGenericParams?.subtext ?? '' - const serviceSuffix = platformGenericParams?.suffix ?? '' - const submitButtonLabel = platformGenericParams?.buttonLabel ?? 'Submit' - const unreachable = !!platformGenericURL - const waiting = platformGenericChecking - - const cancelAddProof = useProfileState(s => s.dispatch.dynamic.cancelAddProof) - const updateUsername = useProfileState(s => s.dispatch.updateUsername) - const submitUsername = useProfileState(s => s.dispatch.dynamic.submitUsername) - - const clearModals = C.useRouterState(s => s.dispatch.clearModals) - const onBack = () => { - cancelAddProof?.() - clearModals() - } - const onChangeUsername = updateUsername - const navigateAppend = C.useRouterState(s => s.dispatch.navigateAppend) - const _onSubmit = () => submitUsername?.() - const onSubmit = _platformURL ? () => _platformURL && openURL(_platformURL) : _onSubmit - - const [waitingButtonKey, setWaitingButtonKey] = React.useState(0) - const wasWaiting = React.useRef(false) - - React.useEffect(() => { - if (waiting) { - wasWaiting.current = true - } else if (wasWaiting.current) { - wasWaiting.current = false - navigateAppend('profileGenericProofResult') - } - if (error) { - setWaitingButtonKey(s => s + 1) - } - }, [waiting, error, navigateAppend]) - - return ( - <> - - {!unreachable && !Kb.Styles.isMobile && } - - - - - - - {serviceName} - - {serviceSub} - - - - - {unreachable ? ( - - ) : ( - - )} - {!!error && {error}} - - - {unreachable && ( - - You need to authorize your proof on {serviceName}. - - )} - - {!Kb.Styles.isMobile && !unreachable && ( - - )} - {unreachable ? ( - - ) : ( - - )} - - - - - ) -} - -type InputProps = { - error: boolean - onChangeUsername: (arg0: string) => void - onEnterKeyDown: () => void - serviceIcon: T.Tracker.SiteIconSet - serviceSuffix: string - username: string -} - -const EnterUsernameInput = (props: InputProps) => { - const [username, setUsername] = React.useState(props.username) - const {onChangeUsername: _onChangeUsername} = props - - const onChangeUsername = (username: string) => { - _onChangeUsername(username) - setUsername(username) - } - - const usernamePlaceholder = props.serviceSuffix === '@theqrl.org' ? 'Your QRL address' : 'Your username' - return ( - - - - {props.serviceSuffix} - - } - /> - - ) -} - -const Unreachable = (props: { - serviceIcon: T.Tracker.SiteIconSet - serviceSuffix: string - username: string -}) => ( - - - - - - {props.username} - - {props.serviceSuffix} - - - - - - - -) - -const styles = Kb.Styles.styleSheetCreate( - () => - ({ - backButton: { - left: Kb.Styles.globalMargins.small, - position: 'absolute', - top: Kb.Styles.globalMargins.small, - }, - buttonBar: { - ...Kb.Styles.padding( - Kb.Styles.globalMargins.small, - Kb.Styles.globalMargins.medium, - Kb.Styles.globalMargins.medium - ), - }, - buttonBarWarning: {backgroundColor: Kb.Styles.globalColors.yellow}, - buttonBig: {flex: 2.5}, - buttonSmall: {flex: 1}, - colorRed: {color: Kb.Styles.globalColors.redDark}, - container: {}, - - inlineIcon: { - position: 'relative', - top: 1, - }, - inputBox: { - ...Kb.Styles.padding(Kb.Styles.globalMargins.xsmall), - borderColor: Kb.Styles.globalColors.black_10, - borderRadius: Kb.Styles.borderRadius, - borderStyle: 'solid', - borderWidth: 1, - padding: Kb.Styles.globalMargins.xsmall, - }, - inputContainer: { - ...Kb.Styles.padding( - 0, - Kb.Styles.isMobile ? Kb.Styles.globalMargins.small : Kb.Styles.globalMargins.medium - ), - }, - marginLeftAuto: {marginLeft: 'auto'}, - opacity40: {opacity: 0.4}, - opacity75: {opacity: 0.75}, - placeholderService: {color: Kb.Styles.globalColors.black_20}, - serviceIconFull: { - height: 64, - width: 64, - }, - serviceIconHeaderContainer: {paddingTop: Kb.Styles.globalMargins.medium}, - serviceMeta: Kb.Styles.platformStyles({ - isElectron: { - paddingLeft: Kb.Styles.globalMargins.medium, - paddingRight: Kb.Styles.globalMargins.medium, - }, - isMobile: { - paddingLeft: Kb.Styles.globalMargins.small, - paddingRight: Kb.Styles.globalMargins.small, - }, - }), - serviceProofIcon: { - bottom: -Kb.Styles.globalMargins.tiny, - position: 'absolute', - right: -Kb.Styles.globalMargins.tiny, - }, - unreachableBox: Kb.Styles.platformStyles({ - common: {...Kb.Styles.padding(Kb.Styles.globalMargins.tiny, Kb.Styles.globalMargins.xsmall)}, - isElectron: {width: 360}, - }), - unreachablePlaceholder: Kb.Styles.platformStyles({ - common: {color: Kb.Styles.globalColors.black_35}, - isElectron: {wordBreak: 'break-all'}, - }), - warningText: {color: Kb.Styles.globalColors.brown_75, marginTop: Kb.Styles.globalMargins.small}, - }) as const -) - -export default ConnectedEnterUsername diff --git a/shared/profile/generic/proofs-list.tsx b/shared/profile/generic/proofs-list.tsx index a1ae3260ec5b..705f0a667594 100644 --- a/shared/profile/generic/proofs-list.tsx +++ b/shared/profile/generic/proofs-list.tsx @@ -1,149 +1,1262 @@ import * as C from '@/constants' import * as Kb from '@/common-adapters' import * as React from 'react' -import type * as T from '@/constants/types' -import {SiteIcon} from './shared' +import * as T from '@/constants/types' import {makeInsertMatcher} from '@/util/string' import {useColorScheme} from 'react-native' import {useTrackerState} from '@/stores/tracker' -import {useProfileState} from '@/stores/profile' +import Modal from '../modal' +import {SiteIcon} from './shared' +import {normalizeProofUsername} from '../proof-utils' +import {useConfigState} from '@/stores/config' +import {openURL as openUrl} from '@/util/misc' +import {subtitle} from '@/util/platforms' +import {useCurrentUserState} from '@/stores/current-user' +import {generateGUIID, ignorePromise} from '@/constants/utils' +import {RPCError} from '@/util/errors' +import logger from '@/logger' +import {navToProfile} from '@/constants/router' -const Container = () => { - const _proofSuggestions = useTrackerState(s => s.proofSuggestions) - const navigateUp = C.useRouterState(s => s.dispatch.navigateUp) - const onCancel = () => { - navigateUp() - } - const addProof = useProfileState(s => s.dispatch.addProof) - const providerClicked = (key: string) => { - addProof(key, 'profile') - } +type ProveGenericParams = { + buttonLabel: string + logoBlack: T.Tracker.SiteIconSet + logoFull: T.Tracker.SiteIconSet + subtext: string + suffix: string + title: string +} +const makeProveGenericParams = (): ProveGenericParams => ({ + buttonLabel: '', + logoBlack: [], + logoFull: [], + subtext: '', + suffix: '', + title: '', +}) + +const toProveGenericParams = (p: T.RPCGen.ProveParameters): ProveGenericParams => ({ + ...makeProveGenericParams(), + buttonLabel: p.buttonLabel, + logoBlack: p.logoBlack || [], + logoFull: p.logoFull || [], + subtext: p.subtext, + suffix: p.suffix, + title: p.title, +}) + +type Props = { + platform?: string + reason?: 'appLink' | 'profile' +} + +type Provider = { + desc: string + icon: T.Tracker.SiteIconSet + key: string + name: string + new: boolean +} + +type PickStep = {kind: 'pick'} +type LoadingStep = {kind: 'loading'} +type WebsiteChoiceStep = {kind: 'websiteChoice'} +type EnterUsernameStep = { + error: string + kind: 'enterUsername' + platform: T.More.PlatformsExpandedType + username: string +} +type GenericEnterUsernameStep = { + error: string + genericParams: ProveGenericParams + kind: 'genericEnterUsername' + proofUrl?: string + service: string + username: string +} +type GenericResultStep = { + error: string + genericParams: ProveGenericParams + kind: 'genericResult' + username: string +} +type PostProofStep = { + error: string + kind: 'postProof' + platform: T.More.PlatformsExpandedType + proofText: string + sigID?: T.RPCGen.SigID + username: string +} +type ConfirmOrPendingStep = { + kind: 'confirmOrPending' + platform: T.More.PlatformsExpandedType + proofFound: boolean + proofStatus?: T.RPCGen.ProofStatus + username: string +} +type Step = + | PickStep + | LoadingStep + | WebsiteChoiceStep + | EnterUsernameStep + | GenericEnterUsernameStep + | GenericResultStep + | PostProofStep + | ConfirmOrPendingStep + +const Container = ({platform, reason = 'profile'}: Props) => { + const currentUsername = useCurrentUserState(s => s.username) + const loadProfile = useTrackerState(s => s.dispatch.load) + const proofSuggestions = useTrackerState(s => s.proofSuggestions) + const copyToClipboard = useConfigState(s => s.dispatch.defer.copyToClipboard) + const registerCryptoAddress = C.useRPC(T.RPCGen.cryptocurrencyRegisterAddressRpcPromise) const isDarkMode = useColorScheme() === 'dark' - const providers = _proofSuggestions.map(s => ({ + const {clearModals, navigateAppend, navigateUp} = C.useRouterState( + C.useShallow(s => ({ + clearModals: s.dispatch.clearModals, + navigateAppend: s.dispatch.navigateAppend, + navigateUp: s.dispatch.navigateUp, + })) + ) + + const providers = proofSuggestions.map(s => ({ desc: s.pickerSubtext, icon: isDarkMode ? s.siteIconFullDarkmode : s.siteIconFull, key: s.assertionKey, name: s.pickerText, new: s.metas.some(({label}) => label === 'new'), })) - const title = 'Prove your...' + + const mountedRef = React.useRef(true) + const initialProofStartedRef = React.useRef(false) + const initialRouteRef = React.useRef({platform, reason}) + const currentUsernameRef = React.useRef('') + const currentGenericParamsRef = React.useRef(makeProveGenericParams()) + const afterCheckProofRef = React.useRef void)>(undefined) + const cancelCurrentRef = React.useRef void)>(undefined) + const submitUsernameRef = React.useRef void)>(undefined) + const startProofRef = React.useRef<(proofPlatform: string, proofReason: 'appLink' | 'profile') => void>(() => {}) + const [step, setStep] = React.useState(platform ? {kind: 'loading'} : {kind: 'pick'}) + + const setStepSafe = (next: Step) => { + if (mountedRef.current) { + setStep(next) + } + } + + const resetSession = () => { + afterCheckProofRef.current = undefined + cancelCurrentRef.current = undefined + submitUsernameRef.current = undefined + currentGenericParamsRef.current = makeProveGenericParams() + currentUsernameRef.current = '' + } + + const cancelSession = () => { + const cancel = cancelCurrentRef.current + resetSession() + cancel?.() + } + + React.useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + const cancel = cancelCurrentRef.current + afterCheckProofRef.current = undefined + cancelCurrentRef.current = undefined + submitUsernameRef.current = undefined + currentGenericParamsRef.current = makeProveGenericParams() + currentUsernameRef.current = '' + cancel?.() + } + }, []) + + const loadCurrentProfile = () => + loadProfile({assertion: currentUsername, guiID: generateGUIID(), inTracker: false, reason: ''}) + + const closeModal = () => { + cancelSession() + navigateUp() + } + + const closeToProfile = () => { + cancelSession() + clearModals() + navToProfile(currentUsername) + loadCurrentProfile() + } + + const checkProofAndNavigate = async ( + proofPlatform: T.More.PlatformsExpandedType, + sigID: T.RPCGen.SigID, + username: string, + proofText: string + ) => { + try { + const {found, status} = await T.RPCGen.proveCheckProofRpcPromise({sigID}, C.waitingKeyProfile) + if (!mountedRef.current) return + if (!found && status >= T.RPCGen.ProofStatus.baseHardError) { + setStepSafe({ + error: "We couldn't find your proof. Please retry!", + kind: 'postProof', + platform: proofPlatform, + proofText, + sigID, + username, + }) + } else { + setStepSafe({ + kind: 'confirmOrPending', + platform: proofPlatform, + proofFound: found, + proofStatus: status, + username, + }) + } + } catch { + logger.warn('Error getting proof update') + setStepSafe({ + error: "We couldn't verify your proof. Please retry!", + kind: 'postProof', + platform: proofPlatform, + proofText, + sigID, + username, + }) + } + } + + const startProof = (proofPlatform: string, proofReason: 'appLink' | 'profile') => { + const service = T.More.asPlatformsExpandedType(proofPlatform) + const genericService = service ? null : proofPlatform + + switch (service) { + case 'dnsOrGenericWebSite': + setStepSafe({kind: 'websiteChoice'}) + return + case 'zcash': + case 'btc': + setStepSafe({error: '', kind: 'enterUsername', platform: service, username: ''}) + return + case 'pgp': + navigateAppend('profilePgp') + return + default: + break + } + + setStepSafe({kind: 'loading'}) + + const inputCancelError = { + code: T.RPCGen.StatusCode.scinputcanceled, + desc: 'Cancel Add Proof', + } + + let canceled = false + let proofText = '' + currentUsernameRef.current = '' + currentGenericParamsRef.current = makeProveGenericParams() + + const failIfCanceled = (response: {error: (arg0: {code: number; desc: string}) => void}) => { + if (canceled || !mountedRef.current) { + response.error(inputCancelError) + return true + } + return false + } + + ignorePromise( + (async () => { + try { + const {sigID} = await T.RPCGen.proveStartProofRpcListener({ + customResponseIncomingCallMap: { + 'keybase.1.proveUi.checking': (_, response) => { + if (failIfCanceled(response)) { + return + } + response.result() + }, + 'keybase.1.proveUi.continueChecking': (_, response) => + response.result(!(canceled || !mountedRef.current)), + 'keybase.1.proveUi.okToCheck': (_, response) => response.result(true), + 'keybase.1.proveUi.outputInstructions': ({proof}, response) => { + if (failIfCanceled(response)) { + return + } + afterCheckProofRef.current = () => { + afterCheckProofRef.current = undefined + response.result() + } + cancelCurrentRef.current = () => { + canceled = true + response.error(inputCancelError) + } + + if (service && proof) { + proofText = proof + setStepSafe({ + error: '', + kind: 'postProof', + platform: service, + proofText: proof, + username: currentUsernameRef.current, + }) + } else if (proof) { + const genericParams = currentGenericParamsRef.current + setStepSafe({ + error: '', + genericParams, + kind: 'genericEnterUsername', + proofUrl: proof, + service: genericService ?? '', + username: currentUsernameRef.current, + }) + openUrl(proof) + afterCheckProofRef.current() + } + }, + 'keybase.1.proveUi.preProofWarning': (_, response) => response.result(true), + 'keybase.1.proveUi.promptOverwrite': (_, response) => response.result(true), + 'keybase.1.proveUi.promptUsername': (args, response) => { + if (failIfCanceled(response)) { + return + } + const {parameters, prevError} = args + cancelCurrentRef.current = () => { + canceled = true + response.error(inputCancelError) + } + submitUsernameRef.current = (username: string) => { + const {normalized} = normalizeProofUsername(service, username) + currentUsernameRef.current = normalized + submitUsernameRef.current = undefined + response.result(normalized) + } + if (service) { + setStepSafe({ + error: prevError?.desc ?? '', + kind: 'enterUsername', + platform: service, + username: currentUsernameRef.current, + }) + cancelCurrentRef.current = () => { + canceled = true + response.error(inputCancelError) + } + } else if (genericService && parameters) { + currentGenericParamsRef.current = toProveGenericParams(parameters) + setStepSafe({ + error: prevError?.desc ?? '', + genericParams: currentGenericParamsRef.current, + kind: 'genericEnterUsername', + service: genericService, + username: currentUsernameRef.current, + }) + } + afterCheckProofRef.current = undefined + }, + }, + incomingCallMap: { + 'keybase.1.proveUi.displayRecheckWarning': () => {}, + 'keybase.1.proveUi.outputPrechecks': () => {}, + }, + params: { + auto: false, + force: true, + promptPosted: !!genericService, + service: proofPlatform, + username: '', + }, + waitingKey: C.waitingKeyProfile, + }) + + loadCurrentProfile() + + if (service) { + ignorePromise(checkProofAndNavigate(service, sigID, currentUsernameRef.current, proofText)) + } else { + setStepSafe({ + error: '', + genericParams: currentGenericParamsRef.current, + kind: 'genericResult', + username: currentUsernameRef.current, + }) + } + } catch (_error) { + loadCurrentProfile() + if (!(_error instanceof RPCError)) { + return + } + const error = _error + logger.warn('Error making proof') + + if (genericService) { + setStepSafe({ + error: error.desc || 'Failed to verify proof', + genericParams: currentGenericParamsRef.current, + kind: 'genericResult', + username: currentUsernameRef.current, + }) + } else if (proofReason === 'appLink' && error.code === T.RPCGen.StatusCode.scgeneric) { + navigateUp() + navigateAppend({ + name: 'keybaseLinkError', + params: { + error: + "We couldn't find a valid service for proofs in this link. The link might be bad, or your Keybase app might be out of date and need to be updated.", + }, + }) + } + } finally { + resetSession() + } + })() + ) + } + + const onSubmitProofUsername = (proofPlatform: T.More.PlatformsExpandedType, input: string) => { + const {normalized, valid} = normalizeProofUsername(proofPlatform, input) + + if (proofPlatform === 'btc') { + if (!valid) { + setStepSafe({error: 'Invalid address format', kind: 'enterUsername', platform: proofPlatform, username: input}) + return + } + registerCryptoAddress( + [{address: normalized, force: true, wantedFamily: 'bitcoin'}, C.waitingKeyProfile], + () => { + setStepSafe({ + kind: 'confirmOrPending', + platform: proofPlatform, + proofFound: true, + proofStatus: T.RPCGen.ProofStatus.ok, + username: normalized, + }) + loadCurrentProfile() + }, + error => { + setStepSafe({error: error.desc, kind: 'enterUsername', platform: proofPlatform, username: input}) + } + ) + return + } + + if (proofPlatform === 'zcash') { + registerCryptoAddress( + [{address: normalized, force: true, wantedFamily: 'zcash'}, C.waitingKeyProfile], + () => { + setStepSafe({ + kind: 'confirmOrPending', + platform: proofPlatform, + proofFound: true, + proofStatus: T.RPCGen.ProofStatus.ok, + username: normalized, + }) + loadCurrentProfile() + }, + error => { + setStepSafe({error: error.desc, kind: 'enterUsername', platform: proofPlatform, username: input}) + } + ) + return + } + + currentUsernameRef.current = normalized + if (!submitUsernameRef.current) { + return + } + submitUsernameRef.current(normalized) + } + + startProofRef.current = startProof + + React.useEffect(() => { + const {platform: initialPlatform, reason: initialReason} = initialRouteRef.current + if (initialPlatform && !initialProofStartedRef.current) { + initialProofStartedRef.current = true + startProofRef.current(initialPlatform, initialReason) + } + }, []) + + const content = (() => { + switch (step.kind) { + case 'loading': + return ( + + + + + Starting proof... + + + + ) + case 'websiteChoice': + return ( + + + + Prove your website in two ways: + + startProof('web', 'profile'), + title: 'Host a TXT file', + }, + { + description: 'Place a Keybase proof in your DNS records.', + icon: 'icon-dns-48', + onClick: () => startProof('dns', 'profile'), + title: 'Set a DNS', + }, + ]} + /> + + + ) + case 'enterUsername': + return ( + + ) + case 'postProof': + return ( + { + if (afterCheckProofRef.current) { + const submit = afterCheckProofRef.current + afterCheckProofRef.current = undefined + submit() + return + } + step.sigID && ignorePromise(checkProofAndNavigate(step.platform, step.sigID, step.username, step.proofText)) + }} + step={step} + /> + ) + case 'confirmOrPending': + return + case 'genericEnterUsername': + return ( + { + if (step.proofUrl) { + openUrl(step.proofUrl) + return + } + currentUsernameRef.current = username + if (!submitUsernameRef.current) { + return + } + submitUsernameRef.current(username) + }} + step={step} + /> + ) + case 'genericResult': + return + case 'pick': + return startProof(key, 'profile')} providers={providers} /> + } + })() + + return <>{content} +} + +const ProviderPicker = ({ + onCancel, + onSelect, + providers, +}: { + onCancel: () => void + onSelect: (key: string) => void + providers: Array +}) => { const [filter, setFilter] = React.useState('') + const itemHeight = { + height: Kb.Styles.isMobile ? 56 : 48, + type: 'fixed', + } as const + const filterRegexp = makeInsertMatcher(filter) + const items = (() => { + const exact: Array = [] + const inexact: Array = [] + providers.forEach(p => { + if (p.name === filter) { + exact.push(p) + } else if (filterProvider(p, filterRegexp)) { + inexact.push(p) + } + }) + return [...exact, ...inexact] + })() return ( - <> - - - - - + + + + + ( + + + onSelect(provider.key)} + style={styles.containerBox} + > + + + + {provider.name} + + {(provider.new || !!provider.desc) && ( + + {provider.new && ( + + )} + + {provider.desc} + + + )} + + + + + )} /> - + + + + - + ) } -export type IdentityProvider = { - name: string - desc: string - icon: T.Tracker.SiteIconSet - key: string - new: boolean +const EnterUsername = ({ + error, + onCancel, + onSubmit, + platform, + username: initialUsername, +}: { + error: string + onCancel: () => void + onSubmit: (platform: T.More.PlatformsExpandedType, username: string) => void + platform: T.More.PlatformsExpandedType + username: string +}) => { + const [username, setUsername] = React.useState(initialUsername) + const [errorText, setErrorText] = React.useState(error === 'Input canceled' ? '' : error) + + React.useEffect(() => { + setUsername(initialUsername) + }, [initialUsername]) + + React.useEffect(() => { + setErrorText(error === 'Input canceled' ? '' : error) + }, [error]) + + const canSubmit = !!username.length + const submit = () => { + if (!canSubmit) { + return + } + setErrorText('') + onSubmit(platform, username) + } + + const pt = platformText[platform] + if (!pt.headerText) { + throw new Error(`Proofs for platform ${platform} are unsupported.`) + } + + return ( + + {!!errorText && ( + + + {errorText} + + + )} + + {C.isMobile ? null : ( + + {pt.headerText} + + )} + + + + + + + + + + ) } -export type Props = { +const GenericEnterUsername = ({ + onCancel, + onSubmit, + step, +}: { onCancel: () => void - providerClicked: (key: string) => void - providers: Array - title: string + onSubmit: (username: string) => void + step: GenericEnterUsernameStep +}) => { + const [username, setUsername] = React.useState(step.username) + React.useEffect(() => { + setUsername(step.username) + }, [step.username]) + + const unreachable = !!step.proofUrl + return ( + + {!unreachable && !Kb.Styles.isMobile && } + + + + + + + {step.genericParams.title} + + {step.genericParams.subtext} + + + + + {unreachable ? ( + + ) : ( + + + + {step.genericParams.suffix} + + } + error={!!step.error} + onChangeText={setUsername} + onEnterKeyDown={() => onSubmit(username)} + placeholder={ + step.genericParams.suffix === '@theqrl.org' ? 'Your QRL address' : 'Your username' + } + value={username} + /> + + )} + {!!step.error && {step.error}} + + + {unreachable && ( + + You need to authorize your proof on {step.genericParams.title}. + + )} + + {!Kb.Styles.isMobile && !unreachable && ( + + )} + {unreachable ? ( + onSubmit(username)} + label={step.genericParams.buttonLabel} + style={styles.buttonBig} + /> + ) : ( + onSubmit(username)} + label={step.genericParams.buttonLabel} + style={styles.buttonBig} + waitingKey={C.waitingKeyProfile} + /> + )} + + + + ) } -type ProvidersProps = { - filter: string -} & Props +const PostProof = ({ + copyToClipboard, + onCancel, + onSubmit, + step, +}: { + copyToClipboard: (text: string) => void + onCancel: () => void + onSubmit: () => void + step: PostProofStep +}) => { + let proofText = step.proofText + let url = '' + let openLinkBeforeSubmit = false + switch (step.platform) { + case 'twitter': + openLinkBeforeSubmit = true + url = proofText ? `https://twitter.com/home?status=${proofText}` : '' + break + case 'github': + openLinkBeforeSubmit = true + url = 'https://gist.github.com/' + break + case 'reddit': + case 'facebook': + openLinkBeforeSubmit = true + url = proofText + proofText = '' + break + case 'hackernews': + openLinkBeforeSubmit = true + url = `https://news.ycombinator.com/user?id=${step.username}` + break + default: + break + } -function Providers({filter, providerClicked, providers}: ProvidersProps) { - const _itemHeight = { - height: Kb.Styles.isMobile ? 56 : 48, - type: 'fixed', - } as const + const [showSubmit, setShowSubmit] = React.useState(!openLinkBeforeSubmit) + const platformSubtitle = subtitle(step.platform) + const proofActionText = actionMap.get(step.platform) ?? '' + const onCompleteText = checkMap.get(step.platform) ?? 'OK posted! Check for it!' + const noteText = noteMap.get(step.platform) ?? '' + const DescriptionView = descriptionMap[step.platform] ?? EmptyDescription - const _renderItem = (_: unknown, provider: IdentityProvider) => ( - - - providerClicked(provider.key)} - style={styles.containerBox} + return ( + + - - - - {provider.name} - - {(provider.new || !!provider.desc) && ( - - {provider.new && ( - - )} - - {provider.desc} + { + e.preventDefault() + proofText && copyToClipboard(proofText) + }} + > + {!!step.error && ( + + + {step.error} )} + + <> + + {step.username} + + {!!platformSubtitle && ( + + {platformSubtitle} + + )} + + + {!!proofText && } + {!!noteText && ( + + {noteText} + + )} + + + {showSubmit ? ( + + ) : ( + { + setShowSubmit(true) + url && openUrl(url) + }} + label={proofActionText} + /> + )} + - + + ) +} + +const ConfirmOrPending = ({ + onClose, + step, +}: { + onClose: () => void + step: ConfirmOrPendingStep +}) => { + const isGood = step.proofFound && step.proofStatus === T.RPCGen.ProofStatus.ok + const isPending = + !isGood && + !step.proofFound && + !!step.proofStatus && + step.proofStatus <= T.RPCGen.ProofStatus.baseHardError + const platformIconOverlayColor = isGood ? Kb.Styles.globalColors.green : Kb.Styles.globalColors.greyDark + const platformIconOverlay = isPending ? 'icon-proof-pending' : 'icon-proof-success' + const platformSubtitle = subtitle(step.platform) + const title = isPending ? 'Your proof is pending.' : 'Verified!' + const message = + messageMap.get(step.platform) || + (isPending + ? 'Some proofs can take a few hours to recognize. Check back later.' + : 'Leave your proof up so other users can identify you!') + + return ( + + + + {title} + + - - + <> + + {step.username} + + {platformSubtitle && ( + + {platformSubtitle} + + )} + + + {message} + + {step.platform === 'http' && ( + + Note: {step.username} doesn't load over https. If you get a real SSL certificate + (not self-signed) in the future, please replace this proof with a fresh one. + + )} + + + ) +} - const filterRegexp = makeInsertMatcher(filter) +const GenericResult = ({ + onClose, + step, +}: { + onClose: () => void + step: GenericResultStep +}) => { + const proofUsername = step.username + step.genericParams.suffix + const success = !step.error + const iconType = success ? 'icon-proof-success' : 'icon-proof-broken' + return ( + + + + + + + + + {success ? ( + <> + You are provably + {proofUsername} + + ) : ( + {step.error} + )} + + + + + + ) +} - const items = (() => { - const exact: Array = [] - const inexact: Array = [] - providers.forEach(p => { - if (p.name === filter) { - exact.push(p) - } else if (filterProvider(p, filterRegexp)) { - inexact.push(p) - } - }) - return [...exact, ...inexact] - })() +const Unreachable = ({ + serviceIcon, + serviceSuffix, + username, +}: { + serviceIcon: T.Tracker.SiteIconSet + serviceSuffix: string + username: string +}) => ( + + + + + + {username} + + {serviceSuffix} + + + + + + + +) + +const UsernameTips = ({platform}: {platform: T.More.PlatformsExpandedType}) => + platform === 'hackernews' ? ( + + • You must have karma ≥ 2 + • You must enter your uSeRName with exact case + + ) : null +const standardText = (name: string) => ({ + headerText: C.isMobile ? `Prove ${name}` : `Prove your ${name} identity`, + hintText: `Your ${name} username`, +}) + +const invalidText = () => ({ + headerText: '', + hintText: '', +}) + +const platformText = { + btc: {headerText: 'Set a Bitcoin address', hintText: 'Your Bitcoin address'}, + dns: {headerText: 'Prove your domain', hintText: 'yourdomain.com'}, + dnsOrGenericWebSite: invalidText(), + facebook: standardText('Facebook'), + github: standardText('GitHub'), + hackernews: standardText('Hacker News'), + http: {headerText: 'Prove your http website', hintText: 'http://whatever.yoursite.com'}, + https: {headerText: 'Prove your https website', hintText: 'https://whatever.yoursite.com'}, + pgp: invalidText(), + reddit: standardText('Reddit'), + rooter: invalidText(), + twitter: standardText('Twitter'), + web: {headerText: 'Prove your website', hintText: 'whatever.yoursite.com'}, + zcash: {headerText: 'Set a Zcash address', hintText: 'Your z_address or t_address'}, +} + +const actionMap = new Map([ + ['github', 'Create gist now'], + ['hackernews', 'Go to Hacker News'], + ['reddit', 'Reddit form'], + ['twitter', 'Tweet it now'], +]) + +const checkMap = new Map([['twitter', 'OK tweeted! Check for it!']]) + +const webNote = 'Note: If someone already verified this domain, just append to the existing keybase.txt file.' + +const noteMap = new Map([ + ['http', webNote], + ['https', webNote], + ['reddit', "Make sure you're signed in to Reddit, and don't edit the text or title before submitting."], + ['web', webNote], +]) + +const WebDescription = ({platformUserName}: {platformUserName: string}) => { + const root = `${platformUserName}/keybase.txt` + const wellKnown = `${platformUserName}/.well-known/keybase.txt` + const rootUrlProps = Kb.useClickURL(`https://${root}`) + const wellKnownUrlProps = Kb.useClickURL(`https://${wellKnown}`) return ( - - - + + + Please serve the text below exactly as it appears + {" at one of these URL's."} + + + {root} + + + {wellKnown} + + ) } -const normalizeForFiltering = (input: string) => input.toLowerCase().replace(/[.\s]/g, '') +const EmptyDescription = () => null + +const descriptionMap: Partial< + Record> +> = { + dns: () => ( + + Enter the following as a TXT entry in your DNS zone, exactly as it appears + {'. If you need a "name" for your entry, give it "@".'} + + ), + facebook: () => null, + github: () => ( + + Login to GitHub and paste the text below into a public gist + called keybase.md. + + ), + hackernews: () => ( + + Please add the below text exactly as it appears{' '} + to your profile. + + ), + http: WebDescription, + https: WebDescription, + reddit: () => ( + + Click the button below and post the form in the subreddit KeybaseProofs. + + ), + rooter: () => null, + twitter: () => ( + + Click the button below and tweet the text exactly as it appears. + + ), + web: WebDescription, +} -const filterProvider = (p: IdentityProvider, filter: RegExp) => +const messageMap = new Map([ + ['btc', 'Your Bitcoin address has now been signed onto your profile.'], + ['dns', 'DNS proofs can take a few hours to recognize. Check back later.'], + [ + 'hackernews', + 'Hacker News caches its bios, so it might be a few hours before you can verify your proof. Check back later.', + ], + ['zcash', 'Your Zcash address has now been signed onto your profile.'], +]) + +const normalizeForFiltering = (input: string) => input.toLowerCase().replace(/[.\s]/g, '') +const filterProvider = (p: Provider, filter: RegExp) => normalizeForFiltering(p.name).search(filter) !== -1 || normalizeForFiltering(p.desc).search(filter) !== -1 const rightColumnStyle = Kb.Styles.platformStyles({ @@ -157,6 +1270,29 @@ const rightColumnStyle = Kb.Styles.platformStyles({ const styles = Kb.Styles.styleSheetCreate( () => ({ + backButton: { + left: Kb.Styles.globalMargins.small, + position: 'absolute', + top: Kb.Styles.globalMargins.small, + }, + blue: Kb.Styles.platformStyles({ + common: {color: Kb.Styles.globalColors.blueDark}, + isElectron: {wordBreak: 'break-all'}, + }), + bottomContainer: {height: 80}, + buttonBar: { + ...Kb.Styles.padding( + Kb.Styles.globalMargins.small, + Kb.Styles.globalMargins.medium, + Kb.Styles.globalMargins.medium + ), + }, + buttonBarWarning: {backgroundColor: Kb.Styles.globalColors.yellow}, + buttonBig: {flex: 2.5}, + buttonSmall: {flex: 1}, + center: {alignSelf: 'center'}, + centered: {alignSelf: 'center'}, + colorRed: {color: Kb.Styles.globalColors.redDark}, container: Kb.Styles.platformStyles({ common: {flex: 1}, isElectron: { @@ -175,6 +1311,13 @@ const styles = Kb.Styles.styleSheetCreate( justifyContent: 'flex-start', }, description: {...rightColumnStyle}, + error: { + backgroundColor: Kb.Styles.globalColors.red, + borderRadius: Kb.Styles.borderRadius, + marginBottom: Kb.Styles.globalMargins.small, + padding: Kb.Styles.globalMargins.medium, + }, + grey: {color: Kb.Styles.globalColors.black_20}, icon: { height: 32, marginLeft: Kb.Styles.globalMargins.small, @@ -182,6 +1325,23 @@ const styles = Kb.Styles.styleSheetCreate( width: 32, }, iconArrow: {marginRight: Kb.Styles.globalMargins.small}, + iconBadgeContainer: { + bottom: -5, + position: 'absolute', + right: -5, + }, + inlineIcon: { + position: 'relative', + top: 1, + }, + inputBox: { + ...Kb.Styles.padding(Kb.Styles.globalMargins.small, Kb.Styles.globalMargins.small), + borderColor: Kb.Styles.globalColors.black_10, + borderRadius: Kb.Styles.borderRadius, + borderStyle: 'solid' as const, + borderWidth: 1, + minHeight: 40, + }, inputContainer: { alignSelf: 'stretch', backgroundColor: Kb.Styles.globalColors.black_10, @@ -193,21 +1353,74 @@ const styles = Kb.Styles.styleSheetCreate( padding: Kb.Styles.globalMargins.tiny, width: 'auto', }, + inputContainerWrap: { + ...Kb.Styles.padding(Kb.Styles.globalMargins.medium), + }, listContainer: {flex: 1}, + marginLeftAuto: {marginLeft: 'auto'}, mobileFlex: {flex: 1}, new: { marginRight: Kb.Styles.globalMargins.xtiny, marginTop: 1, }, + opacity40: {opacity: 0.4}, + opacity75: {opacity: 0.75}, + placeholderService: { + color: Kb.Styles.globalColors.black_50, + }, + proof: { + maxWidth: '100%', + }, + providerButtonBar: { + padding: Kb.Styles.globalMargins.medium, + }, + scroll: {maxWidth: '100%'}, + scrollContent: { + paddingBottom: Kb.Styles.globalMargins.small, + }, + serviceIconContainer: { + marginBottom: Kb.Styles.globalMargins.tiny, + position: 'relative', + }, + serviceIconFull: { + height: 64, + width: 64, + }, + serviceIconHeaderContainer: { + marginTop: Kb.Styles.globalMargins.medium, + }, + serviceMeta: { + maxWidth: 320, + }, + serviceProofIcon: { + bottom: -8, + position: 'absolute', + right: -8, + }, text: { backgroundColor: Kb.Styles.globalColors.transparent, color: Kb.Styles.globalColors.black_50, marginRight: Kb.Styles.globalMargins.tiny, }, + tips: {padding: Kb.Styles.globalMargins.small}, title: { ...rightColumnStyle, color: Kb.Styles.globalColors.black, }, + topContainer: {flex: 1}, + unreachableBox: { + backgroundColor: Kb.Styles.globalColors.black_05, + }, + unreachablePlaceholder: { + alignItems: 'center', + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + gap: Kb.Styles.globalMargins.xtiny, + }, + warningText: { + ...Kb.Styles.padding(Kb.Styles.globalMargins.small, Kb.Styles.globalMargins.medium, 0), + }, }) as const ) diff --git a/shared/profile/generic/result.tsx b/shared/profile/generic/result.tsx deleted file mode 100644 index 9a12e1c548b4..000000000000 --- a/shared/profile/generic/result.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import * as C from '@/constants' -import {useProfileState} from '@/stores/profile' -import * as Kb from '@/common-adapters' -import {SiteIcon} from './shared' - -const GenericResult = () => { - const errorText = useProfileState(s => - s.errorCode !== undefined ? s.errorText || 'Failed to verify proof' : '' - ) - const proofUsername = useProfileState(s => s.username + (s.platformGenericParams?.suffix ?? '@unknown')) - const serviceIcon = useProfileState(s => s.platformGenericParams?.logoFull ?? []) - const backToProfile = useProfileState(s => s.dispatch.backToProfile) - const clearPlatformGeneric = useProfileState(s => s.dispatch.clearPlatformGeneric) - const clearModals = C.useRouterState(s => s.dispatch.clearModals) - const onClose = () => { - clearModals() - backToProfile() - clearPlatformGeneric() - } - - const success = !errorText - const iconType = success ? 'icon-proof-success' : 'icon-proof-broken' - let frag = ( - <> - You are provably - {proofUsername} - - ) - if (!success) { - frag = ( - <> - {errorText} - - ) - } - return ( - - - - - - - - - {frag} - - - - - - ) -} - -const styles = Kb.Styles.styleSheetCreate( - () => - ({ - bottomContainer: { - height: 80, - }, - container: {}, - iconBadgeContainer: { - bottom: -5, - position: 'absolute', - right: -5, - }, - serviceIconContainer: Kb.Styles.platformStyles({ - common: { - marginBottom: Kb.Styles.globalMargins.tiny, - position: 'relative', - }, - }), - topContainer: Kb.Styles.platformStyles({ - common: { - flex: 1, - }, - }), - }) as const -) - -export default GenericResult diff --git a/shared/profile/pgp/choice/index.desktop.tsx b/shared/profile/pgp/choice/index.desktop.tsx index 5d15f6012d34..4c93850dbe86 100644 --- a/shared/profile/pgp/choice/index.desktop.tsx +++ b/shared/profile/pgp/choice/index.desktop.tsx @@ -1,40 +1,327 @@ import * as Kb from '@/common-adapters' import * as C from '@/constants' +import * as React from 'react' +import * as T from '@/constants/types' +import {ignorePromise} from '@/constants/utils' +import {RPCError} from '@/util/errors' import Modal from '@/profile/modal' +import {validatePgpInfo} from '../validation' + +type GeneratePgpArgs = { + pgpEmail1: string + pgpEmail2: string + pgpEmail3: string + pgpFullName: string +} + +type Step = + | {kind: 'choice'} + | {kind: 'info'} + | {kind: 'generate'} + | {kind: 'finished'; pgpKeyString: string; promptShouldStoreKeyOnServer: boolean} + +const makeInitialForm = (): GeneratePgpArgs => ({ + pgpEmail1: '', + pgpEmail2: '', + pgpEmail3: '', + pgpFullName: '', +}) export default function Choice() { - const clearModals = C.useRouterState(s => s.dispatch.clearModals) + const {clearModals, navigateAppend} = C.useRouterState( + C.useShallow(s => ({ + clearModals: s.dispatch.clearModals, + navigateAppend: s.dispatch.navigateAppend, + })) + ) + const mountedRef = React.useRef(true) + const cancelCurrentRef = React.useRef void)>(undefined) + const finishCurrentRef = React.useRef void)>(undefined) + const [form, setForm] = React.useState(makeInitialForm) + const [step, setStep] = React.useState({kind: 'choice'}) + + React.useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + const cancel = cancelCurrentRef.current + cancelCurrentRef.current = undefined + finishCurrentRef.current = undefined + cancel?.() + } + }, []) + + const setStepSafe = (next: Step) => { + if (mountedRef.current) { + setStep(next) + } + } + const onCancel = () => { + if (step.kind === 'info') { + setStepSafe({kind: 'choice'}) + return + } + if (step.kind === 'generate') { + cancelCurrentRef.current?.() + } clearModals() } - const navigateAppend = C.useRouterState(s => s.dispatch.navigateAppend) + const onShowGetNew = () => { - navigateAppend('profileProvideInfo') + setStepSafe({kind: 'info'}) } const onShowImport = () => { navigateAppend('profileImport') } - return ( - - - Add a PGP key - ) => { + setForm(s => ({...s, ...next})) + } + + const data = {...form, ...validatePgpInfo(form)} + const nextDisabled = !data.pgpEmail1 || !data.pgpFullName || !!data.pgpErrorText + + const onGenerate = () => { + if (nextDisabled) { + return + } + const args = form + setStepSafe({kind: 'generate'}) + + ignorePromise( + (async () => { + let canceled = false + let pgpKeyString = 'Error getting public key...' + const inputCancelError = {code: T.RPCGen.StatusCode.scinputcanceled, desc: 'Input canceled'} + const ids = [args.pgpEmail1, args.pgpEmail2, args.pgpEmail3].filter(Boolean).map(email => ({ + comment: '', + email, + username: args.pgpFullName, + })) + + cancelCurrentRef.current = () => { + canceled = true + } + + try { + await T.RPCGen.pgpPgpKeyGenDefaultRpcListener({ + customResponseIncomingCallMap: { + 'keybase.1.pgpUi.keyGenerated': ({key}, response) => { + if (canceled || !mountedRef.current) { + response.error(inputCancelError) + return + } + pgpKeyString = key.key + response.result() + }, + 'keybase.1.pgpUi.shouldPushPrivate': ({prompt}, response) => { + if (canceled || !mountedRef.current) { + response.error(inputCancelError) + return + } + cancelCurrentRef.current = () => { + canceled = true + response.error(inputCancelError) + } + finishCurrentRef.current = (shouldStoreKeyOnServer: boolean) => { + finishCurrentRef.current = undefined + response.result(shouldStoreKeyOnServer) + } + setStepSafe({kind: 'finished', pgpKeyString, promptShouldStoreKeyOnServer: prompt}) + }, }, - ]} - /> - + incomingCallMap: {'keybase.1.pgpUi.finished': () => {}}, + params: {createUids: {ids, useDefault: false}}, + }) + } catch (error) { + if (!(error instanceof RPCError)) { + return + } + if (error.code !== T.RPCGen.StatusCode.scinputcanceled) { + throw error + } + } finally { + cancelCurrentRef.current = undefined + finishCurrentRef.current = undefined + } + })() + ) + } + + const content = (() => { + switch (step.kind) { + case 'choice': + return ( + + Add a PGP key + + + ) + case 'info': + return ( + <> + + + + Fill in your public info. + + onUpdate({pgpFullName})} + /> + onUpdate({pgpEmail1})} + onEnterKeyDown={onGenerate} + value={data.pgpEmail1} + error={data.pgpErrorEmail1} + /> + onUpdate({pgpEmail2})} + onEnterKeyDown={onGenerate} + value={data.pgpEmail2} + error={data.pgpErrorEmail2} + /> + onUpdate({pgpEmail3})} + onEnterKeyDown={onGenerate} + value={data.pgpEmail3} + error={data.pgpErrorEmail3} + /> + + {data.pgpErrorText || 'Include any addresses you plan to use for PGP encrypted email.'} + + + + + + + + ) + case 'generate': + return ( + + + Generating your unique key... + + Math time! You are about to discover a 4096-bit key pair. +
+ This could take as long as a couple of minutes. +
+ +
+ ) + case 'finished': + return ( + { + const finish = finishCurrentRef.current + finishCurrentRef.current = undefined + finish?.(shouldStoreKeyOnServer) + clearModals() + }} + pgpKeyString={step.pgpKeyString} + promptShouldStoreKeyOnServer={step.promptShouldStoreKeyOnServer} + /> + ) + } + })() + + const skipButton = step.kind === 'info' || step.kind === 'finished' + return ( + + {content} ) } + +const Finished = (props: { + onDone: (shouldStoreKeyOnServer: boolean) => void + promptShouldStoreKeyOnServer: boolean + pgpKeyString: string +}) => { + const [shouldStoreKeyOnServer, setShouldStoreKeyOnServer] = React.useState(false) + + return ( + + + Here is your unique public key! + + Your private key has been written to Keybase’s local keychain. You can learn to use it with `keybase + pgp help` from your terminal. If you have GPG installed, it has also been written to GPG’s keychain. + +