about summary refs log tree commit diff
path: root/src/lib
diff options
context:
space:
mode:
authorJaz <ericvolp12@gmail.com>2023-05-30 18:25:29 -0700
committerGitHub <noreply@github.com>2023-05-30 18:25:29 -0700
commit09ade363fdcfadb03433385e0c5510bc58438a65 (patch)
tree710af28d1eb7f70acf81f86acb44759439e164fc /src/lib
parent7f76c2d67e62ba2d10e8b17673a7bbcf7248564f (diff)
parente224569a11b82361d782324a63bdfc19d44a3201 (diff)
downloadvoidsky-09ade363fdcfadb03433385e0c5510bc58438a65.tar.zst
Merge branch 'main' into inherit_system_theme
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/api/feed-manip.ts3
-rw-r--r--src/lib/api/index.ts94
-rw-r--r--src/lib/app-info.ts4
-rw-r--r--src/lib/app-info.web.ts5
-rw-r--r--src/lib/async/revertible.ts16
-rw-r--r--src/lib/constants.ts60
-rw-r--r--src/lib/haptics.ts40
-rw-r--r--src/lib/hooks/useCustomFeed.ts27
-rw-r--r--src/lib/hooks/useDraggableScrollView.ts84
-rw-r--r--src/lib/hooks/useNavigationTabState.ts4
-rw-r--r--src/lib/hooks/useOnMainScroll.ts61
-rw-r--r--src/lib/icons.tsx82
-rw-r--r--src/lib/labeling/const.ts2
-rw-r--r--src/lib/link-meta/bsky.ts27
-rw-r--r--src/lib/link-meta/link-meta.ts38
-rw-r--r--src/lib/media/manip.ts44
-rw-r--r--src/lib/media/manip.web.ts19
-rw-r--r--src/lib/media/picker.e2e.tsx4
-rw-r--r--src/lib/media/util.ts17
-rw-r--r--src/lib/merge-refs.ts27
-rw-r--r--src/lib/notifee.ts30
-rw-r--r--src/lib/routes/helpers.ts2
-rw-r--r--src/lib/routes/types.ts12
-rw-r--r--src/lib/strings/rich-text-detection.ts2
-rw-r--r--src/lib/strings/time.ts14
-rw-r--r--src/lib/strings/url-helpers.ts12
-rw-r--r--src/lib/styles.ts9
27 files changed, 542 insertions, 197 deletions
diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts
index 7f31fb292..035b36096 100644
--- a/src/lib/api/feed-manip.ts
+++ b/src/lib/api/feed-manip.ts
@@ -143,9 +143,6 @@ export class FeedTuner {
       }
     }
 
-    // sort by slice roots' timestamps
-    slices.sort((a, b) => b.ts.localeCompare(a.ts))
-
     for (const slice of slices) {
       for (const item of slice.items) {
         this.seenUris.add(item.post.uri)
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index 3877b3ef7..6235ca343 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -18,6 +18,7 @@ export interface ExternalEmbedDraft {
   uri: string
   isLoading: boolean
   meta?: LinkMeta
+  embed?: AppBskyEmbedRecord.Main
   localThumb?: ImageModel
 }
 
@@ -109,6 +110,7 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
     const images: AppBskyEmbedImages.Image[] = []
     for (const image of opts.images) {
       opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
+      await image.compress()
       const path = image.compressed?.path ?? image.path
       const res = await uploadBlob(store, path, 'image/jpeg')
       images.push({
@@ -135,40 +137,54 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
   }
 
   if (opts.extLink && !opts.images?.length) {
-    let thumb
-    if (opts.extLink.localThumb) {
-      opts.onStateChange?.('Uploading link thumbnail...')
-      let encoding
-      if (opts.extLink.localThumb.mime) {
-        encoding = opts.extLink.localThumb.mime
-      } else if (opts.extLink.localThumb.path.endsWith('.png')) {
-        encoding = 'image/png'
-      } else if (
-        opts.extLink.localThumb.path.endsWith('.jpeg') ||
-        opts.extLink.localThumb.path.endsWith('.jpg')
-      ) {
-        encoding = 'image/jpeg'
-      } else {
-        store.log.warn(
-          'Unexpected image format for thumbnail, skipping',
-          opts.extLink.localThumb.path,
-        )
-      }
-      if (encoding) {
-        const thumbUploadRes = await uploadBlob(
-          store,
-          opts.extLink.localThumb.path,
-          encoding,
-        )
-        thumb = thumbUploadRes.data.blob
+    if (opts.extLink.embed) {
+      embed = opts.extLink.embed
+    } else {
+      let thumb
+      if (opts.extLink.localThumb) {
+        opts.onStateChange?.('Uploading link thumbnail...')
+        let encoding
+        if (opts.extLink.localThumb.mime) {
+          encoding = opts.extLink.localThumb.mime
+        } else if (opts.extLink.localThumb.path.endsWith('.png')) {
+          encoding = 'image/png'
+        } else if (
+          opts.extLink.localThumb.path.endsWith('.jpeg') ||
+          opts.extLink.localThumb.path.endsWith('.jpg')
+        ) {
+          encoding = 'image/jpeg'
+        } else {
+          store.log.warn(
+            'Unexpected image format for thumbnail, skipping',
+            opts.extLink.localThumb.path,
+          )
+        }
+        if (encoding) {
+          const thumbUploadRes = await uploadBlob(
+            store,
+            opts.extLink.localThumb.path,
+            encoding,
+          )
+          thumb = thumbUploadRes.data.blob
+        }
       }
-    }
 
-    if (opts.quote) {
-      embed = {
-        $type: 'app.bsky.embed.recordWithMedia',
-        record: embed,
-        media: {
+      if (opts.quote) {
+        embed = {
+          $type: 'app.bsky.embed.recordWithMedia',
+          record: embed,
+          media: {
+            $type: 'app.bsky.embed.external',
+            external: {
+              uri: opts.extLink.uri,
+              title: opts.extLink.meta?.title || '',
+              description: opts.extLink.meta?.description || '',
+              thumb,
+            },
+          } as AppBskyEmbedExternal.Main,
+        } as AppBskyEmbedRecordWithMedia.Main
+      } else {
+        embed = {
           $type: 'app.bsky.embed.external',
           external: {
             uri: opts.extLink.uri,
@@ -176,18 +192,8 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
             description: opts.extLink.meta?.description || '',
             thumb,
           },
-        } as AppBskyEmbedExternal.Main,
-      } as AppBskyEmbedRecordWithMedia.Main
-    } else {
-      embed = {
-        $type: 'app.bsky.embed.external',
-        external: {
-          uri: opts.extLink.uri,
-          title: opts.extLink.meta?.title || '',
-          description: opts.extLink.meta?.description || '',
-          thumb,
-        },
-      } as AppBskyEmbedExternal.Main
+        } as AppBskyEmbedExternal.Main
+      }
     }
   }
 
diff --git a/src/lib/app-info.ts b/src/lib/app-info.ts
index 1ced274e7..a365e7e9f 100644
--- a/src/lib/app-info.ts
+++ b/src/lib/app-info.ts
@@ -1,4 +1,2 @@
 import VersionNumber from 'react-native-version-number'
-
-export const appVersion = VersionNumber.appVersion
-export const buildVersion = VersionNumber.buildVersion
+export const appVersion = `${VersionNumber.appVersion} (${VersionNumber.buildVersion})`
diff --git a/src/lib/app-info.web.ts b/src/lib/app-info.web.ts
index a2b6858da..5739b8783 100644
--- a/src/lib/app-info.web.ts
+++ b/src/lib/app-info.web.ts
@@ -1,3 +1,2 @@
-// TODO
-export const appVersion = 'TODO'
-export const buildVersion = 'TODO'
+import {version} from '../../package.json'
+export const appVersion = version
diff --git a/src/lib/async/revertible.ts b/src/lib/async/revertible.ts
index 3c8e3e8f9..43383b61e 100644
--- a/src/lib/async/revertible.ts
+++ b/src/lib/async/revertible.ts
@@ -4,6 +4,22 @@ import set from 'lodash.set'
 
 const ongoingActions = new Set<any>()
 
+/**
+ * This is a TypeScript function that optimistically updates data on the client-side before sending a
+ * request to the server and rolling back changes if the request fails.
+ * @param {T} model - The object or record that needs to be updated optimistically.
+ * @param preUpdate - `preUpdate` is a function that is called before the server update is executed. It
+ * can be used to perform any necessary actions or updates on the model or UI before the server update
+ * is initiated.
+ * @param serverUpdate - `serverUpdate` is a function that returns a Promise representing the server
+ * update operation. This function is called after the previous state of the model has been recorded
+ * and the `preUpdate` function has been executed. If the server update is successful, the `postUpdate`
+ * function is called with the result
+ * @param [postUpdate] - `postUpdate` is an optional callback function that will be called after the
+ * server update is successful. It takes in the response from the server update as its parameter. If
+ * this parameter is not provided, nothing will happen after the server update.
+ * @returns A Promise that resolves to `void`.
+ */
 export const updateDataOptimistically = async <
   T extends Record<string, any>,
   U,
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 6d0d4797b..170fe640f 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -4,6 +4,8 @@ export const FEEDBACK_FORM_URL =
 export const MAX_DISPLAY_NAME = 64
 export const MAX_DESCRIPTION = 256
 
+export const MAX_GRAPHEME_LENGTH = 300
+
 // Recommended is 100 per: https://www.w3.org/WAI/GL/WCAG20/tests/test3.html
 // but increasing limit per user feedback
 export const MAX_ALT_TEXT = 1000
@@ -94,8 +96,66 @@ export function SUGGESTED_FOLLOWS(serviceUrl: string) {
   }
 }
 
+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>,
+) {
+  if (serviceUrl.includes('localhost')) {
+    // local dev
+    const aliceDid = await resolveHandle('alice.test')
+    return {
+      pinned: [`at://${aliceDid}/app.bsky.feed.generator/alice-favs`],
+      saved: [`at://${aliceDid}/app.bsky.feed.generator/alice-favs`],
+    }
+  } else if (serviceUrl.includes('staging')) {
+    // 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'),
+        PROD_DEFAULT_FEED('with-friends'),
+      ],
+      saved: [
+        PROD_DEFAULT_FEED('bsky-team'),
+        PROD_DEFAULT_FEED('with-friends'),
+        PROD_DEFAULT_FEED('whats-hot'),
+        PROD_DEFAULT_FEED('hot-classic'),
+      ],
+    }
+  }
+}
+
 export const POST_IMG_MAX = {
   width: 2000,
   height: 2000,
   size: 1000000,
 }
+
+export const STAGING_LINK_META_PROXY =
+  'https://cardyb.staging.bsky.dev/v1/extract?url='
+
+export const PROD_LINK_META_PROXY = 'https://cardyb.bsky.app/v1/extract?url='
+
+export function LINK_META_PROXY(serviceUrl: string) {
+  if (serviceUrl.includes('localhost')) {
+    return STAGING_LINK_META_PROXY
+  } else if (serviceUrl.includes('staging')) {
+    return STAGING_LINK_META_PROXY
+  } else {
+    return PROD_LINK_META_PROXY
+  }
+}
diff --git a/src/lib/haptics.ts b/src/lib/haptics.ts
new file mode 100644
index 000000000..516940c1c
--- /dev/null
+++ b/src/lib/haptics.ts
@@ -0,0 +1,40 @@
+import {isIOS, isWeb} from 'platform/detection'
+import ReactNativeHapticFeedback, {
+  HapticFeedbackTypes,
+} from 'react-native-haptic-feedback'
+
+const hapticImpact: HapticFeedbackTypes = isIOS ? 'impactMedium' : 'impactLight' // Users said the medium impact was too strong on Android; see APP-537s
+
+export class Haptics {
+  static default() {
+    if (isWeb) {
+      return
+    }
+    ReactNativeHapticFeedback.trigger(hapticImpact)
+  }
+  static impact(type: HapticFeedbackTypes = hapticImpact) {
+    if (isWeb) {
+      return
+    }
+    ReactNativeHapticFeedback.trigger(type)
+  }
+  static selection() {
+    if (isWeb) {
+      return
+    }
+    ReactNativeHapticFeedback.trigger('selection')
+  }
+  static notification = (type: 'success' | 'warning' | 'error') => {
+    if (isWeb) {
+      return
+    }
+    switch (type) {
+      case 'success':
+        return ReactNativeHapticFeedback.trigger('notificationSuccess')
+      case 'warning':
+        return ReactNativeHapticFeedback.trigger('notificationWarning')
+      case 'error':
+        return ReactNativeHapticFeedback.trigger('notificationError')
+    }
+  }
+}
diff --git a/src/lib/hooks/useCustomFeed.ts b/src/lib/hooks/useCustomFeed.ts
new file mode 100644
index 000000000..d7a27050d
--- /dev/null
+++ b/src/lib/hooks/useCustomFeed.ts
@@ -0,0 +1,27 @@
+import {useEffect, useState} from 'react'
+import {useStores} from 'state/index'
+import {CustomFeedModel} from 'state/models/feeds/custom-feed'
+
+export function useCustomFeed(uri: string): CustomFeedModel | undefined {
+  const store = useStores()
+  const [item, setItem] = useState<CustomFeedModel | undefined>()
+  useEffect(() => {
+    async function fetchView() {
+      const res = await store.agent.app.bsky.feed.getFeedGenerator({
+        feed: uri,
+      })
+      const view = res.data.view
+      return view
+    }
+    async function buildFeedItem() {
+      const view = await fetchView()
+      if (view) {
+        const temp = new CustomFeedModel(store, view)
+        setItem(temp)
+      }
+    }
+    buildFeedItem()
+  }, [store, uri])
+
+  return item
+}
diff --git a/src/lib/hooks/useDraggableScrollView.ts b/src/lib/hooks/useDraggableScrollView.ts
new file mode 100644
index 000000000..b0f7465d7
--- /dev/null
+++ b/src/lib/hooks/useDraggableScrollView.ts
@@ -0,0 +1,84 @@
+import {useEffect, useRef, useMemo, ForwardedRef} from 'react'
+import {Platform, findNodeHandle} from 'react-native'
+import type {ScrollView} from 'react-native'
+import {mergeRefs} from 'lib/merge-refs'
+
+type Props<Scrollable extends ScrollView = ScrollView> = {
+  cursor?: string
+  outerRef?: ForwardedRef<Scrollable>
+}
+
+export function useDraggableScroll<Scrollable extends ScrollView = ScrollView>({
+  outerRef,
+  cursor = 'grab',
+}: Props<Scrollable> = {}) {
+  const ref = useRef<Scrollable>(null)
+
+  useEffect(() => {
+    if (Platform.OS !== 'web' || !ref.current) {
+      return
+    }
+    const slider = findNodeHandle(ref.current) as unknown as HTMLDivElement
+    if (!slider) {
+      return
+    }
+    let isDragging = false
+    let isMouseDown = false
+    let startX = 0
+    let scrollLeft = 0
+
+    const mouseDown = (e: MouseEvent) => {
+      isMouseDown = true
+      startX = e.pageX - slider.offsetLeft
+      scrollLeft = slider.scrollLeft
+
+      slider.style.cursor = cursor
+    }
+
+    const mouseUp = () => {
+      if (isDragging) {
+        slider.addEventListener('click', e => e.stopPropagation(), {once: true})
+      }
+
+      isMouseDown = false
+      isDragging = false
+      slider.style.cursor = 'default'
+    }
+
+    const mouseMove = (e: MouseEvent) => {
+      if (!isMouseDown) {
+        return
+      }
+
+      // Require n pixels momement before start of drag (3 in this case )
+      const x = e.pageX - slider.offsetLeft
+      if (Math.abs(x - startX) < 3) {
+        return
+      }
+
+      isDragging = true
+      e.preventDefault()
+      const walk = x - startX
+      slider.scrollLeft = scrollLeft - walk
+    }
+
+    slider.addEventListener('mousedown', mouseDown)
+    window.addEventListener('mouseup', mouseUp)
+    window.addEventListener('mousemove', mouseMove)
+
+    return () => {
+      slider.removeEventListener('mousedown', mouseDown)
+      window.removeEventListener('mouseup', mouseUp)
+      window.removeEventListener('mousemove', mouseMove)
+    }
+  }, [cursor])
+
+  const refs = useMemo(
+    () => mergeRefs(outerRef ? [ref, outerRef] : [ref]),
+    [ref, outerRef],
+  )
+
+  return {
+    refs,
+  }
+}
diff --git a/src/lib/hooks/useNavigationTabState.ts b/src/lib/hooks/useNavigationTabState.ts
index fb3662152..3a05fe524 100644
--- a/src/lib/hooks/useNavigationTabState.ts
+++ b/src/lib/hooks/useNavigationTabState.ts
@@ -6,14 +6,16 @@ export function useNavigationTabState() {
     const res = {
       isAtHome: getTabState(state, 'Home') !== TabState.Outside,
       isAtSearch: getTabState(state, 'Search') !== TabState.Outside,
+      isAtFeeds: getTabState(state, 'Feeds') !== TabState.Outside,
       isAtNotifications:
         getTabState(state, 'Notifications') !== TabState.Outside,
       isAtMyProfile: getTabState(state, 'MyProfile') !== TabState.Outside,
     }
     if (
       !res.isAtHome &&
-      !res.isAtNotifications &&
       !res.isAtSearch &&
+      !res.isAtFeeds &&
+      !res.isAtNotifications &&
       !res.isAtMyProfile
     ) {
       // HACK for some reason useNavigationState will give us pre-hydration results
diff --git a/src/lib/hooks/useOnMainScroll.ts b/src/lib/hooks/useOnMainScroll.ts
index 41b35dd4f..12e42aca5 100644
--- a/src/lib/hooks/useOnMainScroll.ts
+++ b/src/lib/hooks/useOnMainScroll.ts
@@ -1,25 +1,56 @@
-import {useState} from 'react'
+import {useState, useCallback, useRef} from 'react'
 import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
 import {RootStoreModel} from 'state/index'
+import {s} from 'lib/styles'
+import {isDesktopWeb} from 'platform/detection'
+
+const DY_LIMIT = isDesktopWeb ? 30 : 10
 
 export type OnScrollCb = (
   event: NativeSyntheticEvent<NativeScrollEvent>,
 ) => void
+export type ResetCb = () => void
+
+export function useOnMainScroll(
+  store: RootStoreModel,
+): [OnScrollCb, boolean, ResetCb] {
+  let lastY = useRef(0)
+  let [isScrolledDown, setIsScrolledDown] = useState(false)
+  return [
+    useCallback(
+      (event: NativeSyntheticEvent<NativeScrollEvent>) => {
+        const y = event.nativeEvent.contentOffset.y
+        const dy = y - (lastY.current || 0)
+        lastY.current = y
 
-export function useOnMainScroll(store: RootStoreModel) {
-  let [lastY, setLastY] = useState(0)
-  let isMinimal = store.shell.minimalShellMode
-  return function onMainScroll(event: NativeSyntheticEvent<NativeScrollEvent>) {
-    const y = event.nativeEvent.contentOffset.y
-    const dy = y - (lastY || 0)
-    setLastY(y)
+        if (!store.shell.minimalShellMode && y > 10 && dy > DY_LIMIT) {
+          store.shell.setMinimalShellMode(true)
+        } else if (
+          store.shell.minimalShellMode &&
+          (y <= 10 || dy < DY_LIMIT * -1)
+        ) {
+          store.shell.setMinimalShellMode(false)
+        }
 
-    if (!isMinimal && y > 10 && dy > 10) {
-      store.shell.setMinimalShellMode(true)
-      isMinimal = true
-    } else if (isMinimal && (y <= 10 || dy < -10)) {
+        if (
+          !isScrolledDown &&
+          event.nativeEvent.contentOffset.y > s.window.height
+        ) {
+          setIsScrolledDown(true)
+        } else if (
+          isScrolledDown &&
+          event.nativeEvent.contentOffset.y < s.window.height
+        ) {
+          setIsScrolledDown(false)
+        }
+      },
+      [store, isScrolledDown],
+    ),
+    isScrolledDown,
+    useCallback(() => {
+      setIsScrolledDown(false)
       store.shell.setMinimalShellMode(false)
-      isMinimal = false
-    }
-  }
+      lastY.current = 1e8 // NOTE we set this very high so that the onScroll logic works right -prf
+    }, [store, setIsScrolledDown]),
+  ]
 }
diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx
index 06f195011..8fa8e11d5 100644
--- a/src/lib/icons.tsx
+++ b/src/lib/icons.tsx
@@ -1,6 +1,6 @@
 import React from 'react'
 import {StyleProp, TextStyle, ViewStyle} from 'react-native'
-import Svg, {Path, Rect, Line, Ellipse} from 'react-native-svg'
+import Svg, {Path, Rect, Line, Ellipse, Circle} from 'react-native-svg'
 
 export function GridIcon({
   style,
@@ -88,7 +88,7 @@ export function HomeIconSolid({
       <Path
         fill="currentColor"
         strokeWidth={strokeWidth}
-        d="M 23.951 2 C 23.631 2.011 23.323 2.124 23.072 2.322 L 8.859 13.52 C 7.055 14.941 6 17.114 6 19.41 L 6 38.5 C 6 39.864 7.136 41 8.5 41 L 18.5 41 C 19.864 41 21 39.864 21 38.5 L 21 28.5 C 21 28.205 21.205 28 21.5 28 L 26.5 28 C 26.795 28 27 28.205 27 28.5 L 27 38.5 C 27 39.864 28.136 41 29.5 41 L 39.5 41 C 40.864 41 42 39.864 42 38.5 L 42 19.41 C 42 17.114 40.945 14.941 39.141 13.52 L 24.928 2.322 C 24.65 2.103 24.304 1.989 23.951 2 Z"
+        d="m 23.951,2 c -0.32,0.011 -0.628,0.124 -0.879,0.322 L 8.859,13.52 C 7.055,14.941 6,17.114 6,19.41 V 38.5 C 6,39.864 7.136,41 8.5,41 h 8 c 1.364,0 2.5,-1.136 2.5,-2.5 v -12 C 19,26.205 19.205,26 19.5,26 h 9 c 0.295,0 0.5,0.205 0.5,0.5 v 12 c 0,1.364 1.136,2.5 2.5,2.5 h 8 C 40.864,41 42,39.864 42,38.5 V 19.41 c 0,-2.296 -1.055,-4.469 -2.859,-5.89 L 24.928,2.322 C 24.65,2.103 24.304,1.989 23.951,2 Z"
       />
     </Svg>
   )
@@ -472,7 +472,7 @@ export function HeartIcon({
   size = 24,
   strokeWidth = 1.5,
 }: {
-  style?: StyleProp<ViewStyle>
+  style?: StyleProp<TextStyle>
   size?: string | number
   strokeWidth: number
 }) {
@@ -493,7 +493,7 @@ export function HeartIconSolid({
   style,
   size = 24,
 }: {
-  style?: StyleProp<ViewStyle>
+  style?: StyleProp<TextStyle>
   size?: string | number
 }) {
   return (
@@ -883,3 +883,77 @@ export function HandIcon({
     </Svg>
   )
 }
+
+export function SatelliteDishIconSolid({
+  style,
+  size,
+  strokeWidth = 1.5,
+}: {
+  style?: StyleProp<ViewStyle>
+  size?: string | number
+  strokeWidth?: number
+}) {
+  return (
+    <Svg
+      width={size || 24}
+      height={size || 24}
+      viewBox="0 0 22 22"
+      style={style}
+      fill="none"
+      stroke="none">
+      <Path
+        d="M16 19.6622C14.5291 20.513 12.8214 21 11 21C5.47715 21 1 16.5229 1 11C1 9.17858 1.48697 7.47088 2.33782 6.00002C3.18867 4.52915 6 7.66219 6 7.66219L14.5 16.1622C14.5 16.1622 17.4709 18.8113 16 19.6622Z"
+        fill="currentColor"
+      />
+      <Path
+        d="M8 1.62961C9.04899 1.22255 10.1847 1 11.3704 1C16.6887 1 21 5.47715 21 11C21 12.0452 20.8456 13.053 20.5592 14"
+        stroke="currentColor"
+        strokeWidth={strokeWidth}
+        strokeLinecap="round"
+      />
+      <Path
+        d="M9 5.38745C9.64553 5.13695 10.3444 5 11.0741 5C14.3469 5 17 7.75517 17 11.1538C17 11.797 16.905 12.4172 16.7287 13"
+        stroke="currentColor"
+        strokeWidth={strokeWidth}
+        strokeLinecap="round"
+      />
+      <Circle cx="10" cy="12" r="2" fill="currentColor" />
+    </Svg>
+  )
+}
+
+export function SatelliteDishIcon({
+  style,
+  size,
+  strokeWidth = 1.5,
+}: {
+  style?: StyleProp<TextStyle>
+  size?: string | number
+  strokeWidth?: number
+}) {
+  return (
+    <Svg
+      fill="none"
+      viewBox="0 0 22 22"
+      strokeWidth={strokeWidth}
+      stroke="currentColor"
+      width={size}
+      height={size}
+      style={style}>
+      <Path d="M5.25593 8.3303L5.25609 8.33047L5.25616 8.33056L5.25621 8.33061L5.27377 8.35018L5.29289 8.3693L13.7929 16.8693L13.8131 16.8895L13.8338 16.908L13.834 16.9081L13.8342 16.9083L13.8342 16.9083L13.8345 16.9086L13.8381 16.9118L13.8574 16.9294C13.8752 16.9458 13.9026 16.9711 13.9377 17.0043C14.0081 17.0708 14.1088 17.1683 14.2258 17.2881C14.4635 17.5315 14.7526 17.8509 14.9928 18.1812C15.2067 18.4755 15.3299 18.7087 15.3817 18.8634C14.0859 19.5872 12.5926 20 11 20C6.02944 20 2 15.9706 2 11C2 9.4151 2.40883 7.9285 3.12619 6.63699C3.304 6.69748 3.56745 6.84213 3.89275 7.08309C4.24679 7.34534 4.58866 7.65673 4.84827 7.9106C4.97633 8.03583 5.08062 8.14337 5.152 8.21863C5.18763 8.25619 5.21487 8.28551 5.23257 8.30473L5.25178 8.32572L5.25571 8.33006L5.25593 8.3303ZM3.00217 6.60712C3.00217 6.6071 3.00267 6.6071 3.00372 6.60715C3.00271 6.60716 3.00218 6.60714 3.00217 6.60712Z" />
+      <Path
+        d="M8 1.62961C9.04899 1.22255 10.1847 1 11.3704 1C16.6887 1 21 5.47715 21 11C21 12.0452 20.8456 13.053 20.5592 14"
+        stroke-linecap="round"
+      />
+      <Path
+        d="M9 5.38745C9.64553 5.13695 10.3444 5 11.0741 5C14.3469 5 17 7.75517 17 11.1538C17 11.797 16.905 12.4172 16.7287 13"
+        stroke-linecap="round"
+      />
+      <Path
+        d="M12 12C12 12.7403 11.5978 13.3866 11 13.7324L8.26756 11C8.61337 10.4022 9.25972 10 10 10C11.1046 10 12 10.8954 12 12Z"
+        fill="currentColor"
+        stroke="none"
+      />
+    </Svg>
+  )
+}
diff --git a/src/lib/labeling/const.ts b/src/lib/labeling/const.ts
index b26388123..e406d71ad 100644
--- a/src/lib/labeling/const.ts
+++ b/src/lib/labeling/const.ts
@@ -62,7 +62,7 @@ export const CONFIGURABLE_LABEL_GROUPS: Record<
     title: 'Violent / Bloody',
     subtitle: 'Gore, self-harm, torture',
     warning: 'Violence',
-    values: ['gore', 'self-harm', 'torture', 'nsfl'],
+    values: ['gore', 'self-harm', 'torture', 'nsfl', 'corpse'],
     isAdultImagery: true,
   },
   hate: {
diff --git a/src/lib/link-meta/bsky.ts b/src/lib/link-meta/bsky.ts
index f4a96a22f..cf43feca8 100644
--- a/src/lib/link-meta/bsky.ts
+++ b/src/lib/link-meta/bsky.ts
@@ -1,3 +1,4 @@
+import * as apilib from 'lib/api/index'
 import {LikelyType, LinkMeta} from './link-meta'
 // import {match as matchRoute} from 'view/routes'
 import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers'
@@ -128,3 +129,29 @@ export async function getPostAsQuote(
     },
   }
 }
+
+export async function getFeedAsEmbed(
+  store: RootStoreModel,
+  url: string,
+): Promise<apilib.ExternalEmbedDraft> {
+  url = convertBskyAppUrlIfNeeded(url)
+  const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
+  const feed = makeRecordUri(user, 'app.bsky.feed.generator', rkey)
+  const res = await store.agent.app.bsky.feed.getFeedGenerator({feed})
+  return {
+    isLoading: false,
+    uri: feed,
+    meta: {
+      url: feed,
+      likelyType: LikelyType.AtpData,
+      title: res.data.view.displayName,
+    },
+    embed: {
+      $type: 'app.bsky.embed.record',
+      record: {
+        uri: res.data.view.uri,
+        cid: res.data.view.cid,
+      },
+    },
+  }
+}
diff --git a/src/lib/link-meta/link-meta.ts b/src/lib/link-meta/link-meta.ts
index 6c4ad5384..6863798b4 100644
--- a/src/lib/link-meta/link-meta.ts
+++ b/src/lib/link-meta/link-meta.ts
@@ -1,8 +1,7 @@
-import he from 'he'
 import {isBskyAppUrl} from '../strings/url-helpers'
 import {RootStoreModel} from 'state/index'
 import {extractBskyMeta} from './bsky'
-import {extractHtmlMeta} from './html'
+import {LINK_META_PROXY} from 'lib/constants'
 
 export enum LikelyType {
   HTML,
@@ -54,26 +53,29 @@ export async function getLinkMeta(
   try {
     const controller = new AbortController()
     const to = setTimeout(() => controller.abort(), timeout || 5e3)
-    const httpRes = await fetch(url, {
-      headers: {accept: 'text/html'},
-      signal: controller.signal,
-    })
-    const httpResBody = await httpRes.text()
+
+    const response = await fetch(
+      `${LINK_META_PROXY(
+        store.session.currentSession?.service || '',
+      )}${encodeURIComponent(url)}`,
+    )
+
+    const body = await response.json()
     clearTimeout(to)
-    const httpResMeta = extractHtmlMeta({
-      html: httpResBody,
-      hostname: urlp?.hostname,
-      pathname: urlp?.pathname,
-    })
-    meta.title = httpResMeta.title ? he.decode(httpResMeta.title) : undefined
-    meta.description = httpResMeta.description
-      ? he.decode(httpResMeta.description)
-      : undefined
-    meta.image = httpResMeta.image
+
+    const {description, error, image, title} = body
+
+    if (error !== '') {
+      throw new Error(error)
+    }
+
+    meta.description = description
+    meta.image = image
+    meta.title = title
   } catch (e) {
     // failed
     console.error(e)
-    meta.error = 'Failed to fetch link'
+    meta.error = e instanceof Error ? e.toString() : 'Failed to fetch link'
   }
 
   return meta
diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts
index 4491010e8..c35953703 100644
--- a/src/lib/media/manip.ts
+++ b/src/lib/media/manip.ts
@@ -6,52 +6,8 @@ import * as RNFS from 'react-native-fs'
 import uuid from 'react-native-uuid'
 import * as Sharing from 'expo-sharing'
 import {Dimensions} from './types'
-import {POST_IMG_MAX} from 'lib/constants'
 import {isAndroid, isIOS} from 'platform/detection'
 
-export async function compressAndResizeImageForPost(
-  image: Image,
-): Promise<Image> {
-  const uri = `file://${image.path}`
-  let resized: Omit<Image, 'mime'>
-
-  for (let i = 0; i < 9; i++) {
-    const quality = 100 - i * 10
-
-    try {
-      resized = await ImageResizer.createResizedImage(
-        uri,
-        POST_IMG_MAX.width,
-        POST_IMG_MAX.height,
-        'JPEG',
-        quality,
-        undefined,
-        undefined,
-        undefined,
-        {mode: 'cover'},
-      )
-    } catch (err) {
-      throw new Error(`Failed to resize: ${err}`)
-    }
-
-    if (resized.size < POST_IMG_MAX.size) {
-      const path = await moveToPermanentPath(resized.path)
-
-      return {
-        path,
-        mime: 'image/jpeg',
-        size: resized.size,
-        height: resized.height,
-        width: resized.width,
-      }
-    }
-  }
-
-  throw new Error(
-    `This image is too big! We couldn't compress it down to ${POST_IMG_MAX.size} bytes`,
-  )
-}
-
 export async function compressIfNeeded(
   img: Image,
   maxSize: number = 1000000,
diff --git a/src/lib/media/manip.web.ts b/src/lib/media/manip.web.ts
index 85f6b6138..464802c32 100644
--- a/src/lib/media/manip.web.ts
+++ b/src/lib/media/manip.web.ts
@@ -1,25 +1,6 @@
 import {Dimensions} from './types'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import {getDataUriSize, blobToDataUri} from './util'
-import {POST_IMG_MAX} from 'lib/constants'
-
-export async function compressAndResizeImageForPost({
-  path,
-  width,
-  height,
-}: {
-  path: string
-  width: number
-  height: number
-}): Promise<RNImage> {
-  // Compression is handled in `doResize` via `quality`
-  return await doResize(path, {
-    width,
-    height,
-    maxSize: POST_IMG_MAX.size,
-    mode: 'stretch',
-  })
-}
 
 export async function compressIfNeeded(
   img: RNImage,
diff --git a/src/lib/media/picker.e2e.tsx b/src/lib/media/picker.e2e.tsx
index e53dc42be..9805c3464 100644
--- a/src/lib/media/picker.e2e.tsx
+++ b/src/lib/media/picker.e2e.tsx
@@ -2,7 +2,7 @@ import {RootStoreModel} from 'state/index'
 import {Image as RNImage} from 'react-native-image-crop-picker'
 import RNFS from 'react-native-fs'
 import {CropperOptions} from './types'
-import {compressAndResizeImageForPost} from './manip'
+import {compressIfNeeded} from './manip'
 
 let _imageCounter = 0
 async function getFile() {
@@ -13,7 +13,7 @@ async function getFile() {
       .join('/'),
   )
   const file = files[_imageCounter++ % files.length]
-  return await compressAndResizeImageForPost({
+  return await compressIfNeeded({
     path: file.path,
     mime: 'image/jpeg',
     size: file.size,
diff --git a/src/lib/media/util.ts b/src/lib/media/util.ts
index 75915de6b..73f974874 100644
--- a/src/lib/media/util.ts
+++ b/src/lib/media/util.ts
@@ -1,5 +1,3 @@
-import {Dimensions} from './types'
-
 export function extractDataUriMime(uri: string): string {
   return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';'))
 }
@@ -10,21 +8,6 @@ export function getDataUriSize(uri: string): number {
   return Math.round((uri.length * 3) / 4)
 }
 
-export function scaleDownDimensions(
-  dim: Dimensions,
-  max: Dimensions,
-): Dimensions {
-  if (dim.width < max.width && dim.height < max.height) {
-    return dim
-  }
-  const wScale = dim.width > max.width ? max.width / dim.width : 1
-  const hScale = dim.height > max.height ? max.height / dim.height : 1
-  if (wScale < hScale) {
-    return {width: dim.width * wScale, height: dim.height * wScale}
-  }
-  return {width: dim.width * hScale, height: dim.height * hScale}
-}
-
 export function isUriImage(uri: string) {
   return /\.(jpg|jpeg|png).*$/.test(uri)
 }
diff --git a/src/lib/merge-refs.ts b/src/lib/merge-refs.ts
new file mode 100644
index 000000000..4617b5260
--- /dev/null
+++ b/src/lib/merge-refs.ts
@@ -0,0 +1,27 @@
+/**
+ * This TypeScript function merges multiple React refs into a single ref callback.
+ * When developing low level UI components, it is common to have to use a local ref
+ * but also support an external one using React.forwardRef.
+ * Natively, React does not offer a way to set two refs inside the ref property. This is the goal of this small utility.
+ * Today a ref can be a function or an object, tomorrow it could be another thing, who knows.
+ * This utility handles compatibility for you.
+ * This function is inspired by https://github.com/gregberge/react-merge-refs
+ * @param refs - An array of React refs, which can be either `React.MutableRefObject<T>` or
+ * `React.LegacyRef<T>`. These refs are used to store references to DOM elements or React components.
+ * The `mergeRefs` function takes in an array of these refs and returns a callback function that
+ * @returns The function `mergeRefs` is being returned. It takes an array of mutable or legacy refs and
+ * returns a ref callback function that can be used to merge multiple refs into a single ref.
+ */
+export function mergeRefs<T = any>(
+  refs: Array<React.MutableRefObject<T> | React.LegacyRef<T>>,
+): React.RefCallback<T> {
+  return value => {
+    refs.forEach(ref => {
+      if (typeof ref === 'function') {
+        ref(value)
+      } else if (ref != null) {
+        ;(ref as React.MutableRefObject<T | null>).current = value
+      }
+    })
+  }
+}
diff --git a/src/lib/notifee.ts b/src/lib/notifee.ts
index 866319031..42feb01c6 100644
--- a/src/lib/notifee.ts
+++ b/src/lib/notifee.ts
@@ -41,26 +41,26 @@ export function displayNotification(
 }
 
 export function displayNotificationFromModel(
-  notif: NotificationsFeedItemModel,
+  notification: NotificationsFeedItemModel,
 ) {
   let author = sanitizeDisplayName(
-    notif.author.displayName || notif.author.handle,
+    notification.author.displayName || notification.author.handle,
   )
   let title: string
   let body: string = ''
-  if (notif.isLike) {
+  if (notification.isLike) {
     title = `${author} liked your post`
-    body = notif.additionalPost?.thread?.postRecord?.text || ''
-  } else if (notif.isRepost) {
+    body = notification.additionalPost?.thread?.postRecord?.text || ''
+  } else if (notification.isRepost) {
     title = `${author} reposted your post`
-    body = notif.additionalPost?.thread?.postRecord?.text || ''
-  } else if (notif.isMention) {
+    body = notification.additionalPost?.thread?.postRecord?.text || ''
+  } else if (notification.isMention) {
     title = `${author} mentioned you`
-    body = notif.additionalPost?.thread?.postRecord?.text || ''
-  } else if (notif.isReply) {
+    body = notification.additionalPost?.thread?.postRecord?.text || ''
+  } else if (notification.isReply) {
     title = `${author} replied to your post`
-    body = notif.additionalPost?.thread?.postRecord?.text || ''
-  } else if (notif.isFollow) {
+    body = notification.additionalPost?.thread?.postRecord?.text || ''
+  } else if (notification.isFollow) {
     title = 'New follower!'
     body = `${author} has followed you`
   } else {
@@ -68,10 +68,12 @@ export function displayNotificationFromModel(
   }
   let image
   if (
-    AppBskyEmbedImages.isView(notif.additionalPost?.thread?.post.embed) &&
-    notif.additionalPost?.thread?.post.embed.images[0]?.thumb
+    AppBskyEmbedImages.isView(
+      notification.additionalPost?.thread?.post.embed,
+    ) &&
+    notification.additionalPost?.thread?.post.embed.images[0]?.thumb
   ) {
-    image = notif.additionalPost.thread.post.embed.images[0].thumb
+    image = notification.additionalPost.thread.post.embed.images[0].thumb
   }
   return displayNotification(title, body, image)
 }
diff --git a/src/lib/routes/helpers.ts b/src/lib/routes/helpers.ts
index cfa6ae53b..071e1ae9b 100644
--- a/src/lib/routes/helpers.ts
+++ b/src/lib/routes/helpers.ts
@@ -11,7 +11,7 @@ export function getCurrentRoute(state: State) {
 export function isStateAtTabRoot(state: State | undefined) {
   if (!state) {
     // NOTE
-    // if state is not defined it's because init is occuring
+    // if state is not defined it's because init is occurring
     // and therefore we can safely assume we're at root
     // -prf
     return true
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 56775deee..4eb5e29d2 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -9,6 +9,7 @@ export type CommonNavigatorParams = {
   ModerationMuteLists: undefined
   ModerationMutedAccounts: undefined
   ModerationBlockedAccounts: undefined
+  DiscoverFeeds: undefined
   Settings: undefined
   Profile: {name: string; hideBackButton?: boolean}
   ProfileFollowers: {name: string}
@@ -17,6 +18,8 @@ export type CommonNavigatorParams = {
   PostThread: {name: string; rkey: string}
   PostLikedBy: {name: string; rkey: string}
   PostRepostedBy: {name: string; rkey: string}
+  CustomFeed: {name: string; rkey: string}
+  CustomFeedLikedBy: {name: string; rkey: string}
   Debug: undefined
   Log: undefined
   Support: undefined
@@ -25,11 +28,13 @@ export type CommonNavigatorParams = {
   CommunityGuidelines: undefined
   CopyrightPolicy: undefined
   AppPasswords: undefined
+  SavedFeeds: undefined
 }
 
 export type BottomTabNavigatorParams = CommonNavigatorParams & {
   HomeTab: undefined
   SearchTab: undefined
+  FeedsTab: undefined
   NotificationsTab: undefined
   MyProfileTab: undefined
 }
@@ -42,6 +47,10 @@ export type SearchTabNavigatorParams = CommonNavigatorParams & {
   Search: {q?: string}
 }
 
+export type FeedsTabNavigatorParams = CommonNavigatorParams & {
+  Feeds: undefined
+}
+
 export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
   Notifications: undefined
 }
@@ -53,6 +62,7 @@ export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
 export type FlatNavigatorParams = CommonNavigatorParams & {
   Home: undefined
   Search: {q?: string}
+  Feeds: undefined
   Notifications: undefined
 }
 
@@ -61,6 +71,8 @@ export type AllNavigatorParams = CommonNavigatorParams & {
   Home: undefined
   SearchTab: undefined
   Search: {q?: string}
+  FeedsTab: undefined
+  Feeds: undefined
   NotificationsTab: undefined
   Notifications: undefined
   MyProfileTab: undefined
diff --git a/src/lib/strings/rich-text-detection.ts b/src/lib/strings/rich-text-detection.ts
index 51d09ec5d..931617cd1 100644
--- a/src/lib/strings/rich-text-detection.ts
+++ b/src/lib/strings/rich-text-detection.ts
@@ -27,7 +27,7 @@ export function detectLinkables(text: string): DetectedLinkable[] {
       matchValue = matchValue.slice(1)
     }
 
-    // strip ending puncuation
+    // strip ending punctuation
     if (/[.,;!?]$/.test(matchValue)) {
       matchValue = matchValue.slice(0, -1)
     }
diff --git a/src/lib/strings/time.ts b/src/lib/strings/time.ts
index 588b84459..3f2847558 100644
--- a/src/lib/strings/time.ts
+++ b/src/lib/strings/time.ts
@@ -1,8 +1,8 @@
 const MINUTE = 60
 const HOUR = MINUTE * 60
 const DAY = HOUR * 24
-const MONTH = DAY * 28
-const YEAR = DAY * 365
+const WEEK = DAY * 7
+
 export function ago(date: number | string | Date): string {
   let ts: number
   if (typeof date === 'string') {
@@ -19,12 +19,14 @@ export function ago(date: number | string | Date): string {
     return `${Math.floor(diffSeconds / MINUTE)}m`
   } else if (diffSeconds < DAY) {
     return `${Math.floor(diffSeconds / HOUR)}h`
-  } else if (diffSeconds < MONTH) {
+  } else if (diffSeconds < WEEK) {
     return `${Math.floor(diffSeconds / DAY)}d`
-  } else if (diffSeconds < YEAR) {
-    return `${Math.floor(diffSeconds / MONTH)}mo`
   } else {
-    return new Date(ts).toLocaleDateString()
+    return new Date(ts).toLocaleDateString('en-us', {
+      year: 'numeric',
+      month: 'short',
+      day: 'numeric',
+    })
   }
 }
 
diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts
index a5412920e..d6d43b89d 100644
--- a/src/lib/strings/url-helpers.ts
+++ b/src/lib/strings/url-helpers.ts
@@ -82,6 +82,18 @@ export function isBskyPostUrl(url: string): boolean {
   return false
 }
 
+export function isBskyCustomFeedUrl(url: string): boolean {
+  if (isBskyAppUrl(url)) {
+    try {
+      const urlp = new URL(url)
+      return /profile\/(?<name>[^/]+)\/feed\/(?<rkey>[^/]+)/i.test(
+        urlp.pathname,
+      )
+    } catch {}
+  }
+  return false
+}
+
 export function convertBskyAppUrlIfNeeded(url: string): string {
   if (isBskyAppUrl(url)) {
     try {
diff --git a/src/lib/styles.ts b/src/lib/styles.ts
index 00a8638f9..fb631c0bf 100644
--- a/src/lib/styles.ts
+++ b/src/lib/styles.ts
@@ -1,4 +1,4 @@
-import {StyleProp, StyleSheet, TextStyle} from 'react-native'
+import {Dimensions, StyleProp, StyleSheet, TextStyle} from 'react-native'
 import {Theme, TypographyVariant} from './ThemeContext'
 import {isMobileWeb} from 'platform/detection'
 
@@ -52,6 +52,7 @@ export const colors = {
   green5: '#082b03',
 
   unreadNotifBg: '#ebf6ff',
+  brandBlue: '#0066FF',
 }
 
 export const gradients = {
@@ -169,6 +170,10 @@ export const s = StyleSheet.create({
   w100pct: {width: '100%'},
   h100pct: {height: '100%'},
   hContentRegion: isMobileWeb ? {flex: 1} : {height: '100%'},
+  window: {
+    width: Dimensions.get('window').width,
+    height: Dimensions.get('window').height,
+  },
 
   // text align
   textLeft: {textAlign: 'left'},
@@ -214,6 +219,8 @@ export const s = StyleSheet.create({
   green3: {color: colors.green3},
   green4: {color: colors.green4},
   green5: {color: colors.green5},
+
+  brandBlue: {color: colors.brandBlue},
 })
 
 export function lh(