about summary refs log tree commit diff
path: root/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/api/index.ts6
-rw-r--r--src/lib/constants.ts103
-rw-r--r--src/lib/hooks/useDedupe.ts17
-rw-r--r--src/lib/hooks/useInitialNumToRender.ts11
-rw-r--r--src/lib/hooks/useIntentHandler.ts93
-rw-r--r--src/lib/hooks/useNavigationDeduped.ts80
-rw-r--r--src/lib/hooks/useOTAUpdate.ts52
-rw-r--r--src/lib/link-meta/link-meta.ts2
-rw-r--r--src/lib/media/picker.shared.ts21
-rw-r--r--src/lib/moderatePost_wrapped.ts62
-rw-r--r--src/lib/moderation.ts189
-rw-r--r--src/lib/moderation/useGlobalLabelStrings.ts52
-rw-r--r--src/lib/moderation/useLabelBehaviorDescription.ts70
-rw-r--r--src/lib/moderation/useLabelInfo.ts100
-rw-r--r--src/lib/moderation/useModerationCauseDescription.ts146
-rw-r--r--src/lib/moderation/useReportOptions.ts94
-rw-r--r--src/lib/react-query.ts20
-rw-r--r--src/lib/routes/links.ts10
-rw-r--r--src/lib/routes/router.ts10
-rw-r--r--src/lib/routes/types.ts8
-rw-r--r--src/lib/sharing.ts4
-rw-r--r--src/lib/statsig/events.ts47
-rw-r--r--src/lib/statsig/statsig.tsx94
-rw-r--r--src/lib/strings/display-names.ts3
-rw-r--r--src/lib/strings/embed-player.ts41
-rw-r--r--src/lib/strings/handles.ts29
-rw-r--r--src/lib/strings/helpers.ts21
-rw-r--r--src/lib/strings/time.ts2
-rw-r--r--src/lib/strings/url-helpers.ts70
-rw-r--r--src/lib/themes.ts6
30 files changed, 1111 insertions, 352 deletions
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index 440dfa5ee..5fb7fe50e 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -104,18 +104,18 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
 
   // add image embed if present
   if (opts.images?.length) {
-    logger.info(`Uploading images`, {
+    logger.debug(`Uploading images`, {
       count: opts.images.length,
     })
 
     const images: AppBskyEmbedImages.Image[] = []
     for (const image of opts.images) {
       opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
-      logger.info(`Compressing image`)
+      logger.debug(`Compressing image`)
       await image.compress()
       const path = image.compressed?.path ?? image.path
       const {width, height} = image.compressed || image
-      logger.info(`Uploading image`)
+      logger.debug(`Uploading image`)
       const res = await uploadBlob(agent, path, 'image/jpeg')
       images.push({
         image: res.data.blob,
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index aec8338d0..f5a72669a 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -3,9 +3,8 @@ import {Insets, Platform} from 'react-native'
 export const LOCAL_DEV_SERVICE =
   Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
 export const STAGING_SERVICE = 'https://staging.bsky.dev'
-export const PROD_SERVICE = 'https://bsky.social'
-export const DEFAULT_SERVICE = PROD_SERVICE
-
+export const BSKY_SERVICE = 'https://bsky.social'
+export const DEFAULT_SERVICE = BSKY_SERVICE
 const HELP_DESK_LANG = 'en-us'
 export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
 
@@ -36,92 +35,16 @@ export const MAX_GRAPHEME_LENGTH = 300
 // but increasing limit per user feedback
 export const MAX_ALT_TEXT = 1000
 
-export function IS_LOCAL_DEV(url: string) {
-  return url.includes('localhost')
-}
-
-export function IS_STAGING(url: string) {
-  return url.startsWith('https://staging.bsky.dev')
-}
-
-export function IS_PROD(url: string) {
-  // NOTE
-  // until open federation, "production" is defined as the main server
-  // this definition will not work once federation is enabled!
-  // -prf
-  return (
-    url.startsWith('https://bsky.social') ||
-    url.startsWith('https://api.bsky.app') ||
-    /bsky\.network\/?$/.test(url)
-  )
+export function IS_TEST_USER(handle?: string) {
+  return handle && handle?.endsWith('.test')
 }
 
-export const PROD_TEAM_HANDLES = [
-  'jay.bsky.social',
-  'pfrazee.com',
-  'divy.zone',
-  'dholms.xyz',
-  'why.bsky.world',
-  'iamrosewang.bsky.social',
-]
-export const STAGING_TEAM_HANDLES = [
-  'arcalinea.staging.bsky.dev',
-  'paul.staging.bsky.dev',
-  'paul2.staging.bsky.dev',
-]
-export const DEV_TEAM_HANDLES = ['alice.test', 'bob.test', 'carla.test']
-
-export function TEAM_HANDLES(serviceUrl: string) {
-  if (serviceUrl.includes('localhost')) {
-    return DEV_TEAM_HANDLES
-  } else if (serviceUrl.includes('staging')) {
-    return STAGING_TEAM_HANDLES
-  } else {
-    return PROD_TEAM_HANDLES
-  }
+export function IS_PROD_SERVICE(url?: string) {
+  return url && url !== STAGING_SERVICE && url !== LOCAL_DEV_SERVICE
 }
 
-export const STAGING_DEFAULT_FEED = (rkey: string) =>
-  `at://did:plc:wqzurwm3kmaig6e6hnc2gqwo/app.bsky.feed.generator/${rkey}`
 export const PROD_DEFAULT_FEED = (rkey: string) =>
   `at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/${rkey}`
-export async function DEFAULT_FEEDS(
-  serviceUrl: string,
-  resolveHandle: (name: string) => Promise<string>,
-) {
-  // TODO: remove this when the test suite no longer relies on it
-  if (IS_LOCAL_DEV(serviceUrl)) {
-    // local dev
-    const aliceDid = await resolveHandle('alice.test')
-    return {
-      pinned: [
-        `at://${aliceDid}/app.bsky.feed.generator/alice-favs`,
-        `at://${aliceDid}/app.bsky.feed.generator/alice-favs2`,
-      ],
-      saved: [
-        `at://${aliceDid}/app.bsky.feed.generator/alice-favs`,
-        `at://${aliceDid}/app.bsky.feed.generator/alice-favs2`,
-      ],
-    }
-  } else if (IS_STAGING(serviceUrl)) {
-    // staging
-    return {
-      pinned: [STAGING_DEFAULT_FEED('whats-hot')],
-      saved: [
-        STAGING_DEFAULT_FEED('bsky-team'),
-        STAGING_DEFAULT_FEED('with-friends'),
-        STAGING_DEFAULT_FEED('whats-hot'),
-        STAGING_DEFAULT_FEED('hot-classic'),
-      ],
-    }
-  } else {
-    // production
-    return {
-      pinned: [PROD_DEFAULT_FEED('whats-hot')],
-      saved: [PROD_DEFAULT_FEED('whats-hot')],
-    }
-  }
-}
 
 export const POST_IMG_MAX = {
   width: 2000,
@@ -135,13 +58,11 @@ export const STAGING_LINK_META_PROXY =
 export const PROD_LINK_META_PROXY = 'https://cardyb.bsky.app/v1/extract?url='
 
 export function LINK_META_PROXY(serviceUrl: string) {
-  if (IS_LOCAL_DEV(serviceUrl)) {
-    return STAGING_LINK_META_PROXY
-  } else if (IS_STAGING(serviceUrl)) {
-    return STAGING_LINK_META_PROXY
-  } else {
+  if (IS_PROD_SERVICE(serviceUrl)) {
     return PROD_LINK_META_PROXY
   }
+
+  return STAGING_LINK_META_PROXY
 }
 
 export const STATUS_PAGE_URL = 'https://status.bsky.app/'
@@ -158,3 +79,9 @@ export const HITSLOP_20 = createHitslop(20)
 export const HITSLOP_30 = createHitslop(30)
 export const BACK_HITSLOP = HITSLOP_30
 export const MAX_POST_LINES = 25
+
+export const BSKY_FEED_OWNER_DIDS = [
+  'did:plc:z72i7hdynmk6r22z27h6tvur',
+  'did:plc:vpkhqolt662uhesyj6nxm7ys',
+  'did:plc:q6gjnaw2blty4crticxkmujt',
+]
diff --git a/src/lib/hooks/useDedupe.ts b/src/lib/hooks/useDedupe.ts
new file mode 100644
index 000000000..d9432cb2c
--- /dev/null
+++ b/src/lib/hooks/useDedupe.ts
@@ -0,0 +1,17 @@
+import React from 'react'
+
+export const useDedupe = () => {
+  const canDo = React.useRef(true)
+
+  return React.useRef((cb: () => unknown) => {
+    if (canDo.current) {
+      canDo.current = false
+      setTimeout(() => {
+        canDo.current = true
+      }, 250)
+      cb()
+      return true
+    }
+    return false
+  }).current
+}
diff --git a/src/lib/hooks/useInitialNumToRender.ts b/src/lib/hooks/useInitialNumToRender.ts
new file mode 100644
index 000000000..942f0404a
--- /dev/null
+++ b/src/lib/hooks/useInitialNumToRender.ts
@@ -0,0 +1,11 @@
+import React from 'react'
+import {Dimensions} from 'react-native'
+
+const MIN_POST_HEIGHT = 100
+
+export function useInitialNumToRender(minItemHeight: number = MIN_POST_HEIGHT) {
+  return React.useMemo(() => {
+    const screenHeight = Dimensions.get('window').height
+    return Math.ceil(screenHeight / minItemHeight) + 1
+  }, [minItemHeight])
+}
diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts
new file mode 100644
index 000000000..8741530b5
--- /dev/null
+++ b/src/lib/hooks/useIntentHandler.ts
@@ -0,0 +1,93 @@
+import React from 'react'
+import * as Linking from 'expo-linking'
+import {isNative} from 'platform/detection'
+import {useComposerControls} from 'state/shell'
+import {useSession} from 'state/session'
+import {useCloseAllActiveElements} from 'state/util'
+
+type IntentType = 'compose'
+
+const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/
+
+export function useIntentHandler() {
+  const incomingUrl = Linking.useURL()
+  const composeIntent = useComposeIntent()
+
+  React.useEffect(() => {
+    const handleIncomingURL = (url: string) => {
+      // We want to be able to support bluesky:// deeplinks. It's unnatural for someone to use a deeplink with three
+      // slashes, like bluesky:///intent/follow. However, supporting just two slashes causes us to have to take care
+      // of two cases when parsing the url. If we ensure there is a third slash, we can always ensure the first
+      // path parameter is in pathname rather than in hostname.
+      if (url.startsWith('bluesky://') && !url.startsWith('bluesky:///')) {
+        url = url.replace('bluesky://', 'bluesky:///')
+      }
+
+      const urlp = new URL(url)
+      const [_, intent, intentType] = urlp.pathname.split('/')
+
+      // On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the
+      // intent check. On web, we have to check the first part of the path since we have an actual hostname
+      const isIntent = intent === 'intent'
+      const params = urlp.searchParams
+
+      if (!isIntent) return
+
+      switch (intentType as IntentType) {
+        case 'compose': {
+          composeIntent({
+            text: params.get('text'),
+            imageUrisStr: params.get('imageUris'),
+          })
+        }
+      }
+    }
+
+    if (incomingUrl) handleIncomingURL(incomingUrl)
+  }, [incomingUrl, composeIntent])
+}
+
+function useComposeIntent() {
+  const closeAllActiveElements = useCloseAllActiveElements()
+  const {openComposer} = useComposerControls()
+  const {hasSession} = useSession()
+
+  return React.useCallback(
+    ({
+      text,
+      imageUrisStr,
+    }: {
+      text: string | null
+      imageUrisStr: string | null // unused for right now, will be used later with intents
+    }) => {
+      if (!hasSession) return
+
+      closeAllActiveElements()
+
+      const imageUris = imageUrisStr
+        ?.split(',')
+        .filter(part => {
+          // For some security, we're going to filter out any image uri that is external. We don't want someone to
+          // be able to provide some link like "bluesky://intent/compose?imageUris=https://IHaveYourIpNow.com/image.jpeg
+          // and we load that image
+          if (part.includes('https://') || part.includes('http://')) {
+            return false
+          }
+          // We also should just filter out cases that don't have all the info we need
+          return VALID_IMAGE_REGEX.test(part)
+        })
+        .map(part => {
+          const [uri, width, height] = part.split('|')
+          return {uri, width: Number(width), height: Number(height)}
+        })
+
+      setTimeout(() => {
+        openComposer({
+          text: text ?? undefined,
+          imageUris: isNative ? imageUris : undefined,
+        })
+      }, 500)
+    },
+    [hasSession, closeAllActiveElements, openComposer],
+  )
+}
diff --git a/src/lib/hooks/useNavigationDeduped.ts b/src/lib/hooks/useNavigationDeduped.ts
new file mode 100644
index 000000000..d913f7f3d
--- /dev/null
+++ b/src/lib/hooks/useNavigationDeduped.ts
@@ -0,0 +1,80 @@
+import React from 'react'
+import {useNavigation} from '@react-navigation/core'
+import {AllNavigatorParams, NavigationProp} from 'lib/routes/types'
+import type {NavigationAction} from '@react-navigation/routers'
+import {NavigationState} from '@react-navigation/native'
+import {useDedupe} from 'lib/hooks/useDedupe'
+
+export type DebouncedNavigationProp = Pick<
+  NavigationProp,
+  | 'popToTop'
+  | 'push'
+  | 'navigate'
+  | 'canGoBack'
+  | 'replace'
+  | 'dispatch'
+  | 'goBack'
+  | 'getState'
+>
+
+export function useNavigationDeduped() {
+  const navigation = useNavigation<NavigationProp>()
+  const dedupe = useDedupe()
+
+  return React.useMemo(
+    (): DebouncedNavigationProp => ({
+      // Types from @react-navigation/routers/lib/typescript/src/StackRouter.ts
+      push: <RouteName extends keyof AllNavigatorParams>(
+        ...args: undefined extends AllNavigatorParams[RouteName]
+          ?
+              | [screen: RouteName]
+              | [screen: RouteName, params: AllNavigatorParams[RouteName]]
+          : [screen: RouteName, params: AllNavigatorParams[RouteName]]
+      ) => {
+        dedupe(() => navigation.push(...args))
+      },
+      // Types from @react-navigation/core/src/types.tsx
+      navigate: <RouteName extends keyof AllNavigatorParams>(
+        ...args: RouteName extends unknown
+          ? undefined extends AllNavigatorParams[RouteName]
+            ?
+                | [screen: RouteName]
+                | [screen: RouteName, params: AllNavigatorParams[RouteName]]
+            : [screen: RouteName, params: AllNavigatorParams[RouteName]]
+          : never
+      ) => {
+        dedupe(() => navigation.navigate(...args))
+      },
+      // Types from @react-navigation/routers/lib/typescript/src/StackRouter.ts
+      replace: <RouteName extends keyof AllNavigatorParams>(
+        ...args: undefined extends AllNavigatorParams[RouteName]
+          ?
+              | [screen: RouteName]
+              | [screen: RouteName, params: AllNavigatorParams[RouteName]]
+          : [screen: RouteName, params: AllNavigatorParams[RouteName]]
+      ) => {
+        dedupe(() => navigation.replace(...args))
+      },
+      dispatch: (
+        action:
+          | NavigationAction
+          | ((state: NavigationState) => NavigationAction),
+      ) => {
+        dedupe(() => navigation.dispatch(action))
+      },
+      popToTop: () => {
+        dedupe(() => navigation.popToTop())
+      },
+      goBack: () => {
+        dedupe(() => navigation.goBack())
+      },
+      canGoBack: () => {
+        return navigation.canGoBack()
+      },
+      getState: () => {
+        return navigation.getState()
+      },
+    }),
+    [dedupe, navigation],
+  )
+}
diff --git a/src/lib/hooks/useOTAUpdate.ts b/src/lib/hooks/useOTAUpdate.ts
index 53eab300e..d35179256 100644
--- a/src/lib/hooks/useOTAUpdate.ts
+++ b/src/lib/hooks/useOTAUpdate.ts
@@ -2,25 +2,9 @@ import * as Updates from 'expo-updates'
 import {useCallback, useEffect} from 'react'
 import {AppState} from 'react-native'
 import {logger} from '#/logger'
-import {useModalControls} from '#/state/modals'
-import {t} from '@lingui/macro'
 
 export function useOTAUpdate() {
-  const {openModal} = useModalControls()
-
   // HELPER FUNCTIONS
-  const showUpdatePopup = useCallback(() => {
-    openModal({
-      name: 'confirm',
-      title: t`Update Available`,
-      message: t`A new version of the app is available. Please update to continue using the app.`,
-      onPressConfirm: async () => {
-        Updates.reloadAsync().catch(err => {
-          throw err
-        })
-      },
-    })
-  }, [openModal])
   const checkForUpdate = useCallback(async () => {
     logger.debug('useOTAUpdate: Checking for update...')
     try {
@@ -32,32 +16,26 @@ export function useOTAUpdate() {
       }
       // Otherwise fetch the update in the background, so even if the user rejects switching to latest version it will be done automatically on next relaunch.
       await Updates.fetchUpdateAsync()
-      // show a popup modal
-      showUpdatePopup()
     } catch (e) {
       logger.error('useOTAUpdate: Error while checking for update', {
         message: e,
       })
     }
-  }, [showUpdatePopup])
-  const updateEventListener = useCallback(
-    (event: Updates.UpdateEvent) => {
-      logger.debug('useOTAUpdate: Listening for update...')
-      if (event.type === Updates.UpdateEventType.ERROR) {
-        logger.error('useOTAUpdate: Error while listening for update', {
-          message: event.message,
-        })
-      } else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) {
-        // Handle no update available
-        // do nothing
-      } else if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) {
-        // Handle update available
-        // open modal, ask for user confirmation, and reload the app
-        showUpdatePopup()
-      }
-    },
-    [showUpdatePopup],
-  )
+  }, [])
+  const updateEventListener = useCallback((event: Updates.UpdateEvent) => {
+    logger.debug('useOTAUpdate: Listening for update...')
+    if (event.type === Updates.UpdateEventType.ERROR) {
+      logger.error('useOTAUpdate: Error while listening for update', {
+        message: event.message,
+      })
+    } else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) {
+      // Handle no update available
+      // do nothing
+    } else if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) {
+      // Handle update available
+      // open modal, ask for user confirmation, and reload the app
+    }
+  }, [])
 
   useEffect(() => {
     // ADD EVENT LISTENERS
diff --git a/src/lib/link-meta/link-meta.ts b/src/lib/link-meta/link-meta.ts
index c7c8d4130..fa951432e 100644
--- a/src/lib/link-meta/link-meta.ts
+++ b/src/lib/link-meta/link-meta.ts
@@ -26,7 +26,7 @@ export interface LinkMeta {
 export async function getLinkMeta(
   agent: BskyAgent,
   url: string,
-  timeout = 5e3,
+  timeout = 15e3,
 ): Promise<LinkMeta> {
   if (isBskyAppUrl(url)) {
     return extractBskyMeta(agent, url)
diff --git a/src/lib/media/picker.shared.ts b/src/lib/media/picker.shared.ts
index 8bade34e2..96e82e4c7 100644
--- a/src/lib/media/picker.shared.ts
+++ b/src/lib/media/picker.shared.ts
@@ -18,11 +18,18 @@ export async function openPicker(opts?: ImagePickerOptions) {
     Toast.show('You may only select up to 4 images')
   }
 
-  return (response.assets ?? []).slice(0, 4).map(image => ({
-    mime: 'image/jpeg',
-    height: image.height,
-    width: image.width,
-    path: image.uri,
-    size: getDataUriSize(image.uri),
-  }))
+  return (response.assets ?? [])
+    .slice(0, 4)
+    .filter(asset => {
+      if (asset.mimeType?.startsWith('image/')) return true
+      Toast.show('Only image files are supported')
+      return false
+    })
+    .map(image => ({
+      mime: 'image/jpeg',
+      height: image.height,
+      width: image.width,
+      path: image.uri,
+      size: getDataUriSize(image.uri),
+    }))
 }
diff --git a/src/lib/moderatePost_wrapped.ts b/src/lib/moderatePost_wrapped.ts
index 2195b2304..0ce01368a 100644
--- a/src/lib/moderatePost_wrapped.ts
+++ b/src/lib/moderatePost_wrapped.ts
@@ -1,58 +1,30 @@
-import {
-  AppBskyEmbedRecord,
-  AppBskyEmbedRecordWithMedia,
-  moderatePost,
-} from '@atproto/api'
+import {moderatePost, BSKY_LABELER_DID} from '@atproto/api'
 
 type ModeratePost = typeof moderatePost
-type Options = Parameters<ModeratePost>[1] & {
-  hiddenPosts?: string[]
-}
+type Options = Parameters<ModeratePost>[1]
 
 export function moderatePost_wrapped(
   subject: Parameters<ModeratePost>[0],
   opts: Options,
 ) {
-  const {hiddenPosts = [], ...options} = opts
-  const moderations = moderatePost(subject, options)
+  // HACK
+  // temporarily translate 'gore' into 'graphic-media' during the transition period
+  // can remove this in a few months
+  // -prf
+  translateOldLabels(subject)
 
-  if (hiddenPosts.includes(subject.uri)) {
-    moderations.content.filter = true
-    moderations.content.blur = true
-    if (!moderations.content.cause) {
-      moderations.content.cause = {
-        // @ts-ignore Temporary extension to the moderation system -prf
-        type: 'post-hidden',
-        source: {type: 'user'},
-        priority: 1,
-      }
-    }
-  }
+  return moderatePost(subject, opts)
+}
 
-  if (subject.embed) {
-    let embedHidden = false
-    if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
-      embedHidden = hiddenPosts.includes(subject.embed.record.uri)
-    }
-    if (
-      AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
-      AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
-    ) {
-      embedHidden = hiddenPosts.includes(subject.embed.record.record.uri)
-    }
-    if (embedHidden) {
-      moderations.embed.filter = true
-      moderations.embed.blur = true
-      if (!moderations.embed.cause) {
-        moderations.embed.cause = {
-          // @ts-ignore Temporary extension to the moderation system -prf
-          type: 'post-hidden',
-          source: {type: 'user'},
-          priority: 1,
-        }
+function translateOldLabels(subject: Parameters<ModeratePost>[0]) {
+  if (subject.labels) {
+    for (const label of subject.labels) {
+      if (
+        label.val === 'gore' &&
+        (!label.src || label.src === BSKY_LABELER_DID)
+      ) {
+        label.val = 'graphic-media'
       }
     }
   }
-
-  return moderations
 }
diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts
index bf19c208a..4105c2c2d 100644
--- a/src/lib/moderation.ts
+++ b/src/lib/moderation.ts
@@ -1,142 +1,81 @@
-import {ModerationCause, ProfileModeration, PostModeration} from '@atproto/api'
+import {
+  ModerationCause,
+  ModerationUI,
+  InterpretedLabelValueDefinition,
+  LABELS,
+  AppBskyLabelerDefs,
+  BskyAgent,
+  ModerationOpts,
+} from '@atproto/api'
 
-export interface ModerationCauseDescription {
-  name: string
-  description: string
-}
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {sanitizeHandle} from '#/lib/strings/handles'
 
-export function describeModerationCause(
-  cause: ModerationCause | undefined,
-  context: 'account' | 'content',
-): ModerationCauseDescription {
-  if (!cause) {
-    return {
-      name: 'Content Warning',
-      description:
-        'Moderator has chosen to set a general warning on the content.',
-    }
-  }
-  if (cause.type === 'blocking') {
-    if (cause.source.type === 'list') {
-      return {
-        name: `User Blocked by "${cause.source.list.name}"`,
-        description:
-          'You have blocked this user. You cannot view their content.',
-      }
-    } else {
-      return {
-        name: 'User Blocked',
-        description:
-          'You have blocked this user. You cannot view their content.',
-      }
-    }
-  }
-  if (cause.type === 'blocked-by') {
-    return {
-      name: 'User Blocking You',
-      description: 'This user has blocked you. You cannot view their content.',
-    }
-  }
-  if (cause.type === 'block-other') {
-    return {
-      name: 'Content Not Available',
-      description:
-        'This content is not available because one of the users involved has blocked the other.',
-    }
-  }
-  if (cause.type === 'muted') {
-    if (cause.source.type === 'list') {
-      return {
-        name:
-          context === 'account'
-            ? `Muted by "${cause.source.list.name}"`
-            : `Post by muted user ("${cause.source.list.name}")`,
-        description: 'You have muted this user',
-      }
-    } else {
-      return {
-        name: context === 'account' ? 'Muted User' : 'Post by muted user',
-        description: 'You have muted this user',
-      }
-    }
-  }
-  // @ts-ignore Temporary extension to the moderation system -prf
-  if (cause.type === 'post-hidden') {
-    return {
-      name: 'Post Hidden by You',
-      description: 'You have hidden this post',
-    }
+export function getModerationCauseKey(cause: ModerationCause): string {
+  const source =
+    cause.source.type === 'labeler'
+      ? cause.source.did
+      : cause.source.type === 'list'
+      ? cause.source.list.uri
+      : 'user'
+  if (cause.type === 'label') {
+    return `label:${cause.label.val}:${source}`
   }
-  return cause.labelDef.strings[context].en
+  return `${cause.type}:${source}`
 }
 
-export function getProfileModerationCauses(
-  moderation: ProfileModeration,
-): ModerationCause[] {
-  /*
-  Gather everything on profile and account that blurs or alerts
-  */
-  return [
-    moderation.decisions.profile.cause,
-    ...moderation.decisions.profile.additionalCauses,
-    moderation.decisions.account.cause,
-    ...moderation.decisions.account.additionalCauses,
-  ].filter(cause => {
-    if (!cause) {
-      return false
-    }
-    if (cause?.type === 'label') {
-      if (
-        cause.labelDef.onwarn === 'blur' ||
-        cause.labelDef.onwarn === 'alert'
-      ) {
-        return true
-      } else {
-        return false
-      }
-    }
-    return true
-  }) as ModerationCause[]
+export function isJustAMute(modui: ModerationUI): boolean {
+  return modui.filters.length === 1 && modui.filters[0].type === 'muted'
 }
 
-export function isPostMediaBlurred(
-  decisions: PostModeration['decisions'],
-): boolean {
-  return decisions.post.blurMedia
+export function getLabelingServiceTitle({
+  displayName,
+  handle,
+}: {
+  displayName?: string
+  handle: string
+}) {
+  return displayName
+    ? sanitizeDisplayName(displayName)
+    : sanitizeHandle(handle, '@')
 }
 
-export function isQuoteBlurred(
-  decisions: PostModeration['decisions'],
-): boolean {
-  return (
-    decisions.quote?.blur ||
-    decisions.quote?.blurMedia ||
-    decisions.quote?.filter ||
-    decisions.quotedAccount?.blur ||
-    decisions.quotedAccount?.filter ||
-    false
-  )
+export function lookupLabelValueDefinition(
+  labelValue: string,
+  customDefs: InterpretedLabelValueDefinition[] | undefined,
+): InterpretedLabelValueDefinition | undefined {
+  let def
+  if (!labelValue.startsWith('!') && customDefs) {
+    def = customDefs.find(d => d.identifier === labelValue)
+  }
+  if (!def) {
+    def = LABELS[labelValue as keyof typeof LABELS]
+  }
+  return def
 }
 
-export function isCauseALabelOnUri(
-  cause: ModerationCause | undefined,
-  uri: string,
+export function isAppLabeler(
+  labeler:
+    | string
+    | AppBskyLabelerDefs.LabelerView
+    | AppBskyLabelerDefs.LabelerViewDetailed,
 ): boolean {
-  if (cause?.type !== 'label') {
-    return false
+  if (typeof labeler === 'string') {
+    return BskyAgent.appLabelers.includes(labeler)
   }
-  return cause.label.uri === uri
+  return BskyAgent.appLabelers.includes(labeler.creator.did)
 }
 
-export function getModerationCauseKey(cause: ModerationCause): string {
-  const source =
-    cause.source.type === 'labeler'
-      ? cause.source.labeler.did
-      : cause.source.type === 'list'
-      ? cause.source.list.uri
-      : 'user'
-  if (cause.type === 'label') {
-    return `label:${cause.label.val}:${source}`
+export function isLabelerSubscribed(
+  labeler:
+    | string
+    | AppBskyLabelerDefs.LabelerView
+    | AppBskyLabelerDefs.LabelerViewDetailed,
+  modOpts: ModerationOpts,
+) {
+  labeler = typeof labeler === 'string' ? labeler : labeler.creator.did
+  if (isAppLabeler(labeler)) {
+    return true
   }
-  return `${cause.type}:${source}`
+  return modOpts.prefs.labelers.find(l => l.did === labeler)
 }
diff --git a/src/lib/moderation/useGlobalLabelStrings.ts b/src/lib/moderation/useGlobalLabelStrings.ts
new file mode 100644
index 000000000..1c5a48231
--- /dev/null
+++ b/src/lib/moderation/useGlobalLabelStrings.ts
@@ -0,0 +1,52 @@
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useMemo} from 'react'
+
+export type GlobalLabelStrings = Record<
+  string,
+  {
+    name: string
+    description: string
+  }
+>
+
+export function useGlobalLabelStrings(): GlobalLabelStrings {
+  const {_} = useLingui()
+  return useMemo(
+    () => ({
+      '!hide': {
+        name: _(msg`Content Blocked`),
+        description: _(msg`This content has been hidden by the moderators.`),
+      },
+      '!warn': {
+        name: _(msg`Content Warning`),
+        description: _(
+          msg`This content has received a general warning from moderators.`,
+        ),
+      },
+      '!no-unauthenticated': {
+        name: _(msg`Sign-in Required`),
+        description: _(
+          msg`This user has requested that their content only be shown to signed-in users.`,
+        ),
+      },
+      porn: {
+        name: _(msg`Pornography`),
+        description: _(msg`Explicit sexual images.`),
+      },
+      sexual: {
+        name: _(msg`Sexually Suggestive`),
+        description: _(msg`Does not include nudity.`),
+      },
+      nudity: {
+        name: _(msg`Non-sexual Nudity`),
+        description: _(msg`E.g. artistic nudes.`),
+      },
+      'graphic-media': {
+        name: _(msg`Graphic Media`),
+        description: _(msg`Explicit or potentially disturbing media.`),
+      },
+    }),
+    [_],
+  )
+}
diff --git a/src/lib/moderation/useLabelBehaviorDescription.ts b/src/lib/moderation/useLabelBehaviorDescription.ts
new file mode 100644
index 000000000..0250c1bc8
--- /dev/null
+++ b/src/lib/moderation/useLabelBehaviorDescription.ts
@@ -0,0 +1,70 @@
+import {InterpretedLabelValueDefinition, LabelPreference} from '@atproto/api'
+import {useLingui} from '@lingui/react'
+import {msg} from '@lingui/macro'
+
+export function useLabelBehaviorDescription(
+  labelValueDef: InterpretedLabelValueDefinition,
+  pref: LabelPreference,
+) {
+  const {_} = useLingui()
+  if (pref === 'ignore') {
+    return _(msg`Off`)
+  }
+  if (labelValueDef.blurs === 'content' || labelValueDef.blurs === 'media') {
+    if (pref === 'hide') {
+      return _(msg`Hide`)
+    }
+    return _(msg`Warn`)
+  } else if (labelValueDef.severity === 'alert') {
+    if (pref === 'hide') {
+      return _(msg`Hide`)
+    }
+    return _(msg`Warn`)
+  } else if (labelValueDef.severity === 'inform') {
+    if (pref === 'hide') {
+      return _(msg`Hide`)
+    }
+    return _(msg`Show badge`)
+  } else {
+    if (pref === 'hide') {
+      return _(msg`Hide`)
+    }
+    return _(msg`Disabled`)
+  }
+}
+
+export function useLabelLongBehaviorDescription(
+  labelValueDef: InterpretedLabelValueDefinition,
+  pref: LabelPreference,
+) {
+  const {_} = useLingui()
+  if (pref === 'ignore') {
+    return _(msg`Disabled`)
+  }
+  if (labelValueDef.blurs === 'content') {
+    if (pref === 'hide') {
+      return _(msg`Warn content and filter from feeds`)
+    }
+    return _(msg`Warn content`)
+  } else if (labelValueDef.blurs === 'media') {
+    if (pref === 'hide') {
+      return _(msg`Blur images and filter from feeds`)
+    }
+    return _(msg`Blur images`)
+  } else if (labelValueDef.severity === 'alert') {
+    if (pref === 'hide') {
+      return _(msg`Show warning and filter from feeds`)
+    }
+    return _(msg`Show warning`)
+  } else if (labelValueDef.severity === 'inform') {
+    if (pref === 'hide') {
+      return _(msg`Show badge and filter from feeds`)
+    }
+    return _(msg`Show badge`)
+  } else {
+    if (pref === 'hide') {
+      return _(msg`Filter from feeds`)
+    }
+    return _(msg`Disabled`)
+  }
+}
diff --git a/src/lib/moderation/useLabelInfo.ts b/src/lib/moderation/useLabelInfo.ts
new file mode 100644
index 000000000..b1cffe1e7
--- /dev/null
+++ b/src/lib/moderation/useLabelInfo.ts
@@ -0,0 +1,100 @@
+import {
+  ComAtprotoLabelDefs,
+  AppBskyLabelerDefs,
+  LABELS,
+  interpretLabelValueDefinition,
+  InterpretedLabelValueDefinition,
+} from '@atproto/api'
+import {useLingui} from '@lingui/react'
+import * as bcp47Match from 'bcp-47-match'
+
+import {
+  GlobalLabelStrings,
+  useGlobalLabelStrings,
+} from '#/lib/moderation/useGlobalLabelStrings'
+import {useLabelDefinitions} from '#/state/preferences'
+
+export interface LabelInfo {
+  label: ComAtprotoLabelDefs.Label
+  def: InterpretedLabelValueDefinition
+  strings: ComAtprotoLabelDefs.LabelValueDefinitionStrings
+  labeler: AppBskyLabelerDefs.LabelerViewDetailed | undefined
+}
+
+export function useLabelInfo(label: ComAtprotoLabelDefs.Label): LabelInfo {
+  const {i18n} = useLingui()
+  const {labelDefs, labelers} = useLabelDefinitions()
+  const globalLabelStrings = useGlobalLabelStrings()
+  const def = getDefinition(labelDefs, label)
+  return {
+    label,
+    def,
+    strings: getLabelStrings(i18n.locale, globalLabelStrings, def),
+    labeler: labelers.find(labeler => label.src === labeler.creator.did),
+  }
+}
+
+export function getDefinition(
+  labelDefs: Record<string, InterpretedLabelValueDefinition[]>,
+  label: ComAtprotoLabelDefs.Label,
+): InterpretedLabelValueDefinition {
+  // check local definitions
+  const customDef =
+    !label.val.startsWith('!') &&
+    labelDefs[label.src]?.find(
+      def => def.identifier === label.val && def.definedBy === label.src,
+    )
+  if (customDef) {
+    return customDef
+  }
+
+  // check global definitions
+  const globalDef = LABELS[label.val as keyof typeof LABELS]
+  if (globalDef) {
+    return globalDef
+  }
+
+  // fallback to a noop definition
+  return interpretLabelValueDefinition(
+    {
+      identifier: label.val,
+      severity: 'none',
+      blurs: 'none',
+      defaultSetting: 'ignore',
+      locales: [],
+    },
+    label.src,
+  )
+}
+
+export function getLabelStrings(
+  locale: string,
+  globalLabelStrings: GlobalLabelStrings,
+  def: InterpretedLabelValueDefinition,
+): ComAtprotoLabelDefs.LabelValueDefinitionStrings {
+  if (!def.definedBy) {
+    // global definition, look up strings
+    if (def.identifier in globalLabelStrings) {
+      return globalLabelStrings[
+        def.identifier
+      ] as ComAtprotoLabelDefs.LabelValueDefinitionStrings
+    }
+  } else {
+    // try to find locale match in the definition's strings
+    const localeMatch = def.locales.find(
+      strings => bcp47Match.basicFilter(locale, strings.lang).length > 0,
+    )
+    if (localeMatch) {
+      return localeMatch
+    }
+    // fall back to the zero item if no match
+    if (def.locales[0]) {
+      return def.locales[0]
+    }
+  }
+  return {
+    lang: locale,
+    name: def.identifier,
+    description: `Labeled "${def.identifier}"`,
+  }
+}
diff --git a/src/lib/moderation/useModerationCauseDescription.ts b/src/lib/moderation/useModerationCauseDescription.ts
new file mode 100644
index 000000000..46771e958
--- /dev/null
+++ b/src/lib/moderation/useModerationCauseDescription.ts
@@ -0,0 +1,146 @@
+import React from 'react'
+import {
+  BSKY_LABELER_DID,
+  ModerationCause,
+  ModerationCauseSource,
+} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {getDefinition, getLabelStrings} from './useLabelInfo'
+import {useLabelDefinitions} from '#/state/preferences'
+import {useGlobalLabelStrings} from './useGlobalLabelStrings'
+
+import {Props as SVGIconProps} from '#/components/icons/common'
+import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
+import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
+
+export interface ModerationCauseDescription {
+  icon: React.ComponentType<SVGIconProps>
+  name: string
+  description: string
+  source?: string
+  sourceType?: ModerationCauseSource['type']
+}
+
+export function useModerationCauseDescription(
+  cause: ModerationCause | undefined,
+): ModerationCauseDescription {
+  const {_, i18n} = useLingui()
+  const {labelDefs, labelers} = useLabelDefinitions()
+  const globalLabelStrings = useGlobalLabelStrings()
+
+  return React.useMemo(() => {
+    if (!cause) {
+      return {
+        icon: Warning,
+        name: _(msg`Content Warning`),
+        description: _(
+          msg`Moderator has chosen to set a general warning on the content.`,
+        ),
+      }
+    }
+    if (cause.type === 'blocking') {
+      if (cause.source.type === 'list') {
+        return {
+          icon: CircleBanSign,
+          name: _(msg`User Blocked by "${cause.source.list.name}"`),
+          description: _(
+            msg`You have blocked this user. You cannot view their content.`,
+          ),
+        }
+      } else {
+        return {
+          icon: CircleBanSign,
+          name: _(msg`User Blocked`),
+          description: _(
+            msg`You have blocked this user. You cannot view their content.`,
+          ),
+        }
+      }
+    }
+    if (cause.type === 'blocked-by') {
+      return {
+        icon: CircleBanSign,
+        name: _(msg`User Blocking You`),
+        description: _(
+          msg`This user has blocked you. You cannot view their content.`,
+        ),
+      }
+    }
+    if (cause.type === 'block-other') {
+      return {
+        icon: CircleBanSign,
+        name: _(msg`Content Not Available`),
+        description: _(
+          msg`This content is not available because one of the users involved has blocked the other.`,
+        ),
+      }
+    }
+    if (cause.type === 'muted') {
+      if (cause.source.type === 'list') {
+        return {
+          icon: EyeSlash,
+          name: _(msg`Muted by "${cause.source.list.name}"`),
+          description: _(msg`You have muted this user`),
+        }
+      } else {
+        return {
+          icon: EyeSlash,
+          name: _(msg`Account Muted`),
+          description: _(msg`You have muted this account.`),
+        }
+      }
+    }
+    if (cause.type === 'mute-word') {
+      return {
+        icon: EyeSlash,
+        name: _(msg`Post Hidden by Muted Word`),
+        description: _(
+          msg`You've chosen to hide a word or tag within this post.`,
+        ),
+      }
+    }
+    if (cause.type === 'hidden') {
+      return {
+        icon: EyeSlash,
+        name: _(msg`Post Hidden by You`),
+        description: _(msg`You have hidden this post`),
+      }
+    }
+    if (cause.type === 'label') {
+      const def = cause.labelDef || getDefinition(labelDefs, cause.label)
+      const strings = getLabelStrings(i18n.locale, globalLabelStrings, def)
+      const labeler = labelers.find(l => l.creator.did === cause.label.src)
+      let source =
+        labeler?.creator.displayName ||
+        (labeler?.creator.handle ? '@' + labeler?.creator.handle : undefined)
+      if (!source) {
+        if (cause.label.src === BSKY_LABELER_DID) {
+          source = 'Bluesky Moderation'
+        } else {
+          source = cause.label.src
+        }
+      }
+      return {
+        icon:
+          def.identifier === '!no-unauthenticated'
+            ? EyeSlash
+            : def.severity === 'alert'
+            ? Warning
+            : CircleInfo,
+        name: strings.name,
+        description: strings.description,
+        source,
+        sourceType: cause.source.type,
+      }
+    }
+    // should never happen
+    return {
+      icon: CircleInfo,
+      name: '',
+      description: ``,
+    }
+  }, [labelDefs, labelers, globalLabelStrings, cause, _, i18n.locale])
+}
diff --git a/src/lib/moderation/useReportOptions.ts b/src/lib/moderation/useReportOptions.ts
new file mode 100644
index 000000000..e00170594
--- /dev/null
+++ b/src/lib/moderation/useReportOptions.ts
@@ -0,0 +1,94 @@
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useMemo} from 'react'
+import {ComAtprotoModerationDefs} from '@atproto/api'
+
+export interface ReportOption {
+  reason: string
+  title: string
+  description: string
+}
+
+interface ReportOptions {
+  account: ReportOption[]
+  post: ReportOption[]
+  list: ReportOption[]
+  feedgen: ReportOption[]
+  other: ReportOption[]
+}
+
+export function useReportOptions(): ReportOptions {
+  const {_} = useLingui()
+  return useMemo(() => {
+    const other = {
+      reason: ComAtprotoModerationDefs.REASONOTHER,
+      title: _(msg`Other`),
+      description: _(msg`An issue not included in these options`),
+    }
+    const common = [
+      {
+        reason: ComAtprotoModerationDefs.REASONRUDE,
+        title: _(msg`Anti-Social Behavior`),
+        description: _(msg`Harassment, trolling, or intolerance`),
+      },
+      {
+        reason: ComAtprotoModerationDefs.REASONVIOLATION,
+        title: _(msg`Illegal and Urgent`),
+        description: _(msg`Glaring violations of law or terms of service`),
+      },
+      other,
+    ]
+    return {
+      account: [
+        {
+          reason: ComAtprotoModerationDefs.REASONMISLEADING,
+          title: _(msg`Misleading Account`),
+          description: _(
+            msg`Impersonation or false claims about identity or affiliation`,
+          ),
+        },
+        {
+          reason: ComAtprotoModerationDefs.REASONSPAM,
+          title: _(msg`Frequently Posts Unwanted Content`),
+          description: _(msg`Spam; excessive mentions or replies`),
+        },
+        {
+          reason: ComAtprotoModerationDefs.REASONVIOLATION,
+          title: _(msg`Name or Description Violates Community Standards`),
+          description: _(msg`Terms used violate community standards`),
+        },
+        other,
+      ],
+      post: [
+        {
+          reason: ComAtprotoModerationDefs.REASONSPAM,
+          title: _(msg`Spam`),
+          description: _(msg`Excessive mentions or replies`),
+        },
+        {
+          reason: ComAtprotoModerationDefs.REASONSEXUAL,
+          title: _(msg`Unwanted Sexual Content`),
+          description: _(msg`Nudity or pornography not labeled as such`),
+        },
+        ...common,
+      ],
+      list: [
+        {
+          reason: ComAtprotoModerationDefs.REASONVIOLATION,
+          title: _(msg`Name or Description Violates Community Standards`),
+          description: _(msg`Terms used violate community standards`),
+        },
+        ...common,
+      ],
+      feedgen: [
+        {
+          reason: ComAtprotoModerationDefs.REASONVIOLATION,
+          title: _(msg`Name or Description Violates Community Standards`),
+          description: _(msg`Terms used violate community standards`),
+        },
+        ...common,
+      ],
+      other: common,
+    }
+  }, [_])
+}
diff --git a/src/lib/react-query.ts b/src/lib/react-query.ts
index 7fe3fe7a4..d6cd3c54b 100644
--- a/src/lib/react-query.ts
+++ b/src/lib/react-query.ts
@@ -1,7 +1,14 @@
 import {AppState, AppStateStatus} from 'react-native'
 import {QueryClient, focusManager} from '@tanstack/react-query'
+import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
+import AsyncStorage from '@react-native-async-storage/async-storage'
+import {PersistQueryClientProviderProps} from '@tanstack/react-query-persist-client'
+
 import {isNative} from '#/platform/detection'
 
+// any query keys in this array will be persisted to AsyncStorage
+const STORED_CACHE_QUERY_KEYS = ['labelers-detailed-info']
+
 focusManager.setEventListener(onFocus => {
   if (isNative) {
     const subscription = AppState.addEventListener(
@@ -48,3 +55,16 @@ export const queryClient = new QueryClient({
     },
   },
 })
+
+export const asyncStoragePersister = createAsyncStoragePersister({
+  storage: AsyncStorage,
+  key: 'queryCache',
+})
+
+export const dehydrateOptions: PersistQueryClientProviderProps['persistOptions']['dehydrateOptions'] =
+  {
+    shouldDehydrateMutation: (_: any) => false,
+    shouldDehydrateQuery: query => {
+      return STORED_CACHE_QUERY_KEYS.includes(String(query.queryKey[0]))
+    },
+  }
diff --git a/src/lib/routes/links.ts b/src/lib/routes/links.ts
index 538f30cd3..9dfdab909 100644
--- a/src/lib/routes/links.ts
+++ b/src/lib/routes/links.ts
@@ -25,3 +25,13 @@ export function makeCustomFeedLink(
 export function makeListLink(did: string, rkey: string, ...segments: string[]) {
   return [`/profile`, did, 'lists', rkey, ...segments].join('/')
 }
+
+export function makeTagLink(did: string) {
+  return `/search?q=${encodeURIComponent(did)}`
+}
+
+export function makeSearchLink(props: {query: string; from?: 'me' | string}) {
+  return `/search?q=${encodeURIComponent(
+    props.query + (props.from ? ` from:${props.from}` : ''),
+  )}`
+}
diff --git a/src/lib/routes/router.ts b/src/lib/routes/router.ts
index 00defaeda..8c8be3739 100644
--- a/src/lib/routes/router.ts
+++ b/src/lib/routes/router.ts
@@ -2,9 +2,15 @@ import {RouteParams, Route} from './types'
 
 export class Router {
   routes: [string, Route][] = []
-  constructor(description: Record<string, string>) {
+  constructor(description: Record<string, string | string[]>) {
     for (const [screen, pattern] of Object.entries(description)) {
-      this.routes.push([screen, createRoute(pattern)])
+      if (typeof pattern === 'string') {
+        this.routes.push([screen, createRoute(pattern)])
+      } else {
+        pattern.forEach(subPattern => {
+          this.routes.push([screen, createRoute(subPattern)])
+        })
+      }
     }
   }
 
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 90ae75830..95af2f237 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -21,7 +21,9 @@ export type CommonNavigatorParams = {
   PostRepostedBy: {name: string; rkey: string}
   ProfileFeed: {name: string; rkey: string}
   ProfileFeedLikedBy: {name: string; rkey: string}
+  ProfileLabelerLikedBy: {name: string}
   Debug: undefined
+  DebugMod: undefined
   Log: undefined
   Support: undefined
   PrivacyPolicy: undefined
@@ -30,9 +32,11 @@ export type CommonNavigatorParams = {
   CopyrightPolicy: undefined
   AppPasswords: undefined
   SavedFeeds: undefined
-  PreferencesHomeFeed: undefined
+  PreferencesFollowingFeed: undefined
   PreferencesThreads: undefined
   PreferencesExternalEmbeds: undefined
+  Search: {q?: string}
+  Hashtag: {tag: string; author?: string}
 }
 
 export type BottomTabNavigatorParams = CommonNavigatorParams & {
@@ -68,6 +72,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
   Search: {q?: string}
   Feeds: undefined
   Notifications: undefined
+  Hashtag: {tag: string; author?: string}
 }
 
 export type AllNavigatorParams = CommonNavigatorParams & {
@@ -80,6 +85,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
   NotificationsTab: undefined
   Notifications: undefined
   MyProfileTab: undefined
+  Hashtag: {tag: string; author?: string}
 }
 
 // NOTE
diff --git a/src/lib/sharing.ts b/src/lib/sharing.ts
index b294d7464..9f402f873 100644
--- a/src/lib/sharing.ts
+++ b/src/lib/sharing.ts
@@ -12,9 +12,9 @@ import {Share} from 'react-native'
  */
 export async function shareUrl(url: string) {
   if (isAndroid) {
-    Share.share({message: url})
+    await Share.share({message: url})
   } else if (isIOS) {
-    Share.share({url})
+    await Share.share({url})
   } else {
     // React Native Share is not supported by web. Web Share API
     // has increasing but not full support, so default to clipboard
diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts
new file mode 100644
index 000000000..fa7e597fb
--- /dev/null
+++ b/src/lib/statsig/events.ts
@@ -0,0 +1,47 @@
+export type LogEvents = {
+  init: {
+    initMs: number
+  }
+  'feed:endReached': {
+    feedType: string
+    itemCount: number
+  }
+  'post:create': {
+    imageCount: number
+    isReply: boolean
+    hasLink: boolean
+    hasQuote: boolean
+    langs: string
+    logContext: 'Composer'
+  }
+  'post:like': {
+    logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
+  }
+  'post:repost': {
+    logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
+  }
+  'post:unlike': {
+    logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
+  }
+  'post:unrepost': {
+    logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
+  }
+  'profile:follow': {
+    logContext:
+      | 'RecommendedFollowsItem'
+      | 'PostThreadItem'
+      | 'ProfileCard'
+      | 'ProfileHeader'
+      | 'ProfileHeaderSuggestedFollows'
+      | 'ProfileMenu'
+  }
+  'profile:unfollow': {
+    logContext:
+      | 'RecommendedFollowsItem'
+      | 'PostThreadItem'
+      | 'ProfileCard'
+      | 'ProfileHeader'
+      | 'ProfileHeaderSuggestedFollows'
+      | 'ProfileMenu'
+  }
+}
diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx
new file mode 100644
index 000000000..5745d204a
--- /dev/null
+++ b/src/lib/statsig/statsig.tsx
@@ -0,0 +1,94 @@
+import React from 'react'
+import {
+  Statsig,
+  StatsigProvider,
+  useGate as useStatsigGate,
+} from 'statsig-react-native-expo'
+import {useSession} from '../../state/session'
+import {sha256} from 'js-sha256'
+import {LogEvents} from './events'
+
+export type {LogEvents}
+
+const statsigOptions = {
+  environment: {
+    tier: process.env.NODE_ENV === 'development' ? 'development' : 'production',
+  },
+  // Don't block on waiting for network. The fetched config will kick in on next load.
+  // This ensures the UI is always consistent and doesn't update mid-session.
+  // Note this makes cold load (no local storage) and private mode return `false` for all gates.
+  initTimeoutMs: 1,
+}
+
+type FlatJSONRecord = Record<
+  string,
+  string | number | boolean | null | undefined
+>
+
+let getCurrentRouteName: () => string | null | undefined = () => null
+
+export function attachRouteToLogEvents(
+  getRouteName: () => string | null | undefined,
+) {
+  getCurrentRouteName = getRouteName
+}
+
+export function logEvent<E extends keyof LogEvents>(
+  eventName: E & string,
+  rawMetadata: LogEvents[E] & FlatJSONRecord,
+) {
+  const fullMetadata = {
+    ...rawMetadata,
+  } as Record<string, string> // Statsig typings are unnecessarily strict here.
+  fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)'
+  Statsig.logEvent(eventName, null, fullMetadata)
+}
+
+export function useGate(gateName: string) {
+  const {isLoading, value} = useStatsigGate(gateName)
+  if (isLoading) {
+    // This should not happen because of waitForInitialization={true}.
+    console.error('Did not expected isLoading to ever be true.')
+  }
+  return value
+}
+
+function toStatsigUser(did: string | undefined) {
+  let userID: string | undefined
+  if (did) {
+    userID = sha256(did)
+  }
+  return {userID}
+}
+
+export function Provider({children}: {children: React.ReactNode}) {
+  const {currentAccount} = useSession()
+  const currentStatsigUser = React.useMemo(
+    () => toStatsigUser(currentAccount?.did),
+    [currentAccount?.did],
+  )
+
+  React.useEffect(() => {
+    function refresh() {
+      // Intentionally refetching the config using the JS SDK rather than React SDK
+      // so that the new config is stored in cache but isn't used during this session.
+      // It will kick in for the next reload.
+      Statsig.updateUser(currentStatsigUser)
+    }
+    const id = setInterval(refresh, 3 * 60e3 /* 3 min */)
+    return () => clearInterval(id)
+  }, [currentStatsigUser])
+
+  return (
+    <StatsigProvider
+      sdkKey="client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV"
+      mountKey={currentStatsigUser.userID}
+      user={currentStatsigUser}
+      // This isn't really blocking due to short initTimeoutMs above.
+      // However, it ensures `isLoading` is always `false`.
+      waitForInitialization={true}
+      options={statsigOptions}>
+      {children}
+    </StatsigProvider>
+  )
+}
diff --git a/src/lib/strings/display-names.ts b/src/lib/strings/display-names.ts
index 75383dd4f..e0f23fa2c 100644
--- a/src/lib/strings/display-names.ts
+++ b/src/lib/strings/display-names.ts
@@ -1,5 +1,4 @@
 import {ModerationUI} from '@atproto/api'
-import {describeModerationCause} from '../moderation'
 
 // \u2705 = ✅
 // \u2713 = ✓
@@ -14,7 +13,7 @@ export function sanitizeDisplayName(
   moderation?: ModerationUI,
 ): string {
   if (moderation?.blur) {
-    return `⚠${describeModerationCause(moderation.cause, 'account').name}`
+    return ''
   }
   if (typeof str === 'string') {
     return str.replace(CHECK_MARKS_RE, '').replace(CONTROL_CHARS_RE, '').trim()
diff --git a/src/lib/strings/embed-player.ts b/src/lib/strings/embed-player.ts
index 21a575b91..ee7328478 100644
--- a/src/lib/strings/embed-player.ts
+++ b/src/lib/strings/embed-player.ts
@@ -2,6 +2,15 @@ import {Dimensions} from 'react-native'
 import {isWeb} from 'platform/detection'
 const {height: SCREEN_HEIGHT} = Dimensions.get('window')
 
+const IFRAME_HOST = isWeb
+  ? // @ts-ignore only for web
+    window.location.host === 'localhost:8100'
+    ? 'http://localhost:8100'
+    : 'https://bsky.app'
+  : __DEV__ && !process.env.JEST_WORKER_ID
+  ? 'http://localhost:8100'
+  : 'https://bsky.app'
+
 export const embedPlayerSources = [
   'youtube',
   'youtubeShorts',
@@ -74,7 +83,7 @@ export function parseEmbedPlayerFromUrl(
       return {
         type: 'youtube_video',
         source: 'youtube',
-        playerUri: `https://bsky.app/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
+        playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
       }
     }
   }
@@ -93,7 +102,7 @@ export function parseEmbedPlayerFromUrl(
         type: page === 'shorts' ? 'youtube_short' : 'youtube_video',
         source: page === 'shorts' ? 'youtubeShorts' : 'youtube',
         hideDetails: page === 'shorts' ? true : undefined,
-        playerUri: `https://bsky.app/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
+        playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
       }
     }
   }
@@ -343,45 +352,45 @@ export function parseEmbedPlayerFromUrl(
   }
 }
 
-export function getPlayerHeight({
+export function getPlayerAspect({
   type,
-  width,
   hasThumb,
+  width,
 }: {
   type: EmbedPlayerParams['type']
-  width: number
   hasThumb: boolean
-}) {
-  if (!hasThumb) return (width / 16) * 9
+  width: number
+}): {aspectRatio?: number; height?: number} {
+  if (!hasThumb) return {aspectRatio: 16 / 9}
 
   switch (type) {
     case 'youtube_video':
     case 'twitch_video':
     case 'vimeo_video':
-      return (width / 16) * 9
+      return {aspectRatio: 16 / 9}
     case 'youtube_short':
       if (SCREEN_HEIGHT < 600) {
-        return ((width / 9) * 16) / 1.75
+        return {aspectRatio: (9 / 16) * 1.75}
       } else {
-        return ((width / 9) * 16) / 1.5
+        return {aspectRatio: (9 / 16) * 1.5}
       }
     case 'spotify_album':
     case 'apple_music_album':
     case 'apple_music_playlist':
     case 'spotify_playlist':
     case 'soundcloud_set':
-      return 380
+      return {height: 380}
     case 'spotify_song':
       if (width <= 300) {
-        return 155
+        return {height: 155}
       }
-      return 232
+      return {height: 232}
     case 'soundcloud_track':
-      return 165
+      return {height: 165}
     case 'apple_music_song':
-      return 150
+      return {height: 150}
     default:
-      return width
+      return {aspectRatio: 16 / 9}
   }
 }
 
diff --git a/src/lib/strings/handles.ts b/src/lib/strings/handles.ts
index 6ce462435..a18fef453 100644
--- a/src/lib/strings/handles.ts
+++ b/src/lib/strings/handles.ts
@@ -1,3 +1,8 @@
+// Regex from the go implementation
+// https://github.com/bluesky-social/indigo/blob/main/atproto/syntax/handle.go#L10
+const VALIDATE_REGEX =
+  /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
+
 export function makeValidHandle(str: string): string {
   if (str.length > 20) {
     str = str.slice(0, 20)
@@ -19,3 +24,27 @@ export function isInvalidHandle(handle: string): boolean {
 export function sanitizeHandle(handle: string, prefix = ''): string {
   return isInvalidHandle(handle) ? '⚠Invalid Handle' : `${prefix}${handle}`
 }
+
+export interface IsValidHandle {
+  handleChars: boolean
+  frontLength: boolean
+  totalLength: boolean
+  overall: boolean
+}
+
+// More checks from https://github.com/bluesky-social/atproto/blob/main/packages/pds/src/handle/index.ts#L72
+export function validateHandle(str: string, userDomain: string): IsValidHandle {
+  const fullHandle = createFullHandle(str, userDomain)
+
+  const results = {
+    handleChars:
+      !str || (VALIDATE_REGEX.test(fullHandle) && !str.includes('.')),
+    frontLength: str.length >= 3,
+    totalLength: fullHandle.length <= 253,
+  }
+
+  return {
+    ...results,
+    overall: !Object.values(results).includes(false),
+  }
+}
diff --git a/src/lib/strings/helpers.ts b/src/lib/strings/helpers.ts
index e2abe9019..de4562d2c 100644
--- a/src/lib/strings/helpers.ts
+++ b/src/lib/strings/helpers.ts
@@ -8,10 +8,27 @@ export function pluralize(n: number, base: string, plural?: string): string {
   return base + 's'
 }
 
-export function enforceLen(str: string, len: number, ellipsis = false): string {
+export function enforceLen(
+  str: string,
+  len: number,
+  ellipsis = false,
+  mode: 'end' | 'middle' = 'end',
+): string {
   str = str || ''
   if (str.length > len) {
-    return str.slice(0, len) + (ellipsis ? '...' : '')
+    if (ellipsis) {
+      if (mode === 'end') {
+        return str.slice(0, len) + '…'
+      } else if (mode === 'middle') {
+        const half = Math.floor(len / 2)
+        return str.slice(0, half) + '…' + str.slice(-half)
+      } else {
+        // fallback
+        return str.slice(0, len)
+      }
+    } else {
+      return str.slice(0, len)
+    }
   }
   return str
 }
diff --git a/src/lib/strings/time.ts b/src/lib/strings/time.ts
index 05a60e94b..3e162af1a 100644
--- a/src/lib/strings/time.ts
+++ b/src/lib/strings/time.ts
@@ -23,7 +23,7 @@ export function ago(date: number | string | Date): string {
   } else if (diffSeconds < DAY) {
     return `${Math.floor(diffSeconds / HOUR)}h`
   } else if (diffSeconds < MONTH) {
-    return `${Math.floor(diffSeconds / DAY)}d`
+    return `${Math.round(diffSeconds / DAY)}d`
   } else if (diffSeconds < YEAR) {
     return `${Math.floor(diffSeconds / MONTH)}mo`
   } else {
diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts
index 8a71718c8..70a2b7069 100644
--- a/src/lib/strings/url-helpers.ts
+++ b/src/lib/strings/url-helpers.ts
@@ -1,8 +1,27 @@
 import {AtUri} from '@atproto/api'
-import {PROD_SERVICE} from 'lib/constants'
+import {BSKY_SERVICE} from 'lib/constants'
 import TLDs from 'tlds'
 import psl from 'psl'
 
+export const BSKY_APP_HOST = 'https://bsky.app'
+const BSKY_TRUSTED_HOSTS = [
+  'bsky.app',
+  'bsky.social',
+  'blueskyweb.xyz',
+  'blueskyweb.zendesk.com',
+  ...(__DEV__ ? ['localhost:19006', 'localhost:8100'] : []),
+]
+
+/*
+ * This will allow any BSKY_TRUSTED_HOSTS value by itself or with a subdomain.
+ * It will also allow relative paths like /profile as well as #.
+ */
+const TRUSTED_REGEX = new RegExp(
+  `^(http(s)?://(([\\w-]+\\.)?${BSKY_TRUSTED_HOSTS.join(
+    '|([\\w-]+\\.)?',
+  )})|/|#)`,
+)
+
 export function isValidDomain(str: string): boolean {
   return !!TLDs.find(tld => {
     let i = str.lastIndexOf(tld)
@@ -28,7 +47,7 @@ export function makeRecordUri(
 export function toNiceDomain(url: string): string {
   try {
     const urlp = new URL(url)
-    if (`https://${urlp.host}` === PROD_SERVICE) {
+    if (`https://${urlp.host}` === BSKY_SERVICE) {
       return 'Bluesky Social'
     }
     return urlp.host ? urlp.host : url
@@ -67,8 +86,25 @@ export function isBskyAppUrl(url: string): boolean {
   return url.startsWith('https://bsky.app/')
 }
 
+export function isRelativeUrl(url: string): boolean {
+  return /^\/[^/]/.test(url)
+}
+
+export function isBskyRSSUrl(url: string): boolean {
+  return (
+    (url.startsWith('https://bsky.app/') || isRelativeUrl(url)) &&
+    /\/rss\/?$/.test(url)
+  )
+}
+
 export function isExternalUrl(url: string): boolean {
-  return !isBskyAppUrl(url) && url.startsWith('http')
+  const external = !isBskyAppUrl(url) && url.startsWith('http')
+  const rss = isBskyRSSUrl(url)
+  return external || rss
+}
+
+export function isTrustedUrl(url: string): boolean {
+  return TRUSTED_REGEX.test(url)
 }
 
 export function isBskyPostUrl(url: string): boolean {
@@ -148,6 +184,11 @@ export function feedUriToHref(url: string): string {
 export function linkRequiresWarning(uri: string, label: string) {
   const labelDomain = labelToDomain(label)
 
+  // We should trust any relative URL or a # since we know it links to internal content
+  if (isRelativeUrl(uri) || uri === '#') {
+    return false
+  }
+
   let urip
   try {
     urip = new URL(uri)
@@ -156,21 +197,11 @@ export function linkRequiresWarning(uri: string, label: string) {
   }
 
   const host = urip.hostname.toLowerCase()
-
-  if (host === 'bsky.app') {
-    // if this is a link to internal content,
-    // warn if it represents itself as a URL to another app
-    if (
-      labelDomain &&
-      labelDomain !== 'bsky.app' &&
-      isPossiblyAUrl(labelDomain)
-    ) {
-      return true
-    }
-    return false
+  if (isTrustedUrl(uri)) {
+    // if this is a link to internal content, warn if it represents itself as a URL to another app
+    return !!labelDomain && labelDomain !== host && isPossiblyAUrl(labelDomain)
   } else {
-    // if this is a link to external content,
-    // warn if the label doesnt match the target
+    // if this is a link to external content, warn if the label doesnt match the target
     if (!labelDomain) {
       return true
     }
@@ -220,3 +251,8 @@ export function splitApexDomain(hostname: string): [string, string] {
     hostnamep.domain,
   ]
 }
+
+export function createBskyAppAbsoluteUrl(path: string): string {
+  const sanitizedPath = path.replace(BSKY_APP_HOST, '').replace(/^\/+/, '')
+  return `${BSKY_APP_HOST.replace(/\/$/, '')}/${sanitizedPath}`
+}
diff --git a/src/lib/themes.ts b/src/lib/themes.ts
index f75ac8ab4..6fada40a7 100644
--- a/src/lib/themes.ts
+++ b/src/lib/themes.ts
@@ -9,7 +9,7 @@ export const defaultTheme: Theme = {
   palette: {
     default: {
       background: lightPalette.white,
-      backgroundLight: lightPalette.contrast_50,
+      backgroundLight: lightPalette.contrast_25,
       text: lightPalette.black,
       textLight: lightPalette.contrast_700,
       textInverted: lightPalette.white,
@@ -306,7 +306,7 @@ export const darkTheme: Theme = {
 
       // non-standard
       textVeryLight: darkPalette.contrast_400,
-      replyLine: darkPalette.contrast_100,
+      replyLine: darkPalette.contrast_200,
       replyLineDot: darkPalette.contrast_200,
       unreadNotifBg: darkPalette.primary_975,
       unreadNotifBorder: darkPalette.primary_900,
@@ -355,7 +355,7 @@ export const dimTheme: Theme = {
 
       // non-standard
       textVeryLight: dimPalette.contrast_400,
-      replyLine: dimPalette.contrast_100,
+      replyLine: dimPalette.contrast_200,
       replyLineDot: dimPalette.contrast_200,
       unreadNotifBg: dimPalette.primary_975,
       unreadNotifBorder: dimPalette.primary_900,