diff options
Diffstat (limited to 'src/state')
-rw-r--r-- | src/state/dialogs/index.tsx | 44 | ||||
-rw-r--r-- | src/state/modals/index.tsx | 14 | ||||
-rw-r--r-- | src/state/persisted/__tests__/migrate.test.ts | 6 | ||||
-rw-r--r-- | src/state/persisted/legacy.ts | 5 | ||||
-rw-r--r-- | src/state/persisted/schema.ts | 18 | ||||
-rw-r--r-- | src/state/preferences/external-embeds-prefs.tsx | 54 | ||||
-rw-r--r-- | src/state/preferences/in-app-browser.tsx | 82 | ||||
-rw-r--r-- | src/state/preferences/index.tsx | 12 | ||||
-rw-r--r-- | src/state/queries/actor-autocomplete.ts | 2 | ||||
-rw-r--r-- | src/state/queries/app-passwords.ts | 1 | ||||
-rw-r--r-- | src/state/queries/feed.ts | 3 | ||||
-rw-r--r-- | src/state/queries/invites.ts | 1 | ||||
-rw-r--r-- | src/state/queries/list.ts | 15 | ||||
-rw-r--r-- | src/state/queries/notifications/types.ts | 1 | ||||
-rw-r--r-- | src/state/queries/notifications/unread.tsx | 37 | ||||
-rw-r--r-- | src/state/queries/post-feed.ts | 11 | ||||
-rw-r--r-- | src/state/queries/preferences/index.ts | 2 | ||||
-rw-r--r-- | src/state/queries/profile.ts | 2 | ||||
-rw-r--r-- | src/state/session/index.tsx | 150 | ||||
-rw-r--r-- | src/state/shell/composer.tsx | 5 |
20 files changed, 396 insertions, 69 deletions
diff --git a/src/state/dialogs/index.tsx b/src/state/dialogs/index.tsx new file mode 100644 index 000000000..4cafaa086 --- /dev/null +++ b/src/state/dialogs/index.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import {DialogControlProps} from '#/components/Dialog' + +const DialogContext = React.createContext<{ + activeDialogs: React.MutableRefObject< + Map<string, React.MutableRefObject<DialogControlProps>> + > +}>({ + activeDialogs: { + current: new Map(), + }, +}) + +const DialogControlContext = React.createContext<{ + closeAllDialogs(): void +}>({ + closeAllDialogs: () => {}, +}) + +export function useDialogStateContext() { + return React.useContext(DialogContext) +} + +export function useDialogStateControlContext() { + return React.useContext(DialogControlContext) +} + +export function Provider({children}: React.PropsWithChildren<{}>) { + const activeDialogs = React.useRef< + Map<string, React.MutableRefObject<DialogControlProps>> + >(new Map()) + const closeAllDialogs = React.useCallback(() => { + activeDialogs.current.forEach(dialog => dialog.current.close()) + }, []) + const context = React.useMemo(() => ({activeDialogs}), []) + const controls = React.useMemo(() => ({closeAllDialogs}), [closeAllDialogs]) + return ( + <DialogContext.Provider value={context}> + <DialogControlContext.Provider value={controls}> + {children} + </DialogControlContext.Provider> + </DialogContext.Provider> + ) +} diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index 81a220d1b..45856e108 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -6,6 +6,7 @@ import {Image as RNImage} from 'react-native-image-crop-picker' import {ImageModel} from '#/state/models/media/image' import {GalleryModel} from '#/state/models/media/gallery' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {EmbedPlayerSource} from '#/lib/strings/embed-player.ts' import {ThreadgateSetting} from '../queries/threadgate' export interface ConfirmModal { @@ -180,6 +181,17 @@ export interface LinkWarningModal { href: string } +export interface EmbedConsentModal { + name: 'embed-consent' + source: EmbedPlayerSource + onAccept: () => void +} + +export interface InAppBrowserConsentModal { + name: 'in-app-browser-consent' + href: string +} + export type Modal = // Account | AddAppPasswordModal @@ -223,6 +235,8 @@ export type Modal = // Generic | ConfirmModal | LinkWarningModal + | EmbedConsentModal + | InAppBrowserConsentModal const ModalContext = React.createContext<{ isModalActive: boolean diff --git a/src/state/persisted/__tests__/migrate.test.ts b/src/state/persisted/__tests__/migrate.test.ts index d42580efd..2435ed24f 100644 --- a/src/state/persisted/__tests__/migrate.test.ts +++ b/src/state/persisted/__tests__/migrate.test.ts @@ -26,7 +26,7 @@ test('migrate: fresh install', async () => { expect(AsyncStorage.getItem).toHaveBeenCalledWith('root') expect(read).toHaveBeenCalledTimes(1) - expect(logger.log).toHaveBeenCalledWith( + expect(logger.info).toHaveBeenCalledWith( 'persisted state: no migration needed', ) }) @@ -38,7 +38,7 @@ test('migrate: fresh install, existing new storage', async () => { expect(AsyncStorage.getItem).toHaveBeenCalledWith('root') expect(read).toHaveBeenCalledTimes(1) - expect(logger.log).toHaveBeenCalledWith( + expect(logger.info).toHaveBeenCalledWith( 'persisted state: no migration needed', ) }) @@ -68,7 +68,7 @@ test('migrate: has legacy data', async () => { await migrate() expect(write).toHaveBeenCalledWith(transform(fixtures.LEGACY_DATA_DUMP)) - expect(logger.log).toHaveBeenCalledWith( + expect(logger.info).toHaveBeenCalledWith( 'persisted state: migrated legacy storage', ) }) diff --git a/src/state/persisted/legacy.ts b/src/state/persisted/legacy.ts index cdb542f5a..097d6bc5c 100644 --- a/src/state/persisted/legacy.ts +++ b/src/state/persisted/legacy.ts @@ -109,6 +109,7 @@ export function transform(legacy: Partial<LegacySchema>): Schema { step: legacy.onboarding?.step || defaults.onboarding.step, }, hiddenPosts: defaults.hiddenPosts, + externalEmbeds: defaults.externalEmbeds, } } @@ -163,14 +164,14 @@ export async function migrate() { if (validate.success) { await write(newData) - logger.log('persisted state: migrated legacy storage') + logger.info('persisted state: migrated legacy storage') } else { logger.error('persisted state: legacy data failed validation', { error: validate.error, }) } } else { - logger.log('persisted state: no migration needed') + logger.info('persisted state: no migration needed') } } catch (e: any) { logger.error(e, { diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index 27b1f26bd..a6f2ea06a 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -1,6 +1,8 @@ import {z} from 'zod' import {deviceLocales} from '#/platform/detection' +const externalEmbedOptions = ['show', 'hide'] as const + // only data needed for rendering account page const accountSchema = z.object({ service: z.string(), @@ -30,6 +32,19 @@ export const schema = z.object({ appLanguage: z.string(), }), requireAltTextEnabled: z.boolean(), // should move to server + externalEmbeds: z + .object({ + giphy: z.enum(externalEmbedOptions).optional(), + tenor: z.enum(externalEmbedOptions).optional(), + youtube: z.enum(externalEmbedOptions).optional(), + youtubeShorts: z.enum(externalEmbedOptions).optional(), + twitch: z.enum(externalEmbedOptions).optional(), + vimeo: z.enum(externalEmbedOptions).optional(), + spotify: z.enum(externalEmbedOptions).optional(), + appleMusic: z.enum(externalEmbedOptions).optional(), + soundcloud: z.enum(externalEmbedOptions).optional(), + }) + .optional(), mutedThreads: z.array(z.string()), // should move to server invites: z.object({ copiedInvites: z.array(z.string()), @@ -38,6 +53,7 @@ export const schema = z.object({ step: z.string(), }), hiddenPosts: z.array(z.string()).optional(), // should move to server + useInAppBrowser: z.boolean().optional(), }) export type Schema = z.infer<typeof schema> @@ -60,6 +76,7 @@ export const defaults: Schema = { appLanguage: deviceLocales[0] || 'en', }, requireAltTextEnabled: false, + externalEmbeds: {}, mutedThreads: [], invites: { copiedInvites: [], @@ -68,4 +85,5 @@ export const defaults: Schema = { step: 'Home', }, hiddenPosts: [], + useInAppBrowser: undefined, } diff --git a/src/state/preferences/external-embeds-prefs.tsx b/src/state/preferences/external-embeds-prefs.tsx new file mode 100644 index 000000000..0f6385fe8 --- /dev/null +++ b/src/state/preferences/external-embeds-prefs.tsx @@ -0,0 +1,54 @@ +import React from 'react' +import * as persisted from '#/state/persisted' +import {EmbedPlayerSource} from 'lib/strings/embed-player' + +type StateContext = persisted.Schema['externalEmbeds'] +type SetContext = (source: EmbedPlayerSource, value: 'show' | 'hide') => void + +const stateContext = React.createContext<StateContext>( + persisted.defaults.externalEmbeds, +) +const setContext = React.createContext<SetContext>({} as SetContext) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(persisted.get('externalEmbeds')) + + const setStateWrapped = React.useCallback( + (source: EmbedPlayerSource, value: 'show' | 'hide') => { + setState(prev => { + persisted.write('externalEmbeds', { + ...prev, + [source]: value, + }) + + return { + ...prev, + [source]: value, + } + }) + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate(() => { + setState(persisted.get('externalEmbeds')) + }) + }, [setStateWrapped]) + + return ( + <stateContext.Provider value={state}> + <setContext.Provider value={setStateWrapped}> + {children} + </setContext.Provider> + </stateContext.Provider> + ) +} + +export function useExternalEmbedsPrefs() { + return React.useContext(stateContext) +} + +export function useSetExternalEmbedPref() { + return React.useContext(setContext) +} diff --git a/src/state/preferences/in-app-browser.tsx b/src/state/preferences/in-app-browser.tsx new file mode 100644 index 000000000..4f033db65 --- /dev/null +++ b/src/state/preferences/in-app-browser.tsx @@ -0,0 +1,82 @@ +import React from 'react' +import * as persisted from '#/state/persisted' +import {Linking} from 'react-native' +import * as WebBrowser from 'expo-web-browser' +import {isNative} from '#/platform/detection' +import {useModalControls} from '../modals' +import {usePalette} from 'lib/hooks/usePalette' + +type StateContext = persisted.Schema['useInAppBrowser'] +type SetContext = (v: persisted.Schema['useInAppBrowser']) => void + +const stateContext = React.createContext<StateContext>( + persisted.defaults.useInAppBrowser, +) +const setContext = React.createContext<SetContext>( + (_: persisted.Schema['useInAppBrowser']) => {}, +) + +export function Provider({children}: React.PropsWithChildren<{}>) { + const [state, setState] = React.useState(persisted.get('useInAppBrowser')) + + const setStateWrapped = React.useCallback( + (inAppBrowser: persisted.Schema['useInAppBrowser']) => { + setState(inAppBrowser) + persisted.write('useInAppBrowser', inAppBrowser) + }, + [setState], + ) + + React.useEffect(() => { + return persisted.onUpdate(() => { + setState(persisted.get('useInAppBrowser')) + }) + }, [setStateWrapped]) + + return ( + <stateContext.Provider value={state}> + <setContext.Provider value={setStateWrapped}> + {children} + </setContext.Provider> + </stateContext.Provider> + ) +} + +export function useInAppBrowser() { + return React.useContext(stateContext) +} + +export function useSetInAppBrowser() { + return React.useContext(setContext) +} + +export function useOpenLink() { + const {openModal} = useModalControls() + const enabled = useInAppBrowser() + const pal = usePalette('default') + + const openLink = React.useCallback( + (url: string, override?: boolean) => { + if (isNative && !url.startsWith('mailto:')) { + if (override === undefined && enabled === undefined) { + openModal({ + name: 'in-app-browser-consent', + href: url, + }) + return + } else if (override ?? enabled) { + WebBrowser.openBrowserAsync(url, { + presentationStyle: + WebBrowser.WebBrowserPresentationStyle.FULL_SCREEN, + toolbarColor: pal.colors.backgroundLight, + }) + return + } + } + Linking.openURL(url) + }, + [enabled, openModal, pal.colors.backgroundLight], + ) + + return openLink +} diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx index 5ec659031..a442b763a 100644 --- a/src/state/preferences/index.tsx +++ b/src/state/preferences/index.tsx @@ -2,19 +2,29 @@ import React from 'react' import {Provider as LanguagesProvider} from './languages' import {Provider as AltTextRequiredProvider} from '../preferences/alt-text-required' import {Provider as HiddenPostsProvider} from '../preferences/hidden-posts' +import {Provider as ExternalEmbedsProvider} from './external-embeds-prefs' +import {Provider as InAppBrowserProvider} from './in-app-browser' export {useLanguagePrefs, useLanguagePrefsApi} from './languages' export { useRequireAltTextEnabled, useSetRequireAltTextEnabled, } from './alt-text-required' +export { + useExternalEmbedsPrefs, + useSetExternalEmbedPref, +} from './external-embeds-prefs' export * from './hidden-posts' export function Provider({children}: React.PropsWithChildren<{}>) { return ( <LanguagesProvider> <AltTextRequiredProvider> - <HiddenPostsProvider>{children}</HiddenPostsProvider> + <ExternalEmbedsProvider> + <HiddenPostsProvider> + <InAppBrowserProvider>{children}</InAppBrowserProvider> + </HiddenPostsProvider> + </ExternalEmbedsProvider> </AltTextRequiredProvider> </LanguagesProvider> ) diff --git a/src/state/queries/actor-autocomplete.ts b/src/state/queries/actor-autocomplete.ts index 785e29765..ba9f97004 100644 --- a/src/state/queries/actor-autocomplete.ts +++ b/src/state/queries/actor-autocomplete.ts @@ -24,6 +24,8 @@ export function useActorAutocompleteQuery(prefix: string) { const {data: follows, isFetching} = useMyFollowsQuery() const moderationOpts = useModerationOpts() + prefix = prefix.toLowerCase() + return useQuery<AppBskyActorDefs.ProfileViewBasic[]>({ staleTime: STALE.MINUTES.ONE, queryKey: RQKEY(prefix || ''), diff --git a/src/state/queries/app-passwords.ts b/src/state/queries/app-passwords.ts index 4b9e09a8d..014244f01 100644 --- a/src/state/queries/app-passwords.ts +++ b/src/state/queries/app-passwords.ts @@ -9,7 +9,6 @@ export const RQKEY = () => ['app-passwords'] export function useAppPasswordsQuery() { return useQuery({ staleTime: STALE.MINUTES.FIVE, - refetchInterval: STALE.MINUTES.ONE, queryKey: RQKEY(), queryFn: async () => { const res = await getAgent().com.atproto.server.listAppPasswords({}) diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts index 7a55b4e18..4acc7179a 100644 --- a/src/state/queries/feed.ts +++ b/src/state/queries/feed.ts @@ -272,7 +272,8 @@ export function usePinnedFeedsInfos(): { }, }) } catch (e) { - logger.warn(`usePinnedFeedsInfos: failed to fetch ${uri}`, { + // expected failure + logger.info(`usePinnedFeedsInfos: failed to fetch ${uri}`, { error: e, }) } diff --git a/src/state/queries/invites.ts b/src/state/queries/invites.ts index bfea402e1..9ae9c707f 100644 --- a/src/state/queries/invites.ts +++ b/src/state/queries/invites.ts @@ -16,7 +16,6 @@ export type InviteCodesQueryResponse = Exclude< export function useInviteCodesQuery() { return useQuery({ staleTime: STALE.MINUTES.FIVE, - refetchInterval: STALE.MINUTES.FIVE, queryKey: ['inviteCodes'], queryFn: async () => { const res = await getAgent() diff --git a/src/state/queries/list.ts b/src/state/queries/list.ts index 013a69076..845658a27 100644 --- a/src/state/queries/list.ts +++ b/src/state/queries/list.ts @@ -3,6 +3,7 @@ import { AppBskyGraphGetList, AppBskyGraphList, AppBskyGraphDefs, + Facet, } from '@atproto/api' import {Image as RNImage} from 'react-native-image-crop-picker' import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' @@ -38,6 +39,7 @@ export interface ListCreateMutateParams { purpose: string name: string description: string + descriptionFacets: Facet[] | undefined avatar: RNImage | null | undefined } export function useListCreateMutation() { @@ -45,7 +47,13 @@ export function useListCreateMutation() { const queryClient = useQueryClient() return useMutation<{uri: string; cid: string}, Error, ListCreateMutateParams>( { - async mutationFn({purpose, name, description, avatar}) { + async mutationFn({ + purpose, + name, + description, + descriptionFacets, + avatar, + }) { if (!currentAccount) { throw new Error('Not logged in') } @@ -59,6 +67,7 @@ export function useListCreateMutation() { purpose, name, description, + descriptionFacets, avatar: undefined, createdAt: new Date().toISOString(), } @@ -93,6 +102,7 @@ export interface ListMetadataMutateParams { uri: string name: string description: string + descriptionFacets: Facet[] | undefined avatar: RNImage | null | undefined } export function useListMetadataMutation() { @@ -103,7 +113,7 @@ export function useListMetadataMutation() { Error, ListMetadataMutateParams >({ - async mutationFn({uri, name, description, avatar}) { + async mutationFn({uri, name, description, descriptionFacets, avatar}) { const {hostname, rkey} = new AtUri(uri) if (!currentAccount) { throw new Error('Not logged in') @@ -121,6 +131,7 @@ export function useListMetadataMutation() { // update the fields record.name = name record.description = description + record.descriptionFacets = descriptionFacets if (avatar) { const blobRes = await uploadBlob(getAgent(), avatar.path, avatar.mime) record.avatar = blobRes.data.blob diff --git a/src/state/queries/notifications/types.ts b/src/state/queries/notifications/types.ts index 86a9bb139..812236cf0 100644 --- a/src/state/queries/notifications/types.ts +++ b/src/state/queries/notifications/types.ts @@ -35,4 +35,5 @@ export interface CachedFeedPage { usableInFeed: boolean syncedAt: Date data: FeedPage | undefined + unreadCount: number } diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx index abaabbf0e..a96b56225 100644 --- a/src/state/queries/notifications/unread.tsx +++ b/src/state/queries/notifications/unread.tsx @@ -15,6 +15,7 @@ import {useMutedThreads} from '#/state/muted-threads' import {RQKEY as RQKEY_NOTIFS} from './feed' import {logger} from '#/logger' import {truncateAndInvalidate} from '../util' +import {AppState} from 'react-native' const UPDATE_INTERVAL = 30 * 1e3 // 30sec @@ -24,7 +25,10 @@ type StateContext = string interface ApiContext { markAllRead: () => Promise<void> - checkUnread: (opts?: {invalidate?: boolean}) => Promise<void> + checkUnread: (opts?: { + invalidate?: boolean + isPoll?: boolean + }) => Promise<void> getCachedUnreadPage: () => FeedPage | undefined } @@ -49,6 +53,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { usableInFeed: false, syncedAt: new Date(), data: undefined, + unreadCount: 0, }) // periodic sync @@ -57,7 +62,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) { return } checkUnreadRef.current() // fire on init - const interval = setInterval(checkUnreadRef.current, UPDATE_INTERVAL) + const interval = setInterval( + () => checkUnreadRef.current?.({isPoll: true}), + UPDATE_INTERVAL, + ) return () => clearInterval(interval) }, [hasSession]) @@ -68,6 +76,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) { usableInFeed: false, syncedAt: new Date(), data: undefined, + unreadCount: + data.event === '30+' + ? 30 + : data.event === '' + ? 0 + : parseInt(data.event, 10) || 1, } setNumUnread(data.event) } @@ -94,9 +108,23 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } }, - async checkUnread({invalidate}: {invalidate?: boolean} = {}) { + async checkUnread({ + invalidate, + isPoll, + }: {invalidate?: boolean; isPoll?: boolean} = {}) { try { if (!getAgent().session) return + if (AppState.currentState !== 'active') { + return + } + + // reduce polling if unread count is set + if (isPoll && cacheRef.current?.unreadCount !== 0) { + // if hit 30+ then don't poll, otherwise reduce polling by 50% + if (cacheRef.current?.unreadCount >= 30 || Math.random() >= 0.5) { + return + } + } // count const page = await fetchPage({ @@ -129,6 +157,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { usableInFeed: !!invalidate, // will be used immediately data: page, syncedAt: !lastIndexed || now > lastIndexed ? now : lastIndexed, + unreadCount, } // update & broadcast @@ -138,7 +167,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } broadcast.postMessage({event: unreadCountStr}) } catch (e) { - logger.error('Failed to check unread notifications', {error: e}) + logger.warn('Failed to check unread notifications', {error: e}) } }, diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 0e943622a..82acf3974 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -1,4 +1,5 @@ import React, {useCallback, useEffect, useRef} from 'react' +import {AppState} from 'react-native' import {AppBskyFeedDefs, AppBskyFeedPost, PostModeration} from '@atproto/api' import { useInfiniteQuery, @@ -17,6 +18,7 @@ import {LikesFeedAPI} from 'lib/api/feed/likes' import {CustomFeedAPI} from 'lib/api/feed/custom' import {ListFeedAPI} from 'lib/api/feed/list' import {MergeFeedAPI} from 'lib/api/feed/merge' +import {HomeFeedAPI} from '#/lib/api/feed/home' import {logger} from '#/logger' import {STALE} from '#/state/queries' import {precacheFeedPosts as precacheResolvedUris} from './resolve-uri' @@ -312,6 +314,9 @@ export async function pollLatest(page: FeedPage | undefined) { if (!page) { return false } + if (AppState.currentState !== 'active') { + return + } logger.debug('usePostFeedQuery: pollLatest') const post = await page.api.peekLatest() @@ -334,7 +339,11 @@ function createApi( feedTuners: FeedTunerFn[], ) { if (feedDesc === 'home') { - return new MergeFeedAPI(params, feedTuners) + if (params.mergeFeedEnabled) { + return new MergeFeedAPI(params, feedTuners) + } else { + return new HomeFeedAPI() + } } else if (feedDesc === 'following') { return new FollowingFeedAPI() } else if (feedDesc.startsWith('author')) { diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index a9aa7f26e..632d31a13 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -31,7 +31,7 @@ export function usePreferencesQuery() { return useQuery({ staleTime: STALE.SECONDS.FIFTEEN, structuralSharing: true, - refetchInterval: STALE.SECONDS.FIFTEEN, + refetchOnWindowFocus: true, queryKey: preferencesQueryKey, queryFn: async () => { const agent = getAgent() diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index 40ba0653c..21e2e5775 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -35,7 +35,7 @@ export function useProfileQuery({did}: {did: string | undefined}) { // if you remove it, the UI infinite-loops // -prf staleTime: isCurrentAccount ? STALE.SECONDS.THIRTY : STALE.MINUTES.FIVE, - refetchInterval: STALE.MINUTES.FIVE, + refetchOnWindowFocus: true, queryKey: RQKEY(did || ''), queryFn: async () => { const res = await getAgent().getProfile({actor: did || ''}) diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index aa8c94ebc..e49bc2b39 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -44,6 +44,8 @@ export type ApiContext = { password: string handle: string inviteCode?: string + verificationPhone?: string + verificationCode?: string }) => Promise<void> login: (props: { service: string @@ -102,10 +104,21 @@ function createPersistSessionHandler( expired: boolean refreshedAccount: SessionAccount }) => void, + { + networkErrorCallback, + }: { + networkErrorCallback?: () => void + } = {}, ): AtpPersistSessionHandler { return function persistSession(event, session) { const expired = event === 'expired' || event === 'create-failed' + if (event === 'network-error') { + logger.warn(`session: persistSessionHandler received network-error event`) + networkErrorCallback?.() + return + } + const refreshedAccount: SessionAccount = { service: account.service, did: session?.did || account.did, @@ -125,9 +138,11 @@ function createPersistSessionHandler( event, did: refreshedAccount.did, handle: refreshedAccount.handle, + service: refreshedAccount.service, }) if (expired) { + logger.warn(`session: expired`) emitSessionDropped() } @@ -179,16 +194,30 @@ export function Provider({children}: React.PropsWithChildren<{}>) { [setStateAndPersist], ) + const clearCurrentAccount = React.useCallback(() => { + logger.warn(`session: clear current account`) + __globalAgent = PUBLIC_BSKY_AGENT + queryClient.clear() + setStateAndPersist(s => ({ + ...s, + currentAccount: undefined, + })) + }, [setStateAndPersist, queryClient]) + const createAccount = React.useCallback<ApiContext['createAccount']>( - async ({service, email, password, handle, inviteCode}: any) => { - logger.debug( - `session: creating account`, - { - service, - handle, - }, - logger.DebugContext.session, - ) + async ({ + service, + email, + password, + handle, + inviteCode, + verificationPhone, + verificationCode, + }: any) => { + logger.info(`session: creating account`, { + service, + handle, + }) track('Try Create Account') const agent = new BskyAgent({service}) @@ -198,12 +227,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) { password, email, inviteCode, + verificationPhone, + verificationCode, }) if (!agent.session) { throw new Error(`session: createAccount failed to establish a session`) } + /*dont await*/ agent.upsertProfile(_existing => { + return { + displayName: handle, + } + }) + const account: SessionAccount = { service: agent.service.toString(), did: agent.session.did, @@ -215,9 +252,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } agent.setPersistSessionHandler( - createPersistSessionHandler(account, ({expired, refreshedAccount}) => { - upsertAccount(refreshedAccount, expired) - }), + createPersistSessionHandler( + account, + ({expired, refreshedAccount}) => { + upsertAccount(refreshedAccount, expired) + }, + {networkErrorCallback: clearCurrentAccount}, + ), ) __globalAgent = agent @@ -234,7 +275,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ) track('Create Account') }, - [upsertAccount, queryClient], + [upsertAccount, queryClient, clearCurrentAccount], ) const login = React.useCallback<ApiContext['login']>( @@ -267,9 +308,13 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } agent.setPersistSessionHandler( - createPersistSessionHandler(account, ({expired, refreshedAccount}) => { - upsertAccount(refreshedAccount, expired) - }), + createPersistSessionHandler( + account, + ({expired, refreshedAccount}) => { + upsertAccount(refreshedAccount, expired) + }, + {networkErrorCallback: clearCurrentAccount}, + ), ) __globalAgent = agent @@ -287,26 +332,12 @@ export function Provider({children}: React.PropsWithChildren<{}>) { track('Sign In', {resumedSession: false}) }, - [upsertAccount, queryClient], + [upsertAccount, queryClient, clearCurrentAccount], ) - const clearCurrentAccount = React.useCallback(() => { - logger.debug( - `session: clear current account`, - {}, - logger.DebugContext.session, - ) - __globalAgent = PUBLIC_BSKY_AGENT - queryClient.clear() - setStateAndPersist(s => ({ - ...s, - currentAccount: undefined, - })) - }, [setStateAndPersist, queryClient]) - const logout = React.useCallback<ApiContext['logout']>(async () => { + logger.info(`session: logout`) clearCurrentAccount() - logger.debug(`session: logout`, {}, logger.DebugContext.session) setStateAndPersist(s => { return { ...s, @@ -337,6 +368,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { ({expired, refreshedAccount}) => { upsertAccount(refreshedAccount, expired) }, + {networkErrorCallback: clearCurrentAccount}, ), }) @@ -437,7 +469,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } } }, - [upsertAccount, queryClient], + [upsertAccount, queryClient, clearCurrentAccount], ) const resumeSession = React.useCallback<ApiContext['resumeSession']>( @@ -533,30 +565,36 @@ export function Provider({children}: React.PropsWithChildren<{}>) { return persisted.onUpdate(() => { const session = persisted.get('session') - logger.debug(`session: onUpdate`, {}, logger.DebugContext.session) + logger.info(`session: persisted onUpdate`, {}) - if (session.currentAccount) { + if (session.currentAccount && session.currentAccount.refreshJwt) { if (session.currentAccount?.did !== state.currentAccount?.did) { - logger.debug( - `session: switching account`, - { - from: { - did: state.currentAccount?.did, - handle: state.currentAccount?.handle, - }, - to: { - did: session.currentAccount.did, - handle: session.currentAccount.handle, - }, + logger.info(`session: persisted onUpdate, switching accounts`, { + from: { + did: state.currentAccount?.did, + handle: state.currentAccount?.handle, }, - logger.DebugContext.session, - ) + to: { + did: session.currentAccount.did, + handle: session.currentAccount.handle, + }, + }) initSession(session.currentAccount) + } else { + logger.info(`session: persisted onUpdate, updating session`, {}) + + /* + * Use updated session in this tab's agent. Do not call + * upsertAccount, since that will only persist the session that's + * already persisted, and we'll get a loop between tabs. + */ + // @ts-ignore we checked for `refreshJwt` above + __globalAgent.session = session.currentAccount } } else if (!session.currentAccount && state.currentAccount) { logger.debug( - `session: logging out`, + `session: persisted onUpdate, logging out`, { did: state.currentAccount?.did, handle: state.currentAccount?.handle, @@ -564,10 +602,22 @@ export function Provider({children}: React.PropsWithChildren<{}>) { logger.DebugContext.session, ) + /* + * No need to do a hard logout here. If we reach this, tokens for this + * account have already been cleared either by an `expired` event + * handled by `persistSession` (which nukes this accounts tokens only), + * or by a `logout` call which nukes all accounts tokens) + */ clearCurrentAccount() } + + setState(s => ({ + ...s, + accounts: session.accounts, + currentAccount: session.currentAccount, + })) }) - }, [state, clearCurrentAccount, initSession]) + }, [state, setState, clearCurrentAccount, initSession]) const stateContext = React.useMemo( () => ({ diff --git a/src/state/shell/composer.tsx b/src/state/shell/composer.tsx index bdf5e4a7a..2133ee6b0 100644 --- a/src/state/shell/composer.tsx +++ b/src/state/shell/composer.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {AppBskyEmbedRecord} from '@atproto/api' +import {AppBskyEmbedRecord, AppBskyRichtextFacet} from '@atproto/api' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' export interface ComposerOptsPostRef { @@ -11,11 +11,13 @@ export interface ComposerOptsPostRef { displayName?: string avatar?: string } + embed?: AppBskyEmbedRecord.ViewRecord['embed'] } export interface ComposerOptsQuote { uri: string cid: string text: string + facets?: AppBskyRichtextFacet.Main[] indexedAt: string author: { did: string @@ -30,6 +32,7 @@ export interface ComposerOpts { onPost?: () => void quote?: ComposerOptsQuote mention?: string // handle of user to mention + openPicker?: (pos: DOMRect | undefined) => void } type StateContext = ComposerOpts | undefined |