From 2f3fc4fe4e799084799631323b73fc97820144c8 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Mon, 6 Mar 2023 21:37:48 -0600 Subject: 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 (limited to 'src') diff --git a/src/lib/styles.ts b/src/lib/styles.ts index d307e9ba8..a8c387616 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 b15c866f4..b79283be1 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 0dad2bd9e..68d9cd3d0 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 78b24e68c..4f994f831 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 000000000..519be7b2e --- /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 1346f12f3..d3a02e0da 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 b10b60be8..dd9a3aa65 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 9332b5150..47e76a124 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, }, }) -- cgit 1.4.1