diff --git a/package.json b/package.json index c107ea56..bca0d745 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" }, "dependencies": { - "@atproto/api": "^0.12.14", + "@atproto/api": "^0.12.16", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", diff --git a/src/screens/Deactivated.tsx b/src/screens/Deactivated.tsx index faee517c..add550f9 100644 --- a/src/screens/Deactivated.tsx +++ b/src/screens/Deactivated.tsx @@ -4,18 +4,27 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useFocusEffect} from '@react-navigation/native' +import {useQueryClient} from '@tanstack/react-query' import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' +import {logger} from '#/logger' import {isWeb} from '#/platform/detection' -import {type SessionAccount, useSession, useSessionApi} from '#/state/session' +import { + type SessionAccount, + useAgent, + useSession, + useSessionApi, +} from '#/state/session' import {useSetMinimalShellMode} from '#/state/shell' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {ScrollView} from '#/view/com/util/Views' import {Logo} from '#/view/icons/Logo' import {atoms as a, useTheme} from '#/alf' import {AccountList} from '#/components/AccountList' -import {Button, ButtonText} from '#/components/Button' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Divider} from '#/components/Divider' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' const COL_WIDTH = 400 @@ -30,6 +39,10 @@ export function Deactivated() { const hasOtherAccounts = accounts.length > 1 const setMinimalShellMode = useSetMinimalShellMode() const {logout} = useSessionApi() + const agent = useAgent() + const [pending, setPending] = React.useState(false) + const [error, setError] = React.useState() + const queryClient = useQueryClient() useFocusEffect( React.useCallback(() => { @@ -62,6 +75,34 @@ export function Deactivated() { logout('Deactivated') }, [logout]) + const handleActivate = React.useCallback(async () => { + try { + setPending(true) + await agent.com.atproto.server.activateAccount() + await queryClient.resetQueries() + await agent.resumeSession(agent.session!) + } catch (e: any) { + switch (e.message) { + case 'Bad token scope': + setError( + _( + msg`You're logged in with an App Password. Please log in with your main password to continue deactivating your account.`, + ), + ) + break + default: + setError(_(msg`Something went wrong, please try again`)) + break + } + + logger.error(e, { + context: 'Failed to activate account', + }) + } finally { + setPending(false) + } + }, [_, agent, setPending, setError, queryClient]) + return ( setShowLoggedOut(true)}> + onPress={handleActivate}> Yes, reactivate my account + {pending && } + + {error && ( + + + {error} + + )} diff --git a/src/screens/Settings/components/DeactivateAccountDialog.tsx b/src/screens/Settings/components/DeactivateAccountDialog.tsx index 4330ffca..99999d06 100644 --- a/src/screens/Settings/components/DeactivateAccountDialog.tsx +++ b/src/screens/Settings/components/DeactivateAccountDialog.tsx @@ -3,9 +3,14 @@ import {View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {atoms as a, useTheme} from '#/alf' +import {logger} from '#/logger' +import {useAgent, useSessionApi} from '#/state/session' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {DialogOuterProps} from '#/components/Dialog' import {Divider} from '#/components/Divider' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {Loader} from '#/components/Loader' import * as Prompt from '#/components/Prompt' import {Text} from '#/components/Typography' @@ -13,12 +18,58 @@ export function DeactivateAccountDialog({ control, }: { control: DialogOuterProps['control'] +}) { + return ( + + + + ) +} + +function DeactivateAccountDialogInner({ + control, +}: { + control: DialogOuterProps['control'] }) { const t = useTheme() + const {gtMobile} = useBreakpoints() const {_} = useLingui() + const agent = useAgent() + const {logout} = useSessionApi() + const [pending, setPending] = React.useState(false) + const [error, setError] = React.useState() + + const handleDeactivate = React.useCallback(async () => { + try { + setPending(true) + await agent.com.atproto.server.deactivateAccount({}) + control.close(() => { + logout('Deactivated') + }) + } catch (e: any) { + switch (e.message) { + case 'Bad token scope': + setError( + _( + msg`You're logged in with an App Password. Please log in with your main password to continue deactivating your account.`, + ), + ) + break + default: + setError(_(msg`Something went wrong, please try again`)) + break + } + + logger.error(e, { + context: 'Failed to deactivate account', + }) + } finally { + setPending(false) + } + }, [agent, control, logout, _, setPending]) return ( - + <> {_(msg`Deactivate account`)} @@ -48,13 +99,32 @@ export function DeactivateAccountDialog({ - {}} + - + + {error && ( + + + {error} + + )} + ) } diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 7d579d55..b81cf596 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -18,9 +18,12 @@ const accountSchema = z.object({ refreshJwt: z.string().optional(), // optional because it can expire accessJwt: z.string().optional(), // optional because it can expire signupQueued: z.boolean().optional(), - status: z - .enum(['active', 'takendown', 'suspended', 'deactivated']) - .optional(), + active: z.boolean().optional(), // optional for backwards compat + /** + * Known values: takendown, suspended, deactivated + * @see https://github.com/bluesky-social/atproto/blob/5441fbde9ed3b22463e91481ec80cb095643e141/lexicons/com/atproto/server/getSession.json + */ + status: z.string().optional(), pdsUrl: z.string().optional(), }) export type PersistedAccount = z.infer diff --git a/src/state/session/__tests__/session-test.ts b/src/state/session/__tests__/session-test.ts index ffffd332..48660416 100644 --- a/src/state/session/__tests__/session-test.ts +++ b/src/state/session/__tests__/session-test.ts @@ -28,6 +28,7 @@ describe('session', () => { const agent = new BskyAgent({service: 'https://alice.com'}) agent.session = { + active: true, did: 'alice-did', handle: 'alice.test', accessJwt: 'alice-access-jwt-1', @@ -50,6 +51,7 @@ describe('session', () => { "accounts": [ { "accessJwt": "alice-access-jwt-1", + "active": true, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -88,6 +90,7 @@ describe('session', () => { "accounts": [ { "accessJwt": undefined, + "active": true, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -116,6 +119,7 @@ describe('session', () => { const agent1 = new BskyAgent({service: 'https://alice.com'}) agent1.session = { + active: true, did: 'alice-did', handle: 'alice.test', accessJwt: 'alice-access-jwt-1', @@ -138,6 +142,7 @@ describe('session', () => { "accounts": [ { "accessJwt": "alice-access-jwt-1", + "active": true, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -162,6 +167,7 @@ describe('session', () => { const agent2 = new BskyAgent({service: 'https://bob.com'}) agent2.session = { + active: true, did: 'bob-did', handle: 'bob.test', accessJwt: 'bob-access-jwt-1', @@ -186,6 +192,7 @@ describe('session', () => { "accounts": [ { "accessJwt": "bob-access-jwt-1", + "active": true, "did": "bob-did", "email": undefined, "emailAuthFactor": false, @@ -199,6 +206,7 @@ describe('session', () => { }, { "accessJwt": "alice-access-jwt-1", + "active": true, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -223,6 +231,7 @@ describe('session', () => { const agent3 = new BskyAgent({service: 'https://alice.com'}) agent3.session = { + active: true, did: 'alice-did', handle: 'alice-updated.test', accessJwt: 'alice-access-jwt-2', @@ -247,6 +256,7 @@ describe('session', () => { "accounts": [ { "accessJwt": "alice-access-jwt-2", + "active": true, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -260,6 +270,7 @@ describe('session', () => { }, { "accessJwt": "bob-access-jwt-1", + "active": true, "did": "bob-did", "email": undefined, "emailAuthFactor": false, @@ -284,6 +295,7 @@ describe('session', () => { const agent4 = new BskyAgent({service: 'https://jay.com'}) agent4.session = { + active: true, did: 'jay-did', handle: 'jay.test', accessJwt: 'jay-access-jwt-1', @@ -306,6 +318,7 @@ describe('session', () => { "accounts": [ { "accessJwt": "jay-access-jwt-1", + "active": true, "did": "jay-did", "email": undefined, "emailAuthFactor": false, @@ -319,6 +332,7 @@ describe('session', () => { }, { "accessJwt": "alice-access-jwt-2", + "active": true, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -332,6 +346,7 @@ describe('session', () => { }, { "accessJwt": "bob-access-jwt-1", + "active": true, "did": "bob-did", "email": undefined, "emailAuthFactor": false, @@ -374,6 +389,7 @@ describe('session', () => { "accounts": [ { "accessJwt": undefined, + "active": true, "did": "jay-did", "email": undefined, "emailAuthFactor": false, @@ -387,6 +403,7 @@ describe('session', () => { }, { "accessJwt": undefined, + "active": true, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -400,6 +417,7 @@ describe('session', () => { }, { "accessJwt": undefined, + "active": true, "did": "bob-did", "email": undefined, "emailAuthFactor": false, @@ -428,6 +446,7 @@ describe('session', () => { const agent1 = new BskyAgent({service: 'https://alice.com'}) agent1.session = { + active: true, did: 'alice-did', handle: 'alice.test', accessJwt: 'alice-access-jwt-1', @@ -459,6 +478,7 @@ describe('session', () => { "accounts": [ { "accessJwt": undefined, + "active": true, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -483,6 +503,7 @@ describe('session', () => { const agent2 = new BskyAgent({service: 'https://alice.com'}) agent2.session = { + active: true, did: 'alice-did', handle: 'alice.test', accessJwt: 'alice-access-jwt-2', @@ -504,6 +525,7 @@ describe('session', () => { "accounts": [ { "accessJwt": "alice-access-jwt-2", + "active": true, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -532,6 +554,7 @@ describe('session', () => { const agent1 = new BskyAgent({service: 'https://alice.com'}) agent1.session = { + active: true, did: 'alice-did', handle: 'alice.test', accessJwt: 'alice-access-jwt-1', @@ -576,6 +599,7 @@ describe('session', () => { const agent1 = new BskyAgent({service: 'https://alice.com'}) agent1.session = { + active: true, did: 'alice-did', handle: 'alice.test', accessJwt: 'alice-access-jwt-1', @@ -583,6 +607,7 @@ describe('session', () => { } const agent2 = new BskyAgent({service: 'https://bob.com'}) agent2.session = { + active: true, did: 'bob-did', handle: 'bob.test', accessJwt: 'bob-access-jwt-1', @@ -616,6 +641,7 @@ describe('session', () => { "accounts": [ { "accessJwt": "bob-access-jwt-1", + "active": true, "did": "bob-did", "email": undefined, "emailAuthFactor": false, @@ -653,6 +679,7 @@ describe('session', () => { const agent1 = new BskyAgent({service: 'https://alice.com'}) agent1.session = { + active: true, did: 'alice-did', handle: 'alice.test', accessJwt: 'alice-access-jwt-1', @@ -669,6 +696,7 @@ describe('session', () => { expect(state.currentAgentState.did).toBe('alice-did') agent1.session = { + active: true, did: 'alice-did', handle: 'alice-updated.test', accessJwt: 'alice-access-jwt-2', @@ -697,6 +725,7 @@ describe('session', () => { "accounts": [ { "accessJwt": "alice-access-jwt-2", + "active": true, "did": "alice-did", "email": "alice@foo.bar", "emailAuthFactor": false, @@ -720,6 +749,7 @@ describe('session', () => { `) agent1.session = { + active: true, did: 'alice-did', handle: 'alice-updated.test', accessJwt: 'alice-access-jwt-3', @@ -748,6 +778,7 @@ describe('session', () => { "accounts": [ { "accessJwt": "alice-access-jwt-3", + "active": true, "did": "alice-did", "email": "alice@foo.baz", "emailAuthFactor": true, @@ -771,6 +802,7 @@ describe('session', () => { `) agent1.session = { + active: true, did: 'alice-did', handle: 'alice-updated.test', accessJwt: 'alice-access-jwt-4', @@ -799,6 +831,7 @@ describe('session', () => { "accounts": [ { "accessJwt": "alice-access-jwt-4", + "active": true, "did": "alice-did", "email": "alice@foo.baz", "emailAuthFactor": false, @@ -827,6 +860,7 @@ describe('session', () => { const agent1 = new BskyAgent({service: 'https://alice.com'}) agent1.session = { + active: true, did: 'alice-did', handle: 'alice.test', accessJwt: 'alice-access-jwt-1', @@ -843,6 +877,7 @@ describe('session', () => { expect(state.currentAgentState.did).toBe('alice-did') agent1.session = { + active: true, did: 'alice-did', handle: 'alice-updated.test', accessJwt: 'alice-access-jwt-2', @@ -873,6 +908,7 @@ describe('session', () => { expect(lastState === state).toBe(true) agent1.session = { + active: true, did: 'alice-did', handle: 'alice-updated.test', accessJwt: 'alice-access-jwt-3', @@ -896,6 +932,7 @@ describe('session', () => { const agent1 = new BskyAgent({service: 'https://alice.com'}) agent1.session = { + active: true, did: 'alice-did', handle: 'alice.test', accessJwt: 'alice-access-jwt-1', @@ -904,6 +941,7 @@ describe('session', () => { const agent2 = new BskyAgent({service: 'https://bob.com'}) agent2.session = { + active: true, did: 'bob-did', handle: 'bob.test', accessJwt: 'bob-access-jwt-1', @@ -928,6 +966,7 @@ describe('session', () => { expect(state.currentAgentState.did).toBe('bob-did') agent1.session = { + active: true, did: 'alice-did', handle: 'alice-updated.test', accessJwt: 'alice-access-jwt-2', @@ -956,6 +995,7 @@ describe('session', () => { "accounts": [ { "accessJwt": "bob-access-jwt-1", + "active": true, "did": "bob-did", "email": undefined, "emailAuthFactor": false, @@ -969,6 +1009,7 @@ describe('session', () => { }, { "accessJwt": "alice-access-jwt-2", + "active": true, "did": "alice-did", "email": "alice@foo.bar", "emailAuthFactor": false, @@ -992,6 +1033,7 @@ describe('session', () => { `) agent2.session = { + active: true, did: 'bob-did', handle: 'bob-updated.test', accessJwt: 'bob-access-jwt-2', @@ -1018,6 +1060,7 @@ describe('session', () => { "accounts": [ { "accessJwt": "bob-access-jwt-2", + "active": true, "did": "bob-did", "email": undefined, "emailAuthFactor": false, @@ -1031,6 +1074,7 @@ describe('session', () => { }, { "accessJwt": "alice-access-jwt-2", + "active": true, "did": "alice-did", "email": "alice@foo.bar", "emailAuthFactor": false, @@ -1083,6 +1127,7 @@ describe('session', () => { const agent1 = new BskyAgent({service: 'https://alice.com'}) agent1.session = { + active: true, did: 'alice-did', handle: 'alice.test', accessJwt: 'alice-access-jwt-1', @@ -1091,6 +1136,7 @@ describe('session', () => { const agent2 = new BskyAgent({service: 'https://bob.com'}) agent2.session = { + active: true, did: 'bob-did', handle: 'bob.test', accessJwt: 'bob-access-jwt-1', @@ -1117,6 +1163,7 @@ describe('session', () => { expect(state.currentAgentState.did).toBe('bob-did') agent1.session = { + active: true, did: 'alice-did', handle: 'alice.test', accessJwt: 'alice-access-jwt-2', @@ -1142,6 +1189,7 @@ describe('session', () => { const agent1 = new BskyAgent({service: 'https://alice.com'}) agent1.session = { + active: true, did: 'alice-did', handle: 'alice.test', accessJwt: 'alice-access-jwt-1', @@ -1179,6 +1227,7 @@ describe('session', () => { "accounts": [ { "accessJwt": "alice-access-jwt-1", + "active": true, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -1207,6 +1256,7 @@ describe('session', () => { const agent1 = new BskyAgent({service: 'https://alice.com'}) agent1.session = { + active: true, did: 'alice-did', handle: 'alice.test', accessJwt: 'alice-access-jwt-1', @@ -1242,6 +1292,7 @@ describe('session', () => { "accounts": [ { "accessJwt": undefined, + "active": true, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -1270,6 +1321,7 @@ describe('session', () => { const agent1 = new BskyAgent({service: 'https://alice.com'}) agent1.session = { + active: true, did: 'alice-did', handle: 'alice.test', accessJwt: 'alice-access-jwt-1', @@ -1305,6 +1357,7 @@ describe('session', () => { "accounts": [ { "accessJwt": undefined, + "active": true, "did": "alice-did", "email": undefined, "emailAuthFactor": false, @@ -1333,6 +1386,7 @@ describe('session', () => { const agent1 = new BskyAgent({service: 'https://alice.com'}) agent1.session = { + active: true, did: 'alice-did', handle: 'alice.test', accessJwt: 'alice-access-jwt-1', @@ -1340,6 +1394,7 @@ describe('session', () => { } const agent2 = new BskyAgent({service: 'https://bob.com'}) agent2.session = { + active: true, did: 'bob-did', handle: 'bob.test', accessJwt: 'bob-access-jwt-1', @@ -1362,6 +1417,7 @@ describe('session', () => { const anotherTabAgent1 = new BskyAgent({service: 'https://jay.com'}) anotherTabAgent1.session = { + active: true, did: 'jay-did', handle: 'jay.test', accessJwt: 'jay-access-jwt-1', @@ -1369,6 +1425,7 @@ describe('session', () => { } const anotherTabAgent2 = new BskyAgent({service: 'https://alice.com'}) anotherTabAgent2.session = { + active: true, did: 'bob-did', handle: 'bob.test', accessJwt: 'bob-access-jwt-2', @@ -1397,6 +1454,7 @@ describe('session', () => { "accounts": [ { "accessJwt": "jay-access-jwt-1", + "active": true, "did": "jay-did", "email": undefined, "emailAuthFactor": false, @@ -1410,6 +1468,7 @@ describe('session', () => { }, { "accessJwt": "bob-access-jwt-2", + "active": true, "did": "bob-did", "email": undefined, "emailAuthFactor": false, @@ -1434,6 +1493,7 @@ describe('session', () => { const anotherTabAgent3 = new BskyAgent({service: 'https://clarence.com'}) anotherTabAgent3.session = { + active: true, did: 'clarence-did', handle: 'clarence.test', accessJwt: 'clarence-access-jwt-2', @@ -1457,6 +1517,7 @@ describe('session', () => { "accounts": [ { "accessJwt": "clarence-access-jwt-2", + "active": true, "did": "clarence-did", "email": undefined, "emailAuthFactor": false, diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts index 2b5e85a4..48f5614b 100644 --- a/src/state/session/agent.ts +++ b/src/state/session/agent.ts @@ -46,6 +46,11 @@ export async function createAgentAndResume( emailConfirmed: storedAccount.emailConfirmed, handle: storedAccount.handle, refreshJwt: storedAccount.refreshJwt ?? '', + /** + * @see https://github.com/bluesky-social/atproto/blob/c5d36d5ba2a2c2a5c4f366a5621c06a5608e361e/packages/api/src/agent.ts#L188 + */ + active: storedAccount.active ?? true, + status: storedAccount.status, } if (isSessionExpired(storedAccount)) { await networkRetry(1, () => agent.resumeSession(prevSession)) @@ -235,8 +240,8 @@ export function agentToSessionAccount( refreshJwt: agent.session.refreshJwt, accessJwt: agent.session.accessJwt, signupQueued: isSignupQueued(agent.session.accessJwt), - // @ts-expect-error TODO remove when backend is ready - status: agent.session.status, + active: agent.session.active, + status: agent.session.status as SessionAccount['status'], pdsUrl: agent.pdsUrl?.toString(), } } diff --git a/yarn.lock b/yarn.lock index ae18bfbe..fc29b2a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34,10 +34,10 @@ jsonpointer "^5.0.0" leven "^3.1.0" -"@atproto/api@^0.12.14": - version "0.12.14" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.14.tgz#81252fd166ec8fe950056531e690d563437720fa" - integrity sha512-ZPh/afoRjFEQDQgMZW2FQiG5CDUifY7SxBqI0zVJUwed8Zi6fqYzGYM8fcDvD8yJfflRCqRxUE72g5fKiA1zAQ== +"@atproto/api@^0.12.16": + version "0.12.16" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.16.tgz#f5b5e06d75d379dafe79521d727ed8ad5516d3fc" + integrity sha512-v3lA/m17nkawDXiqgwXyaUSzJPeXJBMH8QKOoYxcDqN+8yG9LFlGe2ecGarXcbGQjYT0GJTAAW3Y/AaCOEwuLg== dependencies: "@atproto/common-web" "^0.3.0" "@atproto/lexicon" "^0.4.0"