From 2f3fc4fe4e799084799631323b73fc97820144c8 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Mon, 6 Mar 2023 21:37:48 -0600 Subject: [PATCH] Handle-change modal with custom domain support (#273) * Dont append the server's domain name when a custom domain is used * Update the settings look & feel and add a tool to remove accounts from the switcher * Try not rendering the bottomsheet when no modal is active. There are cases where the bottomsheet decides to show itself when it's not supposed to. It seems obvious to do what this change is doing -- just dont render bottomsheet if no modal is active -- but previously we experienced issues with that approach. This time it seems to be working, so we're gonna yolo try it. * Implement a handle-change modal with support for custom domains (closes #65) --- src/lib/styles.ts | 2 + src/state/models/session.ts | 43 ++- src/state/models/shell-ui.ts | 6 + src/view/com/login/Signin.tsx | 1 + src/view/com/modals/ChangeHandle.tsx | 518 +++++++++++++++++++++++++++ src/view/com/modals/Modal.tsx | 6 +- src/view/com/modals/Modal.web.tsx | 3 + src/view/screens/Settings.tsx | 340 ++++++++++++------ 8 files changed, 808 insertions(+), 111 deletions(-) create mode 100644 src/view/com/modals/ChangeHandle.tsx diff --git a/src/lib/styles.ts b/src/lib/styles.ts index d307e9ba..a8c38761 100644 --- a/src/lib/styles.ts +++ b/src/lib/styles.ts @@ -30,6 +30,8 @@ export const colors = { red3: '#ec4899', red4: '#d1106f', red5: '#97074e', + red6: '#690436', + red7: '#4F0328', pink1: '#f8ccff', pink2: '#e966ff', diff --git a/src/state/models/session.ts b/src/state/models/session.ts index b15c866f..b79283be 100644 --- a/src/state/models/session.ts +++ b/src/state/models/session.ts @@ -1,4 +1,4 @@ -import {makeAutoObservable} from 'mobx' +import {makeAutoObservable, runInAction} from 'mobx' import { AtpAgent, AtpSessionEvent, @@ -368,4 +368,45 @@ export class SessionModel { this.clearSessionTokens() this.rootStore.clearAllSessionState() } + + /** + * Removes an account from the list of stored accounts. + */ + removeAccount(handle: string) { + this.accounts = this.accounts.filter(acc => acc.handle !== handle) + } + + /** + * Reloads the session from the server. Useful when account details change, like the handle. + */ + async reloadFromServer() { + const sess = this.currentSession + if (!sess) { + return + } + const res = await this.rootStore.api.app.bsky.actor + .getProfile({actor: sess.did}) + .catch(_e => undefined) + if (res?.success) { + const updated = { + ...sess, + handle: res.data.handle, + displayName: res.data.displayName, + aviUrl: res.data.avatar, + } + runInAction(() => { + this.accounts = [ + updated, + ...this.accounts.filter( + account => + !( + account.service === updated.service && + account.did === updated.did + ), + ), + ] + }) + await this.rootStore.me.load() + } + } } diff --git a/src/state/models/shell-ui.ts b/src/state/models/shell-ui.ts index 0dad2bd9..68d9cd3d 100644 --- a/src/state/models/shell-ui.ts +++ b/src/state/models/shell-ui.ts @@ -51,6 +51,11 @@ export interface RepostModal { isReposted: boolean } +export interface ChangeHandleModal { + name: 'change-handle' + onChanged: () => void +} + export type Modal = | ConfirmModal | EditProfileModal @@ -60,6 +65,7 @@ export type Modal = | CropImageModal | DeleteAccountModal | RepostModal + | ChangeHandleModal interface LightboxModel {} diff --git a/src/view/com/login/Signin.tsx b/src/view/com/login/Signin.tsx index 78b24e68..4f994f83 100644 --- a/src/view/com/login/Signin.tsx +++ b/src/view/com/login/Signin.tsx @@ -296,6 +296,7 @@ const LoginForm = ({ let fullIdent = identifier if ( !identifier.includes('@') && // not an email + !identifier.includes('.') && // not a domain serviceDescription && serviceDescription.availableUserDomains.length > 0 ) { diff --git a/src/view/com/modals/ChangeHandle.tsx b/src/view/com/modals/ChangeHandle.tsx new file mode 100644 index 00000000..519be7b2 --- /dev/null +++ b/src/view/com/modals/ChangeHandle.tsx @@ -0,0 +1,518 @@ +import React, {useState} from 'react' +import Clipboard from '@react-native-clipboard/clipboard' +import * as Toast from '../util/Toast' +import { + ActivityIndicator, + StyleSheet, + TouchableOpacity, + View, +} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {ScrollView, TextInput} from './util' +import {Text} from '../util/text/Text' +import {Button} from '../util/forms/Button' +import {ErrorMessage} from '../util/error/ErrorMessage' +import {useStores} from 'state/index' +import {ServiceDescription} from 'state/models/session' +import {s} from 'lib/styles' +import {makeValidHandle, createFullHandle} from 'lib/strings/handles' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnalytics} from 'lib/analytics' +import {cleanError} from 'lib/strings/errors' + +export const snapPoints = ['100%'] + +export function Component({onChanged}: {onChanged: () => void}) { + const store = useStores() + const [error, setError] = useState('') + const pal = usePalette('default') + const {track} = useAnalytics() + + const [isProcessing, setProcessing] = useState(false) + const [retryDescribeTrigger, setRetryDescribeTrigger] = React.useState( + {}, + ) + const [serviceDescription, setServiceDescription] = React.useState< + ServiceDescription | undefined + >(undefined) + const [userDomain, setUserDomain] = React.useState('') + const [isCustom, setCustom] = React.useState(false) + const [handle, setHandle] = React.useState('') + const [canSave, setCanSave] = React.useState(false) + + // init + // = + React.useEffect(() => { + let aborted = false + setError('') + setServiceDescription(undefined) + setProcessing(true) + + // load the service description so we can properly provision handles + store.session.describeService(String(store.agent.service)).then( + desc => { + if (aborted) { + return + } + setServiceDescription(desc) + setUserDomain(desc.availableUserDomains[0]) + setProcessing(false) + }, + err => { + if (aborted) { + return + } + setProcessing(false) + store.log.warn( + `Failed to fetch service description for ${String( + store.agent.service, + )}`, + err, + ) + setError( + 'Unable to contact your service. Please check your Internet connection.', + ) + }, + ) + return () => { + aborted = true + } + }, [store.agent.service, store.session, store.log, retryDescribeTrigger]) + + // events + // = + const onPressCancel = React.useCallback(() => { + store.shell.closeModal() + }, [store]) + const onPressRetryConnect = React.useCallback( + () => setRetryDescribeTrigger({}), + [setRetryDescribeTrigger], + ) + const onToggleCustom = React.useCallback(() => { + // toggle between a provided domain vs a custom one + setHandle('') + setCanSave(false) + setCustom(!isCustom) + track( + isCustom ? 'EditHandle:ViewCustomForm' : 'EditHandle:ViewProvidedForm', + ) + }, [setCustom, isCustom, track]) + const onPressSave = React.useCallback(async () => { + setError('') + setProcessing(true) + try { + track('EditHandle:SetNewHandle') + const newHandle = isCustom ? handle : createFullHandle(handle, userDomain) + store.log.debug(`Updating handle to ${newHandle}`) + await store.api.com.atproto.handle.update({ + handle: newHandle, + }) + store.shell.closeModal() + onChanged() + } catch (err: any) { + setError(cleanError(err)) + store.log.error('Failed to update handle', {handle, err}) + } finally { + setProcessing(false) + } + }, [ + setError, + setProcessing, + handle, + userDomain, + store, + isCustom, + onChanged, + track, + ]) + + // rendering + // = + return ( + + + + + + Cancel + + + + + Change my handle + + + {isProcessing ? ( + + ) : error && !serviceDescription ? ( + + + Retry + + + ) : canSave ? ( + + + Save + + + ) : undefined} + + + + {error !== '' && ( + + + + )} + + {isCustom ? ( + + ) : ( + + )} + + + ) +} + +/** + * The form for using a domain allocated by the PDS + */ +function ProvidedHandleForm({ + userDomain, + handle, + isProcessing, + setHandle, + onToggleCustom, + setCanSave, +}: { + userDomain: string + handle: string + isProcessing: boolean + setHandle: (v: string) => void + onToggleCustom: () => void + setCanSave: (v: boolean) => void +}) { + const pal = usePalette('default') + + // events + // = + const onChangeHandle = React.useCallback( + (v: string) => { + const newHandle = makeValidHandle(v) + setHandle(newHandle) + setCanSave(newHandle.length > 0) + }, + [setHandle, setCanSave], + ) + + // rendering + // = + return ( + <> + + + + + + Your full handle will be{' '} + + @{createFullHandle(handle, userDomain)} + + + + + I have my own domain + + + + ) +} + +/** + * The form for using a custom domain + */ +function CustomHandleForm({ + handle, + canSave, + isProcessing, + setHandle, + onToggleCustom, + onPressSave, + setCanSave, +}: { + handle: string + canSave: boolean + isProcessing: boolean + setHandle: (v: string) => void + onToggleCustom: () => void + onPressSave: () => void + setCanSave: (v: boolean) => void +}) { + const store = useStores() + const pal = usePalette('default') + const palSecondary = usePalette('secondary') + const palError = usePalette('error') + const [isVerifying, setIsVerifying] = React.useState(false) + const [error, setError] = React.useState('') + + // events + // = + const onPressCopy = React.useCallback(() => { + Clipboard.setString(`did=${store.me.did}`) + Toast.show('Copied to clipboard') + }, [store.me.did]) + const onChangeHandle = React.useCallback( + (v: string) => { + setHandle(v) + setCanSave(false) + }, + [setHandle, setCanSave], + ) + const onPressVerify = React.useCallback(async () => { + if (canSave) { + onPressSave() + } + try { + setIsVerifying(true) + setError('') + const res = await store.api.com.atproto.handle.resolve({handle}) + if (res.data.did === store.me.did) { + setCanSave(true) + } else { + setError(`Incorrect DID returned (got ${res.data.did})`) + } + } catch (err: any) { + setError(cleanError(err)) + store.log.error('Failed to verify domain', {handle, err}) + } finally { + setIsVerifying(false) + } + }, [ + handle, + store.me.did, + setIsVerifying, + setCanSave, + setError, + canSave, + onPressSave, + store.log, + store.api, + ]) + + // rendering + // = + return ( + <> + + Enter the domain you want to use + + + + + + + + Add the following record to your domain: + + + + Domain: + + + + _atproto.{handle} + + + + Type: + + + + TXT + + + + Value: + + + + did={store.me.did} + + + + + + {canSave === true && ( + + + Domain verified! + + + )} + {error && ( + + + {error} + + + )} + + + + + Nevermind, create a handle for me + + + + ) +} + +const styles = StyleSheet.create({ + inner: { + padding: 14, + }, + footer: { + padding: 14, + }, + spacer: { + height: 20, + }, + dimmed: { + opacity: 0.7, + }, + + title: { + flexDirection: 'row', + alignItems: 'center', + paddingTop: 25, + paddingHorizontal: 20, + paddingBottom: 15, + borderBottomWidth: 1, + }, + titleLeft: { + width: 80, + }, + titleRight: { + width: 80, + flexDirection: 'row', + justifyContent: 'flex-end', + }, + titleMiddle: { + flex: 1, + textAlign: 'center', + fontSize: 21, + }, + + textInputWrapper: { + borderRadius: 8, + flexDirection: 'row', + alignItems: 'center', + }, + textInputIcon: { + marginLeft: 12, + }, + textInput: { + flex: 1, + width: '100%', + paddingVertical: 10, + paddingHorizontal: 8, + fontSize: 17, + letterSpacing: 0.25, + fontWeight: '400', + borderRadius: 10, + }, + + dnsTable: { + borderRadius: 4, + paddingTop: 2, + paddingBottom: 16, + }, + dnsLabel: { + paddingHorizontal: 14, + paddingTop: 10, + }, + dnsValue: { + paddingHorizontal: 14, + borderRadius: 4, + }, + monoText: { + fontSize: 18, + lineHeight: 20, + }, + + message: { + paddingHorizontal: 12, + paddingVertical: 10, + borderRadius: 8, + marginBottom: 10, + }, + + btn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + borderRadius: 32, + padding: 10, + marginBottom: 10, + }, + errorContainer: {marginBottom: 10}, +}) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 1346f12f..d3a02e0d 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -12,6 +12,7 @@ import * as ReportPostModal from './ReportPost' import * as RepostModal from './Repost' import * as ReportAccountModal from './ReportAccount' import * as DeleteAccountModal from './DeleteAccount' +import * as ChangeHandleModal from './ChangeHandle' import {usePalette} from 'lib/hooks/usePalette' import {StyleSheet} from 'react-native' @@ -65,8 +66,11 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'repost') { snapPoints = RepostModal.snapPoints element = + } else if (activeModal?.name === 'change-handle') { + snapPoints = ChangeHandleModal.snapPoints + element = } else { - element = + return } return ( diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index b10b60be..dd9a3aa6 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -12,6 +12,7 @@ import * as ReportPostModal from './ReportPost' import * as ReportAccountModal from './ReportAccount' import * as RepostModal from './Repost' import * as CropImageModal from './crop-image/CropImage.web' +import * as ChangeHandleModal from './ChangeHandle' export const ModalsContainer = observer(function ModalsContainer() { const store = useStores() @@ -62,6 +63,8 @@ function Modal({modal}: {modal: ModalIface}) { element = } else if (modal.name === 'repost') { element = + } else if (modal.name === 'change-handle') { + element = } else { return null } diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 9332b515..47e76a12 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -13,13 +13,15 @@ import {observer} from 'mobx-react-lite' import * as AppInfo from 'lib/app-info' import {useStores} from 'state/index' import {ScreenParams} from '../routes' -import {s} from 'lib/styles' +import {s, colors} from 'lib/styles' import {ScrollView} from '../com/util/Views' import {ViewHeader} from '../com/util/ViewHeader' import {Link} from '../com/util/Link' import {Text} from '../com/util/text/Text' import * as Toast from '../com/util/Toast' import {UserAvatar} from '../com/util/UserAvatar' +import {DropdownButton} from 'view/com/util/forms/DropdownButton' +import {useTheme} from 'lib/ThemeContext' import {usePalette} from 'lib/hooks/usePalette' import {AccountData} from 'state/models/session' import {useAnalytics} from 'lib/analytics' @@ -28,6 +30,7 @@ export const Settings = observer(function Settings({ navIdx, visible, }: ScreenParams) { + const theme = useTheme() const pal = usePalette('default') const store = useStores() const {screen, track} = useAnalytics() @@ -63,6 +66,28 @@ export const Settings = observer(function Settings({ track('Settings:AddAccountButtonClicked') store.session.clear() } + const onPressChangeHandle = () => { + track('Settings:ChangeHandleButtonClicked') + store.shell.openModal({ + name: 'change-handle', + onChanged() { + setIsSwitching(true) + store.session.reloadFromServer().then( + () => { + setIsSwitching(false) + Toast.show('Your handle has been updated') + }, + err => { + store.log.error( + 'Failed to reload from server after handle update', + {err}, + ) + setIsSwitching(false) + }, + ) + }, + }) + } const onPressSignout = () => { track('Settings:SignOutButtonClicked') store.session.logout() @@ -75,145 +100,207 @@ export const Settings = observer(function Settings({ - - - - Signed in as - - - - - Sign out - - + + + + Signed in as + + + + {isSwitching ? ( + + - {isSwitching ? ( - - - - ) : ( - - + ) : ( + + + - - - {store.me.displayName || store.me.handle} - - @{store.me.handle} - - - )} - - Switch to: - - {store.session.switchableAccounts.map(account => ( - onPressSwitchAccount(account) - }> + + + {store.me.displayName || store.me.handle} + + + {store.me.handle} + + + + + Sign out + + + + + )} + {store.session.switchableAccounts.map(account => ( + onPressSwitchAccount(account) + }> + - - - {account.displayName || account.handle} - - @{account.handle} - - - ))} - + + + + {account.displayName || account.handle} + + + {account.handle} + + + + + ))} + + - - - Add account - - - + + + Add account + + - - - Danger zone + + + + Advanced + + + + + + + Change my handle - - Delete my account - - - Developer tools + + + + + + Danger zone + + + + + + + Delete my account - - System log - - - Storybook - - - Build version {AppInfo.appVersion} ({AppInfo.buildVersion}) + + + + + + Developer tools + + + + System log - - + + + + Storybook + + + + Build version {AppInfo.appVersion} ({AppInfo.buildVersion}) + + ) }) +function AccountDropdownBtn({handle}: {handle: string}) { + const store = useStores() + const items = [ + { + label: 'Remove account', + onPress: () => { + store.session.removeAccount(handle) + Toast.show('Account removed from quick access') + }, + }, + ] + return ( + + + + + + ) +} + const styles = StyleSheet.create({ dimmed: { opacity: 0.5, }, - spacer: { - height: 50, + spacer20: { + height: 20, }, - alignCenter: { - alignItems: 'center', - }, - title: { - fontSize: 32, - fontWeight: 'bold', - marginTop: 20, - marginBottom: 14, + heading: { + paddingHorizontal: 18, + paddingBottom: 6, }, profile: { flexDirection: 'row', @@ -222,10 +309,45 @@ const styles = StyleSheet.create({ paddingVertical: 10, paddingHorizontal: 10, }, + linkCard: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 18, + marginBottom: 1, + }, + linkCardNoIcon: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 20, + paddingHorizontal: 18, + marginBottom: 1, + }, avi: { + marginRight: 12, + }, + iconContainer: { + alignItems: 'center', + justifyContent: 'center', width: 40, height: 40, borderRadius: 30, - marginRight: 8, + marginRight: 12, + }, + trashIconContainerDark: { + backgroundColor: colors.red7, + }, + trashIconContainerLight: { + backgroundColor: colors.red1, + }, + dangerLight: { + color: colors.red4, + }, + dangerDark: { + color: colors.red2, + }, + buildInfo: { + paddingVertical: 8, + paddingHorizontal: 18, }, })