about summary refs log tree commit diff
path: root/src/state
diff options
context:
space:
mode:
Diffstat (limited to 'src/state')
-rw-r--r--src/state/dialogs/index.tsx44
-rw-r--r--src/state/modals/index.tsx14
-rw-r--r--src/state/persisted/__tests__/migrate.test.ts6
-rw-r--r--src/state/persisted/legacy.ts5
-rw-r--r--src/state/persisted/schema.ts18
-rw-r--r--src/state/preferences/external-embeds-prefs.tsx54
-rw-r--r--src/state/preferences/in-app-browser.tsx82
-rw-r--r--src/state/preferences/index.tsx12
-rw-r--r--src/state/queries/actor-autocomplete.ts2
-rw-r--r--src/state/queries/app-passwords.ts1
-rw-r--r--src/state/queries/feed.ts3
-rw-r--r--src/state/queries/invites.ts1
-rw-r--r--src/state/queries/list.ts15
-rw-r--r--src/state/queries/notifications/types.ts1
-rw-r--r--src/state/queries/notifications/unread.tsx37
-rw-r--r--src/state/queries/post-feed.ts11
-rw-r--r--src/state/queries/preferences/index.ts2
-rw-r--r--src/state/queries/profile.ts2
-rw-r--r--src/state/session/index.tsx150
-rw-r--r--src/state/shell/composer.tsx5
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