about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Navigation.tsx8
-rw-r--r--src/lib/icons.tsx4
-rw-r--r--src/lib/routes/types.ts3
-rw-r--r--src/lib/styles.ts6
-rw-r--r--src/routes.ts3
-rw-r--r--src/state/models/feeds/algo/actor.ts121
-rw-r--r--src/state/models/feeds/algo/algo-item.ts142
-rw-r--r--src/state/models/feeds/algo/saved.ts249
-rw-r--r--src/state/models/feeds/posts.ts31
-rw-r--r--src/state/models/me.ts9
-rw-r--r--src/state/models/ui/profile.ts25
-rw-r--r--src/view/com/algos/AlgoItem.tsx153
-rw-r--r--src/view/com/algos/SavedFeedItem.tsx50
-rw-r--r--src/view/com/algos/useCustomFeed.ts27
-rw-r--r--src/view/com/pager/FeedsTabBar.web.tsx14
-rw-r--r--src/view/com/pager/FeedsTabBarMobile.tsx14
-rw-r--r--src/view/com/pager/TabBar.tsx99
-rw-r--r--src/view/com/posts/Feed.tsx6
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx2
-rw-r--r--src/view/com/util/post-embeds/index.tsx28
-rw-r--r--src/view/index.ts4
-rw-r--r--src/view/screens/CustomFeed.tsx160
-rw-r--r--src/view/screens/Home.tsx11
-rw-r--r--src/view/screens/ModerationMutedAccounts.tsx2
-rw-r--r--src/view/screens/PinnedFeeds.tsx181
-rw-r--r--src/view/screens/Profile.tsx4
-rw-r--r--src/view/screens/SavedFeeds.tsx192
-rw-r--r--src/view/screens/Settings.tsx17
28 files changed, 1505 insertions, 60 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 09631701f..45ab439b6 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -55,6 +55,9 @@ import {AppPasswords} from 'view/screens/AppPasswords'
 import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
 import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
 import {getRoutingInstrumentation} from 'lib/sentry'
+import {SavedFeeds} from './view/screens/SavedFeeds'
+import {CustomFeed} from './view/screens/CustomFeed'
+import {PinnedFeeds} from 'view/screens/PinnedFeeds'
 import {bskyTitle} from 'lib/strings/headings'
 
 const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
@@ -184,6 +187,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
         component={AppPasswords}
         options={{title: title('App Passwords')}}
       />
+      <Stack.Screen name="CopyrightPolicy" component={CopyrightPolicyScreen} />
+      <Stack.Screen name="AppPasswords" component={AppPasswords} />
+      <Stack.Screen name="SavedFeeds" component={SavedFeeds} />
+      <Stack.Screen name="PinnedFeeds" component={PinnedFeeds} />
+      <Stack.Screen name="CustomFeed" component={CustomFeed} />
     </>
   )
 }
diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx
index 06f195011..0c7b7512a 100644
--- a/src/lib/icons.tsx
+++ b/src/lib/icons.tsx
@@ -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 (
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 56775deee..8b96aaad7 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -17,6 +17,7 @@ export type CommonNavigatorParams = {
   PostThread: {name: string; rkey: string}
   PostLikedBy: {name: string; rkey: string}
   PostRepostedBy: {name: string; rkey: string}
+  CustomFeed: {name: string; rkey: string; displayName?: string}
   Debug: undefined
   Log: undefined
   Support: undefined
@@ -25,6 +26,8 @@ export type CommonNavigatorParams = {
   CommunityGuidelines: undefined
   CopyrightPolicy: undefined
   AppPasswords: undefined
+  SavedFeeds: undefined
+  PinnedFeeds: undefined
 }
 
 export type BottomTabNavigatorParams = CommonNavigatorParams & {
diff --git a/src/lib/styles.ts b/src/lib/styles.ts
index 00a8638f9..07315c9f2 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'
 
@@ -169,6 +169,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'},
diff --git a/src/routes.ts b/src/routes.ts
index 571aca7ff..7501e7abf 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -16,9 +16,12 @@ export const router = new Router({
   PostThread: '/profile/:name/post/:rkey',
   PostLikedBy: '/profile/:name/post/:rkey/liked-by',
   PostRepostedBy: '/profile/:name/post/:rkey/reposted-by',
+  CustomFeed: '/profile/:name/feed/:rkey',
   Debug: '/sys/debug',
   Log: '/sys/log',
   AppPasswords: '/settings/app-passwords',
+  SavedFeeds: '/settings/saved-feeds',
+  PinnedFeeds: '/settings/pinned-feeds',
   Support: '/support',
   PrivacyPolicy: '/support/privacy',
   TermsOfService: '/support/tos',
diff --git a/src/state/models/feeds/algo/actor.ts b/src/state/models/feeds/algo/actor.ts
new file mode 100644
index 000000000..e42df8495
--- /dev/null
+++ b/src/state/models/feeds/algo/actor.ts
@@ -0,0 +1,121 @@
+import {makeAutoObservable} from 'mobx'
+import {AppBskyFeedGetActorFeeds as GetActorFeeds} from '@atproto/api'
+import {RootStoreModel} from '../../root-store'
+import {bundleAsync} from 'lib/async/bundle'
+import {cleanError} from 'lib/strings/errors'
+import {AlgoItemModel} from './algo-item'
+
+const PAGE_SIZE = 30
+
+export class ActorFeedsModel {
+  // state
+  isLoading = false
+  isRefreshing = false
+  hasLoaded = false
+  error = ''
+  hasMore = true
+  loadMoreCursor?: string
+
+  // data
+  feeds: AlgoItemModel[] = []
+
+  constructor(
+    public rootStore: RootStoreModel,
+    public params: GetActorFeeds.QueryParams,
+  ) {
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+      },
+      {autoBind: true},
+    )
+  }
+
+  get hasContent() {
+    return this.feeds.length > 0
+  }
+
+  get hasError() {
+    return this.error !== ''
+  }
+
+  get isEmpty() {
+    return this.hasLoaded && !this.hasContent
+  }
+
+  // public api
+  // =
+
+  async refresh() {
+    return this.loadMore(true)
+  }
+
+  clear() {
+    this.isLoading = false
+    this.isRefreshing = false
+    this.hasLoaded = false
+    this.error = ''
+    this.hasMore = true
+    this.loadMoreCursor = undefined
+    this.feeds = []
+  }
+
+  loadMore = bundleAsync(async (replace: boolean = false) => {
+    if (!replace && !this.hasMore) {
+      return
+    }
+    this._xLoading(replace)
+    try {
+      const res = await this.rootStore.agent.app.bsky.feed.getActorFeeds({
+        actor: this.params.actor,
+        limit: PAGE_SIZE,
+        cursor: replace ? undefined : this.loadMoreCursor,
+      })
+      console.log('res', res.data.feeds)
+      if (replace) {
+        this._replaceAll(res)
+      } else {
+        this._appendAll(res)
+      }
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(e)
+    }
+  })
+
+  // state transitions
+  // =
+
+  _xLoading(isRefreshing = false) {
+    this.isLoading = true
+    this.isRefreshing = isRefreshing
+    this.error = ''
+  }
+
+  _xIdle(err?: any) {
+    this.isLoading = false
+    this.isRefreshing = false
+    this.hasLoaded = true
+    this.error = cleanError(err)
+    if (err) {
+      this.rootStore.log.error('Failed to fetch user followers', err)
+    }
+  }
+
+  // helper functions
+  // =
+
+  _replaceAll(res: GetActorFeeds.Response) {
+    this.feeds = []
+    this._appendAll(res)
+  }
+
+  _appendAll(res: GetActorFeeds.Response) {
+    this.loadMoreCursor = res.data.cursor
+    this.hasMore = !!this.loadMoreCursor
+    for (const f of res.data.feeds) {
+      this.feeds.push(new AlgoItemModel(this.rootStore, f))
+    }
+  }
+}
diff --git a/src/state/models/feeds/algo/algo-item.ts b/src/state/models/feeds/algo/algo-item.ts
new file mode 100644
index 000000000..bd4ea4fd6
--- /dev/null
+++ b/src/state/models/feeds/algo/algo-item.ts
@@ -0,0 +1,142 @@
+import {AppBskyFeedDefs, AtUri} from '@atproto/api'
+import {makeAutoObservable} from 'mobx'
+import {RootStoreModel} from 'state/models/root-store'
+
+export class AlgoItemModel {
+  // data
+  data: AppBskyFeedDefs.GeneratorView
+
+  constructor(
+    public rootStore: RootStoreModel,
+    view: AppBskyFeedDefs.GeneratorView,
+  ) {
+    this.data = view
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+      },
+      {autoBind: true},
+    )
+  }
+
+  // local actions
+  // =
+  set toggleSaved(value: boolean) {
+    console.log('toggleSaved', this.data.viewer)
+    if (this.data.viewer) {
+      this.data.viewer.saved = value
+    }
+  }
+
+  get getUri() {
+    return this.data.uri
+  }
+
+  get isSaved() {
+    return this.data.viewer?.saved
+  }
+
+  get isLiked() {
+    return this.data.viewer?.like
+  }
+
+  private toggleLiked(s?: string) {
+    if (this.data.viewer) {
+      if (this.data.viewer.like) {
+        this.data.viewer.like = undefined
+      } else {
+        this.data.viewer.like = s
+      }
+    }
+  }
+
+  private incrementLike() {
+    if (this.data.likeCount) {
+      this.data.likeCount += 1
+    } else {
+      this.data.likeCount = 1
+    }
+  }
+
+  private decrementLike() {
+    if (this.data.likeCount) {
+      this.data.likeCount -= 1
+    } else {
+      this.data.likeCount = 0
+    }
+  }
+
+  private rewriteData(data: AppBskyFeedDefs.GeneratorView) {
+    this.data = data
+  }
+
+  // public apis
+  // =
+  async like() {
+    try {
+      const res = await this.rootStore.agent.app.bsky.feed.like.create(
+        {
+          repo: this.rootStore.me.did,
+        },
+        {
+          subject: {
+            uri: this.data.uri,
+            cid: this.data.cid,
+          },
+          createdAt: new Date().toISOString(),
+        },
+      )
+      this.toggleLiked(res.uri)
+      this.incrementLike()
+    } catch (e: any) {
+      this.rootStore.log.error('Failed to like feed', e)
+    }
+  }
+
+  async unlike() {
+    try {
+      await this.rootStore.agent.app.bsky.feed.like.delete({
+        repo: this.rootStore.me.did,
+        rkey: new AtUri(this.data.viewer?.like!).rkey,
+      })
+      this.toggleLiked()
+      this.decrementLike()
+    } catch (e: any) {
+      this.rootStore.log.error('Failed to unlike feed', e)
+    }
+  }
+
+  static async getView(store: RootStoreModel, uri: string) {
+    const res = await store.agent.app.bsky.feed.getFeedGenerator({
+      feed: uri,
+    })
+    const view = res.data.view
+    return view
+  }
+
+  async checkIsValid() {
+    const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({
+      feed: this.data.uri,
+    })
+    return res.data.isValid
+  }
+
+  async checkIsOnline() {
+    const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({
+      feed: this.data.uri,
+    })
+    return res.data.isOnline
+  }
+
+  async reload() {
+    const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({
+      feed: this.data.uri,
+    })
+    this.rewriteData(res.data.view)
+  }
+
+  serialize() {
+    return JSON.stringify(this.data)
+  }
+}
diff --git a/src/state/models/feeds/algo/saved.ts b/src/state/models/feeds/algo/saved.ts
new file mode 100644
index 000000000..cb2015ccb
--- /dev/null
+++ b/src/state/models/feeds/algo/saved.ts
@@ -0,0 +1,249 @@
+import {makeAutoObservable, runInAction} from 'mobx'
+import {AppBskyFeedGetSavedFeeds as GetSavedFeeds} from '@atproto/api'
+import {RootStoreModel} from '../../root-store'
+import {bundleAsync} from 'lib/async/bundle'
+import {cleanError} from 'lib/strings/errors'
+import {AlgoItemModel} from './algo-item'
+import {hasProp, isObj} from 'lib/type-guards'
+
+const PAGE_SIZE = 30
+
+export class SavedFeedsModel {
+  // state
+  isLoading = false
+  isRefreshing = false
+  hasLoaded = false
+  error = ''
+  hasMore = true
+  loadMoreCursor?: string
+
+  // data
+  feeds: AlgoItemModel[] = []
+  pinned: AlgoItemModel[] = []
+
+  constructor(public rootStore: RootStoreModel) {
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+      },
+      {autoBind: true},
+    )
+  }
+
+  serialize() {
+    return {
+      pinned: this.pinned.map(f => f.serialize()),
+    }
+  }
+
+  hydrate(v: unknown) {
+    if (isObj(v)) {
+      if (hasProp(v, 'pinned')) {
+        const pinnedSerialized = (v as any).pinned as string[]
+        const pinnedDeserialized = pinnedSerialized.map(
+          (s: string) => new AlgoItemModel(this.rootStore, JSON.parse(s)),
+        )
+        this.pinned = pinnedDeserialized
+      }
+    }
+  }
+
+  get hasContent() {
+    return this.feeds.length > 0
+  }
+
+  get hasError() {
+    return this.error !== ''
+  }
+
+  get isEmpty() {
+    return this.hasLoaded && !this.hasContent
+  }
+
+  get numOfFeeds() {
+    return this.feeds.length
+  }
+
+  get listOfFeedNames() {
+    return this.feeds.map(
+      f => f.data.displayName ?? f.data.creator.displayName + "'s feed",
+    )
+  }
+
+  get listOfPinnedFeedNames() {
+    return this.pinned.map(
+      f => f.data.displayName ?? f.data.creator.displayName + "'s feed",
+    )
+  }
+
+  get savedFeedsWithoutPinned() {
+    return this.feeds.filter(
+      f => !this.pinned.find(p => p.data.uri === f.data.uri),
+    )
+  }
+
+  togglePinnedFeed(feed: AlgoItemModel) {
+    if (!this.isPinned(feed)) {
+      this.pinned.push(feed)
+    } else {
+      this.removePinnedFeed(feed.data.uri)
+    }
+  }
+
+  removePinnedFeed(uri: string) {
+    this.pinned = this.pinned.filter(f => f.data.uri !== uri)
+  }
+
+  reorderPinnedFeeds(temp: AlgoItemModel[]) {
+    this.pinned = temp
+  }
+
+  isPinned(feed: AlgoItemModel) {
+    return this.pinned.find(f => f.data.uri === feed.data.uri) ? true : false
+  }
+
+  movePinnedItem(item: AlgoItemModel, direction: 'up' | 'down') {
+    if (this.pinned.length < 2) {
+      throw new Error('Array must have at least 2 items')
+    }
+    const index = this.pinned.indexOf(item)
+    if (index === -1) {
+      throw new Error('Item not found in array')
+    }
+
+    const len = this.pinned.length
+
+    runInAction(() => {
+      if (direction === 'up') {
+        if (index === 0) {
+          // Remove the item from the first place and put it at the end
+          this.pinned.push(this.pinned.shift()!)
+        } else {
+          // Swap the item with the one before it
+          const temp = this.pinned[index]
+          this.pinned[index] = this.pinned[index - 1]
+          this.pinned[index - 1] = temp
+        }
+      } else if (direction === 'down') {
+        if (index === len - 1) {
+          // Remove the item from the last place and put it at the start
+          this.pinned.unshift(this.pinned.pop()!)
+        } else {
+          // Swap the item with the one after it
+          const temp = this.pinned[index]
+          this.pinned[index] = this.pinned[index + 1]
+          this.pinned[index + 1] = temp
+        }
+      }
+      // this.pinned = [...this.pinned]
+    })
+  }
+
+  // public api
+  // =
+
+  async refresh() {
+    return this.loadMore(true)
+  }
+
+  clear() {
+    this.isLoading = false
+    this.isRefreshing = false
+    this.hasLoaded = false
+    this.error = ''
+    this.hasMore = true
+    this.loadMoreCursor = undefined
+    this.feeds = []
+  }
+
+  loadMore = bundleAsync(async (replace: boolean = false) => {
+    if (!replace && !this.hasMore) {
+      return
+    }
+    this._xLoading(replace)
+    try {
+      const res = await this.rootStore.agent.app.bsky.feed.getSavedFeeds({
+        limit: PAGE_SIZE,
+        cursor: replace ? undefined : this.loadMoreCursor,
+      })
+      if (replace) {
+        this._replaceAll(res)
+      } else {
+        this._appendAll(res)
+      }
+      this._xIdle()
+    } catch (e: any) {
+      this._xIdle(e)
+    }
+  })
+
+  removeFeed(uri: string) {
+    this.feeds = this.feeds.filter(f => f.data.uri !== uri)
+  }
+
+  addFeed(algoItem: AlgoItemModel) {
+    this.feeds.push(new AlgoItemModel(this.rootStore, algoItem.data))
+  }
+
+  async save(algoItem: AlgoItemModel) {
+    try {
+      await this.rootStore.agent.app.bsky.feed.saveFeed({
+        feed: algoItem.getUri,
+      })
+      algoItem.toggleSaved = true
+      this.addFeed(algoItem)
+    } catch (e: any) {
+      this.rootStore.log.error('Failed to save feed', e)
+    }
+  }
+
+  async unsave(algoItem: AlgoItemModel) {
+    const uri = algoItem.getUri
+    try {
+      await this.rootStore.agent.app.bsky.feed.unsaveFeed({
+        feed: uri,
+      })
+      algoItem.toggleSaved = false
+      this.removeFeed(uri)
+      this.removePinnedFeed(uri)
+    } catch (e: any) {
+      this.rootStore.log.error('Failed to unsanve feed', e)
+    }
+  }
+
+  // state transitions
+  // =
+
+  _xLoading(isRefreshing = false) {
+    this.isLoading = true
+    this.isRefreshing = isRefreshing
+    this.error = ''
+  }
+
+  _xIdle(err?: any) {
+    this.isLoading = false
+    this.isRefreshing = false
+    this.hasLoaded = true
+    this.error = cleanError(err)
+    if (err) {
+      this.rootStore.log.error('Failed to fetch user followers', err)
+    }
+  }
+
+  // helper functions
+  // =
+
+  _replaceAll(res: GetSavedFeeds.Response) {
+    this.feeds = []
+    this._appendAll(res)
+  }
+
+  _appendAll(res: GetSavedFeeds.Response) {
+    this.loadMoreCursor = res.data.cursor
+    this.hasMore = !!this.loadMoreCursor
+    for (const f of res.data.feeds) {
+      this.feeds.push(new AlgoItemModel(this.rootStore, f))
+    }
+  }
+}
diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts
index b2dffdc69..dfd92b35c 100644
--- a/src/state/models/feeds/posts.ts
+++ b/src/state/models/feeds/posts.ts
@@ -4,6 +4,7 @@ import {
   AppBskyFeedDefs,
   AppBskyFeedPost,
   AppBskyFeedGetAuthorFeed as GetAuthorFeed,
+  AppBskyFeedGetFeed as GetCustomFeed,
   RichText,
   jsonToLex,
 } from '@atproto/api'
@@ -309,8 +310,11 @@ export class PostsFeedModel {
 
   constructor(
     public rootStore: RootStoreModel,
-    public feedType: 'home' | 'author' | 'suggested' | 'goodstuff',
-    params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams,
+    public feedType: 'home' | 'author' | 'suggested' | 'goodstuff' | 'custom',
+    params:
+      | GetTimeline.QueryParams
+      | GetAuthorFeed.QueryParams
+      | GetCustomFeed.QueryParams,
   ) {
     makeAutoObservable(
       this,
@@ -599,13 +603,15 @@ export class PostsFeedModel {
   // helper functions
   // =
 
-  async _replaceAll(res: GetTimeline.Response | GetAuthorFeed.Response) {
+  async _replaceAll(
+    res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
+  ) {
     this.pollCursor = res.data.feed[0]?.post.uri
     return this._appendAll(res, true)
   }
 
   async _appendAll(
-    res: GetTimeline.Response | GetAuthorFeed.Response,
+    res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
     replace = false,
   ) {
     this.loadMoreCursor = res.data.cursor
@@ -644,7 +650,9 @@ export class PostsFeedModel {
     })
   }
 
-  _updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) {
+  _updateAll(
+    res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response,
+  ) {
     for (const item of res.data.feed) {
       const existingSlice = this.slices.find(slice =>
         slice.containsUri(item.post.uri),
@@ -661,8 +669,13 @@ export class PostsFeedModel {
   }
 
   protected async _getFeed(
-    params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams = {},
-  ): Promise<GetTimeline.Response | GetAuthorFeed.Response> {
+    params:
+      | GetTimeline.QueryParams
+      | GetAuthorFeed.QueryParams
+      | GetCustomFeed.QueryParams,
+  ): Promise<
+    GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response
+  > {
     params = Object.assign({}, this.params, params)
     if (this.feedType === 'suggested') {
       const responses = await getMultipleAuthorsPosts(
@@ -684,6 +697,10 @@ export class PostsFeedModel {
       }
     } else if (this.feedType === 'home') {
       return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams)
+    } else if (this.feedType === 'custom') {
+      return this.rootStore.agent.app.bsky.feed.getFeed(
+        params as GetCustomFeed.QueryParams,
+      )
     } else if (this.feedType === 'goodstuff') {
       const res = await getGoodStuff(
         this.rootStore.session.currentSession?.accessJwt || '',
diff --git a/src/state/models/me.ts b/src/state/models/me.ts
index ba2dc6f32..68c89ac9b 100644
--- a/src/state/models/me.ts
+++ b/src/state/models/me.ts
@@ -8,6 +8,7 @@ import {PostsFeedModel} from './feeds/posts'
 import {NotificationsFeedModel} from './feeds/notifications'
 import {MyFollowsCache} from './cache/my-follows'
 import {isObj, hasProp} from 'lib/type-guards'
+import {SavedFeedsModel} from './feeds/algo/saved'
 
 const PROFILE_UPDATE_INTERVAL = 10 * 60 * 1e3 // 10min
 const NOTIFS_UPDATE_INTERVAL = 30 * 1e3 // 30sec
@@ -21,6 +22,7 @@ export class MeModel {
   followsCount: number | undefined
   followersCount: number | undefined
   mainFeed: PostsFeedModel
+  savedFeeds: SavedFeedsModel
   notifications: NotificationsFeedModel
   follows: MyFollowsCache
   invites: ComAtprotoServerDefs.InviteCode[] = []
@@ -43,12 +45,14 @@ export class MeModel {
     })
     this.notifications = new NotificationsFeedModel(this.rootStore)
     this.follows = new MyFollowsCache(this.rootStore)
+    this.savedFeeds = new SavedFeedsModel(this.rootStore)
   }
 
   clear() {
     this.mainFeed.clear()
     this.notifications.clear()
     this.follows.clear()
+    this.savedFeeds.clear()
     this.did = ''
     this.handle = ''
     this.displayName = ''
@@ -65,6 +69,7 @@ export class MeModel {
       displayName: this.displayName,
       description: this.description,
       avatar: this.avatar,
+      savedFeeds: this.savedFeeds.serialize(),
     }
   }
 
@@ -86,6 +91,9 @@ export class MeModel {
       if (hasProp(v, 'avatar') && typeof v.avatar === 'string') {
         avatar = v.avatar
       }
+      if (hasProp(v, 'savedFeeds') && isObj(v.savedFeeds)) {
+        this.savedFeeds.hydrate(v.savedFeeds)
+      }
       if (did && handle) {
         this.did = did
         this.handle = handle
@@ -110,6 +118,7 @@ export class MeModel {
       /* dont await */ this.notifications.setup().catch(e => {
         this.rootStore.log.error('Failed to setup notifications model', e)
       })
+      /* dont await */ this.savedFeeds.refresh()
       this.rootStore.emitSessionLoaded()
       await this.fetchInviteCodes()
       await this.fetchAppPasswords()
diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts
index 861b3df0e..4f604bfc0 100644
--- a/src/state/models/ui/profile.ts
+++ b/src/state/models/ui/profile.ts
@@ -1,18 +1,22 @@
 import {makeAutoObservable} from 'mobx'
+import {AppBskyFeedDefs} from '@atproto/api'
 import {RootStoreModel} from '../root-store'
 import {ProfileModel} from '../content/profile'
 import {PostsFeedModel} from '../feeds/posts'
+import {ActorFeedsModel} from '../feeds/algo/actor'
 import {ListsListModel} from '../lists/lists-list'
 
 export enum Sections {
   Posts = 'Posts',
   PostsWithReplies = 'Posts & replies',
+  CustomAlgorithms = 'Algos',
   Lists = 'Lists',
 }
 
 const USER_SELECTOR_ITEMS = [
   Sections.Posts,
   Sections.PostsWithReplies,
+  Sections.CustomAlgorithms,
   Sections.Lists,
 ]
 
@@ -28,6 +32,7 @@ export class ProfileUiModel {
   // data
   profile: ProfileModel
   feed: PostsFeedModel
+  algos: ActorFeedsModel
   lists: ListsListModel
 
   // ui state
@@ -50,10 +55,11 @@ export class ProfileUiModel {
       actor: params.user,
       limit: 10,
     })
+    this.algos = new ActorFeedsModel(rootStore, {actor: params.user})
     this.lists = new ListsListModel(rootStore, params.user)
   }
 
-  get currentView(): PostsFeedModel | ListsListModel {
+  get currentView(): PostsFeedModel | ActorFeedsModel | ListsListModel {
     if (
       this.selectedView === Sections.Posts ||
       this.selectedView === Sections.PostsWithReplies
@@ -62,6 +68,9 @@ export class ProfileUiModel {
     } else if (this.selectedView === Sections.Lists) {
       return this.lists
     }
+    if (this.selectedView === Sections.CustomAlgorithms) {
+      return this.algos
+    }
     throw new Error(`Invalid selector value: ${this.selectedViewIndex}`)
   }
 
@@ -81,12 +90,17 @@ export class ProfileUiModel {
   get selectedView() {
     return this.selectorItems[this.selectedViewIndex]
   }
+  isGeneratorView(v: any) {
+    return AppBskyFeedDefs.isGeneratorView(v)
+  }
 
   get uiItems() {
     let arr: any[] = []
+    // if loading, return loading item to show loading spinner
     if (this.isInitialLoading) {
       arr = arr.concat([ProfileUiModel.LOADING_ITEM])
     } else if (this.currentView.hasError) {
+      // if error, return error item to show error message
       arr = arr.concat([
         {
           _reactKey: '__error__',
@@ -94,12 +108,16 @@ export class ProfileUiModel {
         },
       ])
     } else {
+      // not loading, no error, show content
       if (
         this.selectedView === Sections.Posts ||
-        this.selectedView === Sections.PostsWithReplies
+        this.selectedView === Sections.PostsWithReplies ||
+        this.selectedView === Sections.CustomAlgorithms
       ) {
         if (this.feed.hasContent) {
-          if (this.selectedView === Sections.Posts) {
+          if (this.selectedView === Sections.CustomAlgorithms) {
+            arr = this.algos.feeds
+          } else if (this.selectedView === Sections.Posts) {
             arr = this.feed.nonReplyFeed
           } else {
             arr = this.feed.slices.slice()
@@ -117,6 +135,7 @@ export class ProfileUiModel {
           arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
         }
       } else {
+        // fallback, add empty item, to show empty message
         arr = arr.concat([ProfileUiModel.EMPTY_ITEM])
       }
     }
diff --git a/src/view/com/algos/AlgoItem.tsx b/src/view/com/algos/AlgoItem.tsx
new file mode 100644
index 000000000..56ee6d1d2
--- /dev/null
+++ b/src/view/com/algos/AlgoItem.tsx
@@ -0,0 +1,153 @@
+import React from 'react'
+import {
+  StyleProp,
+  StyleSheet,
+  View,
+  ViewStyle,
+  TouchableOpacity,
+} from 'react-native'
+import {Text} from '../util/text/Text'
+import {usePalette} from 'lib/hooks/usePalette'
+import {colors, s} from 'lib/styles'
+import {UserAvatar} from '../util/UserAvatar'
+import {Button} from '../util/forms/Button'
+import {observer} from 'mobx-react-lite'
+import {AlgoItemModel} from 'state/models/feeds/algo/algo-item'
+import {useFocusEffect, useNavigation} from '@react-navigation/native'
+import {NavigationProp} from 'lib/routes/types'
+import {useStores} from 'state/index'
+import {HeartIconSolid} from 'lib/icons'
+import {pluralize} from 'lib/strings/helpers'
+import {AtUri} from '@atproto/api'
+import {isWeb} from 'platform/detection'
+
+const AlgoItem = observer(
+  ({
+    item,
+    style,
+    showBottom = true,
+    reloadOnFocus = false,
+  }: {
+    item: AlgoItemModel
+    style?: StyleProp<ViewStyle>
+    showBottom?: boolean
+    reloadOnFocus?: boolean
+  }) => {
+    const store = useStores()
+    const pal = usePalette('default')
+    const navigation = useNavigation<NavigationProp>()
+
+    // TODO: this is pretty hacky, but it works for now
+    // causes issues on web
+    useFocusEffect(() => {
+      if (reloadOnFocus && !isWeb) {
+        item.reload()
+      }
+    })
+
+    return (
+      <TouchableOpacity
+        accessibilityRole="button"
+        style={[styles.container, style]}
+        onPress={() => {
+          navigation.navigate('CustomFeed', {
+            name: item.data.creator.did,
+            rkey: new AtUri(item.data.uri).rkey,
+            displayName:
+              item.data.displayName ??
+              `${item.data.creator.displayName}'s feed`,
+          })
+        }}
+        key={item.data.uri}>
+        <View style={[styles.headerContainer]}>
+          <View style={[s.mr10]}>
+            <UserAvatar size={36} avatar={item.data.avatar} />
+          </View>
+          <View style={[styles.headerTextContainer]}>
+            <Text style={[pal.text, s.bold]}>
+              {item.data.displayName ?? 'Feed name'}
+            </Text>
+            <Text style={[pal.textLight, styles.description]} numberOfLines={5}>
+              {item.data.description ??
+                "Explore our Feed for the latest updates and insights! Dive into a world of intriguing articles, trending news, and exciting stories that cover a wide range of topics. From technology breakthroughs to lifestyle tips, there's something here for everyone. Stay informed and get inspired with us. Join the conversation now!"}
+            </Text>
+          </View>
+        </View>
+
+        {showBottom ? (
+          <View style={styles.bottomContainer}>
+            <View style={styles.likedByContainer}>
+              {/* <View style={styles.likedByAvatars}>
+              <UserAvatar size={24} avatar={item.data.avatar} />
+              <UserAvatar size={24} avatar={item.data.avatar} />
+              <UserAvatar size={24} avatar={item.data.avatar} />
+            </View> */}
+
+              <HeartIconSolid size={16} style={[s.mr2, {color: colors.red3}]} />
+              <Text style={[pal.text, pal.textLight]}>
+                {item.data.likeCount && item.data.likeCount > 0
+                  ? `Liked by ${item.data.likeCount} ${pluralize(
+                      item.data.likeCount,
+                      'other',
+                    )}`
+                  : 'Be the first to like this'}
+              </Text>
+            </View>
+            <View>
+              <Button
+                type={item.isSaved ? 'default' : 'inverted'}
+                onPress={() => {
+                  if (item.data.viewer?.saved) {
+                    store.me.savedFeeds.unsave(item)
+                  } else {
+                    store.me.savedFeeds.save(item)
+                  }
+                }}
+                label={item.data.viewer?.saved ? 'Unsave' : 'Save'}
+              />
+            </View>
+          </View>
+        ) : null}
+      </TouchableOpacity>
+    )
+  },
+)
+export default AlgoItem
+
+const styles = StyleSheet.create({
+  container: {
+    paddingHorizontal: 18,
+    paddingVertical: 20,
+    flexDirection: 'column',
+    flex: 1,
+    borderTopWidth: 1,
+    borderTopColor: '#E5E5E5',
+    gap: 18,
+  },
+  headerContainer: {
+    flexDirection: 'row',
+  },
+  headerTextContainer: {
+    flexDirection: 'column',
+    columnGap: 4,
+    flex: 1,
+  },
+  description: {
+    flex: 1,
+    flexWrap: 'wrap',
+  },
+  bottomContainer: {
+    flexDirection: 'row',
+    justifyContent: 'space-between',
+    alignItems: 'center',
+  },
+  likedByContainer: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 2,
+  },
+  likedByAvatars: {
+    flexDirection: 'row',
+    gap: -12,
+  },
+})
diff --git a/src/view/com/algos/SavedFeedItem.tsx b/src/view/com/algos/SavedFeedItem.tsx
new file mode 100644
index 000000000..bb4ec10b3
--- /dev/null
+++ b/src/view/com/algos/SavedFeedItem.tsx
@@ -0,0 +1,50 @@
+import React from 'react'
+import {View, TouchableOpacity, StyleSheet} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {colors} from 'lib/styles'
+import {observer} from 'mobx-react-lite'
+import {AlgoItemModel} from 'state/models/feeds/algo/algo-item'
+import {SavedFeedsModel} from 'state/models/feeds/algo/saved'
+import AlgoItem from './AlgoItem'
+
+export const SavedFeedItem = observer(
+  ({item, savedFeeds}: {item: AlgoItemModel; savedFeeds: SavedFeedsModel}) => {
+    const isPinned = savedFeeds.isPinned(item)
+
+    return (
+      <View style={styles.itemContainer}>
+        <AlgoItem
+          key={item.data.uri}
+          item={item}
+          showBottom={false}
+          style={styles.item}
+        />
+        <TouchableOpacity
+          accessibilityRole="button"
+          onPress={() => {
+            savedFeeds.togglePinnedFeed(item)
+            console.log('pinned', savedFeeds.pinned)
+            console.log('isPinned', savedFeeds.isPinned(item))
+          }}>
+          <FontAwesomeIcon
+            icon="thumb-tack"
+            size={20}
+            color={isPinned ? colors.blue3 : colors.gray3}
+          />
+        </TouchableOpacity>
+      </View>
+    )
+  },
+)
+
+const styles = StyleSheet.create({
+  itemContainer: {
+    flex: 1,
+    flexDirection: 'row',
+    alignItems: 'center',
+    marginRight: 18,
+  },
+  item: {
+    borderTopWidth: 0,
+  },
+})
diff --git a/src/view/com/algos/useCustomFeed.ts b/src/view/com/algos/useCustomFeed.ts
new file mode 100644
index 000000000..cea9c1cea
--- /dev/null
+++ b/src/view/com/algos/useCustomFeed.ts
@@ -0,0 +1,27 @@
+import {useEffect, useState} from 'react'
+import {useStores} from 'state/index'
+import {AlgoItemModel} from 'state/models/feeds/algo/algo-item'
+
+export function useCustomFeed(uri: string) {
+  const store = useStores()
+  const [item, setItem] = useState<AlgoItemModel>()
+  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 AlgoItemModel(store, view)
+        setItem(temp)
+      }
+    }
+    buildFeedItem()
+  }, [store, uri])
+
+  return item
+}
diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx
index 0fc1b7310..6de38fa1d 100644
--- a/src/view/com/pager/FeedsTabBar.web.tsx
+++ b/src/view/com/pager/FeedsTabBar.web.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import React, {useMemo} from 'react'
 import {Animated, StyleSheet} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import {TabBar} from 'view/com/pager/TabBar'
@@ -27,6 +27,14 @@ const FeedsTabBarDesktop = observer(
     props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
   ) => {
     const store = useStores()
+    const items = useMemo(
+      () => [
+        'Following',
+        "What's hot",
+        ...store.me.savedFeeds.listOfPinnedFeedNames,
+      ],
+      [store.me.savedFeeds.listOfPinnedFeedNames],
+    )
     const pal = usePalette('default')
     const interp = useAnimatedValue(0)
 
@@ -44,12 +52,14 @@ const FeedsTabBarDesktop = observer(
         {translateY: Animated.multiply(interp, -100)},
       ],
     }
+
     return (
       // @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf
       <Animated.View style={[pal.view, styles.tabBar, transform]}>
         <TabBar
           {...props}
-          items={['Following', "What's hot"]}
+          key={items.join(',')}
+          items={items}
           indicatorPosition="bottom"
           indicatorColor={pal.colors.link}
         />
diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx
index 725c44603..ab8f98309 100644
--- a/src/view/com/pager/FeedsTabBarMobile.tsx
+++ b/src/view/com/pager/FeedsTabBarMobile.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import React, {useMemo} from 'react'
 import {Animated, StyleSheet, TouchableOpacity} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import {TabBar} from 'view/com/pager/TabBar'
@@ -32,6 +32,15 @@ export const FeedsTabBar = observer(
       store.shell.openDrawer()
     }, [store])
 
+    const items = useMemo(
+      () => [
+        'Following',
+        "What's hot",
+        ...store.me.savedFeeds.listOfPinnedFeedNames,
+      ],
+      [store.me.savedFeeds.listOfPinnedFeedNames],
+    )
+
     return (
       <Animated.View style={[pal.view, pal.border, styles.tabBar, transform]}>
         <TouchableOpacity
@@ -44,8 +53,9 @@ export const FeedsTabBar = observer(
           <UserAvatar avatar={store.me.avatar} size={30} />
         </TouchableOpacity>
         <TabBar
+          key={items.join(',')}
           {...props}
-          items={['Following', "What's hot"]}
+          items={items}
           indicatorPosition="bottom"
           indicatorColor={pal.colors.link}
         />
diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx
index a0b72a93f..9294b6026 100644
--- a/src/view/com/pager/TabBar.tsx
+++ b/src/view/com/pager/TabBar.tsx
@@ -1,5 +1,5 @@
 import React, {createRef, useState, useMemo, useRef} from 'react'
-import {Animated, StyleSheet, View} from 'react-native'
+import {Animated, StyleSheet, View, ScrollView} from 'react-native'
 import {Text} from '../util/text/Text'
 import {PressableWithHover} from '../util/PressableWithHover'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -43,27 +43,39 @@ export function TabBar({
   )
   const panX = Animated.add(position, offset)
   const containerRef = useRef<View>(null)
+  const [scrollX, setScrollX] = useState(0)
 
-  const indicatorStyle = {
-    backgroundColor: indicatorColor || pal.colors.link,
-    bottom:
-      indicatorPosition === 'bottom' ? (isDesktopWeb ? 0 : -1) : undefined,
-    top: indicatorPosition === 'top' ? (isDesktopWeb ? 0 : -1) : undefined,
-    transform: [
-      {
-        translateX: panX.interpolate({
-          inputRange: items.map((_item, i) => i),
-          outputRange: itemLayouts.map(l => l.x + l.width / 2),
-        }),
-      },
-      {
-        scaleX: panX.interpolate({
-          inputRange: items.map((_item, i) => i),
-          outputRange: itemLayouts.map(l => l.width),
-        }),
-      },
+  const indicatorStyle = useMemo(
+    () => ({
+      backgroundColor: indicatorColor || pal.colors.link,
+      bottom:
+        indicatorPosition === 'bottom' ? (isDesktopWeb ? 0 : -1) : undefined,
+      top: indicatorPosition === 'top' ? (isDesktopWeb ? 0 : -1) : undefined,
+      transform: [
+        {
+          translateX: panX.interpolate({
+            inputRange: items.map((_item, i) => i),
+            outputRange: itemLayouts.map(l => l.x + l.width / 2 - scrollX),
+          }),
+        },
+        {
+          scaleX: panX.interpolate({
+            inputRange: items.map((_item, i) => i),
+            outputRange: itemLayouts.map(l => l.width),
+          }),
+        },
+      ],
+    }),
+    [
+      indicatorColor,
+      indicatorPosition,
+      itemLayouts,
+      items,
+      panX,
+      pal.colors.link,
+      scrollX,
     ],
-  }
+  )
 
   const onLayout = React.useCallback(() => {
     const promises = []
@@ -105,26 +117,33 @@ export function TabBar({
       onLayout={onLayout}
       ref={containerRef}>
       <Animated.View style={[styles.indicator, indicatorStyle]} />
-      {items.map((item, i) => {
-        const selected = i === selectedPage
-        return (
-          <PressableWithHover
-            ref={itemRefs[i]}
-            key={item}
-            style={
-              indicatorPosition === 'top' ? styles.itemTop : styles.itemBottom
-            }
-            hoverStyle={pal.viewLight}
-            onPress={() => onPressItem(i)}>
-            <Text
-              type="xl-bold"
-              testID={testID ? `${testID}-${item}` : undefined}
-              style={selected ? pal.text : pal.textLight}>
-              {item}
-            </Text>
-          </PressableWithHover>
-        )
-      })}
+      <ScrollView
+        horizontal={true}
+        showsHorizontalScrollIndicator={false}
+        onScroll={({nativeEvent}) => {
+          setScrollX(nativeEvent.contentOffset.x)
+        }}>
+        {items.map((item, i) => {
+          const selected = i === selectedPage
+          return (
+            <PressableWithHover
+              ref={itemRefs[i]}
+              key={item}
+              style={
+                indicatorPosition === 'top' ? styles.itemTop : styles.itemBottom
+              }
+              hoverStyle={pal.viewLight}
+              onPress={() => onPressItem(i)}>
+              <Text
+                type="xl-bold"
+                testID={testID ? `${testID}-${item}` : undefined}
+                style={selected ? pal.text : pal.textLight}>
+                {item}
+              </Text>
+            </PressableWithHover>
+          )
+        })}
+      </ScrollView>
     </View>
   )
 }
diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx
index 998cfe0c9..5b0110df8 100644
--- a/src/view/com/posts/Feed.tsx
+++ b/src/view/com/posts/Feed.tsx
@@ -34,6 +34,8 @@ export const Feed = observer(function Feed({
   renderEmptyState,
   testID,
   headerOffset = 0,
+  ListHeaderComponent,
+  extraData,
 }: {
   feed: PostsFeedModel
   style?: StyleProp<ViewStyle>
@@ -44,6 +46,8 @@ export const Feed = observer(function Feed({
   renderEmptyState?: () => JSX.Element
   testID?: string
   headerOffset?: number
+  ListHeaderComponent?: () => JSX.Element
+  extraData?: any
 }) {
   const pal = usePalette('default')
   const {track} = useAnalytics()
@@ -163,6 +167,7 @@ export const Feed = observer(function Feed({
           keyExtractor={item => item._reactKey}
           renderItem={renderItem}
           ListFooterComponent={FeedFooter}
+          ListHeaderComponent={ListHeaderComponent}
           refreshControl={
             <RefreshControl
               refreshing={isRefreshing}
@@ -179,6 +184,7 @@ export const Feed = observer(function Feed({
           onEndReachedThreshold={0.6}
           removeClippedSubviews={true}
           contentOffset={{x: 0, y: headerOffset * -1}}
+          extraData={extraData}
           // @ts-ignore our .web version only -prf
           desktopFixedHeight
         />
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index 5c0296e28..9980e9de0 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -205,7 +205,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
         }>
         {opts.isLiked ? (
           <HeartIconSolid
-            style={styles.ctrlIconLiked as StyleProp<ViewStyle>}
+            style={styles.ctrlIconLiked}
             size={opts.big ? 22 : 16}
           />
         ) : (
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index a55ff9050..328b9305b 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -13,6 +13,7 @@ import {
   AppBskyEmbedRecord,
   AppBskyEmbedRecordWithMedia,
   AppBskyFeedPost,
+  AppBskyFeedDefs,
 } from '@atproto/api'
 import {Link} from '../Link'
 import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
@@ -24,6 +25,8 @@ import {ExternalLinkEmbed} from './ExternalLinkEmbed'
 import {getYoutubeVideoId} from 'lib/strings/url-helpers'
 import QuoteEmbed from './QuoteEmbed'
 import {AutoSizedImage} from '../images/AutoSizedImage'
+import AlgoItem from 'view/com/algos/AlgoItem'
+import {AlgoItemModel} from 'state/models/feeds/algo/algo-item'
 
 type Embed =
   | AppBskyEmbedRecord.View
@@ -42,6 +45,8 @@ export function PostEmbeds({
   const pal = usePalette('default')
   const store = useStores()
 
+  // quote post with media
+  // =
   if (
     AppBskyEmbedRecordWithMedia.isView(embed) &&
     AppBskyEmbedRecord.isViewRecord(embed.record.record) &&
@@ -65,6 +70,8 @@ export function PostEmbeds({
     )
   }
 
+  // quote post
+  // =
   if (AppBskyEmbedRecord.isView(embed)) {
     if (
       AppBskyEmbedRecord.isViewRecord(embed.record) &&
@@ -87,6 +94,8 @@ export function PostEmbeds({
     }
   }
 
+  // image embed
+  // =
   if (AppBskyEmbedImages.isView(embed)) {
     const {images} = embed
 
@@ -132,10 +141,11 @@ export function PostEmbeds({
           />
         </View>
       )
-      // }
     }
   }
 
+  // external link embed
+  // =
   if (AppBskyEmbedExternal.isView(embed)) {
     const link = embed.external
     const youtubeVideoId = getYoutubeVideoId(link.uri)
@@ -153,6 +163,22 @@ export function PostEmbeds({
       </Link>
     )
   }
+
+  // custom feed embed (i.e. generator view)
+  // =
+  if (
+    AppBskyEmbedRecord.isView(embed) &&
+    AppBskyFeedDefs.isGeneratorView(embed.record)
+  ) {
+    return (
+      <AlgoItem
+        item={new AlgoItemModel(store, embed.record)}
+        style={[pal.view, pal.border, styles.extOuter]}
+        reloadOnFocus={true}
+      />
+    )
+  }
+
   return <View />
 }
 
diff --git a/src/view/index.ts b/src/view/index.ts
index b8a13f7f8..84fc3f315 100644
--- a/src/view/index.ts
+++ b/src/view/index.ts
@@ -8,6 +8,7 @@ import {faAngleUp} from '@fortawesome/free-solid-svg-icons/faAngleUp'
 import {faArrowLeft} from '@fortawesome/free-solid-svg-icons/faArrowLeft'
 import {faArrowRight} from '@fortawesome/free-solid-svg-icons/faArrowRight'
 import {faArrowUp} from '@fortawesome/free-solid-svg-icons/faArrowUp'
+import {faArrowDown} from '@fortawesome/free-solid-svg-icons/faArrowDown'
 import {faArrowRightFromBracket} from '@fortawesome/free-solid-svg-icons/faArrowRightFromBracket'
 import {faArrowUpFromBracket} from '@fortawesome/free-solid-svg-icons/faArrowUpFromBracket'
 import {faArrowUpRightFromSquare} from '@fortawesome/free-solid-svg-icons/faArrowUpRightFromSquare'
@@ -80,6 +81,7 @@ import {faX} from '@fortawesome/free-solid-svg-icons/faX'
 import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark'
 import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay'
 import {faPause} from '@fortawesome/free-solid-svg-icons/faPause'
+import {faThumbtack} from '@fortawesome/free-solid-svg-icons/faThumbtack'
 
 export function setup() {
   library.add(
@@ -91,6 +93,7 @@ export function setup() {
     faArrowLeft,
     faArrowRight,
     faArrowUp,
+    faArrowDown,
     faArrowRightFromBracket,
     faArrowUpFromBracket,
     faArrowUpRightFromSquare,
@@ -159,6 +162,7 @@ export function setup() {
     faUsersSlash,
     faTicket,
     faTrashCan,
+    faThumbtack,
     faX,
     faXmark,
     faPlay,
diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx
new file mode 100644
index 000000000..5c19556e2
--- /dev/null
+++ b/src/view/screens/CustomFeed.tsx
@@ -0,0 +1,160 @@
+import {NativeStackScreenProps} from '@react-navigation/native-stack'
+import {usePalette} from 'lib/hooks/usePalette'
+import {HeartIcon, HeartIconSolid} from 'lib/icons'
+import {CommonNavigatorParams} from 'lib/routes/types'
+import {makeRecordUri} from 'lib/strings/url-helpers'
+import {colors, s} from 'lib/styles'
+import {observer} from 'mobx-react-lite'
+import React, {useMemo, useRef} from 'react'
+import {FlatList, StyleSheet, TouchableOpacity, View} from 'react-native'
+import {useStores} from 'state/index'
+import {PostsFeedModel} from 'state/models/feeds/posts'
+import {useCustomFeed} from 'view/com/algos/useCustomFeed'
+import {withAuthRequired} from 'view/com/auth/withAuthRequired'
+import {Feed} from 'view/com/posts/Feed'
+import {Link} from 'view/com/util/Link'
+import {UserAvatar} from 'view/com/util/UserAvatar'
+import {ViewHeader} from 'view/com/util/ViewHeader'
+import {Button} from 'view/com/util/forms/Button'
+import {Text} from 'view/com/util/text/Text'
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeed'>
+export const CustomFeed = withAuthRequired(
+  observer(({route}: Props) => {
+    const rootStore = useStores()
+    const {rkey, name, displayName} = route.params
+    const uri = useMemo(
+      () => makeRecordUri(name, 'app.bsky.feed.generator', rkey),
+      [rkey, name],
+    )
+    const currentFeed = useCustomFeed(uri)
+    const scrollElRef = useRef<FlatList>(null)
+    const algoFeed: PostsFeedModel = useMemo(() => {
+      const feed = new PostsFeedModel(rootStore, 'custom', {
+        feed: uri,
+      })
+      feed.setup()
+      return feed
+    }, [rootStore, uri])
+
+    return (
+      <View style={[styles.container]}>
+        <ViewHeader
+          title={
+            displayName ?? `${currentFeed?.data.creator.displayName}'s feed`
+          }
+          showOnDesktop
+        />
+        <Feed
+          scrollElRef={scrollElRef}
+          testID={'test-feed'}
+          key="default"
+          feed={algoFeed}
+          headerOffset={12}
+          ListHeaderComponent={() => <ListHeaderComponent uri={uri} />}
+          extraData={uri}
+        />
+      </View>
+    )
+  }),
+)
+
+const ListHeaderComponent = observer(({uri}: {uri: string}) => {
+  const currentFeed = useCustomFeed(uri)
+  const pal = usePalette('default')
+  const rootStore = useStores()
+  return (
+    <View style={[styles.headerContainer]}>
+      <View style={[styles.header]}>
+        <View style={styles.avatarContainer}>
+          <UserAvatar size={28} avatar={currentFeed?.data.creator.avatar} />
+          <Link href={`/profile/${currentFeed?.data.creator.handle}`}>
+            <Text style={[pal.textLight]}>
+              @{currentFeed?.data.creator.handle}
+            </Text>
+          </Link>
+        </View>
+        <Text style={[pal.text]}>{currentFeed?.data.description}</Text>
+      </View>
+
+      <View style={[styles.buttonsContainer]}>
+        <Button
+          type={currentFeed?.isSaved ? 'default' : 'inverted'}
+          style={[styles.saveButton]}
+          onPress={() => {
+            if (currentFeed?.data.viewer?.saved) {
+              rootStore.me.savedFeeds.unsave(currentFeed!)
+            } else {
+              rootStore.me.savedFeeds.save(currentFeed!)
+            }
+          }}
+          label={currentFeed?.data.viewer?.saved ? 'Unsave' : 'Save'}
+        />
+
+        <TouchableOpacity
+          accessibilityRole="button"
+          onPress={() => {
+            if (currentFeed?.isLiked) {
+              currentFeed?.unlike()
+            } else {
+              currentFeed?.like()
+            }
+          }}
+          style={[styles.likeButton, pal.viewLight]}>
+          <Text style={[pal.text, s.semiBold]}>
+            {currentFeed?.data.likeCount}
+          </Text>
+          {currentFeed?.isLiked ? (
+            <HeartIconSolid size={18} style={styles.liked} />
+          ) : (
+            <HeartIcon strokeWidth={3} size={18} style={styles.liked} />
+          )}
+        </TouchableOpacity>
+      </View>
+    </View>
+  )
+})
+
+const styles = StyleSheet.create({
+  container: {
+    flex: 1,
+  },
+  headerContainer: {
+    alignItems: 'center',
+    justifyContent: 'center',
+    gap: 8,
+    marginBottom: 12,
+  },
+  header: {
+    alignItems: 'center',
+    gap: 4,
+  },
+  avatarContainer: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 8,
+  },
+  buttonsContainer: {
+    flexDirection: 'row',
+    gap: 8,
+  },
+  saveButton: {
+    minWidth: 100,
+    alignItems: 'center',
+  },
+  liked: {
+    color: colors.red3,
+  },
+  notLiked: {
+    color: colors.gray3,
+  },
+  likeButton: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    paddingVertical: 4,
+    paddingHorizontal: 8,
+    borderRadius: 24,
+    gap: 4,
+  },
+})
diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx
index 0ead6b65c..1457478d5 100644
--- a/src/view/screens/Home.tsx
+++ b/src/view/screens/Home.tsx
@@ -112,6 +112,17 @@ export const HomeScreen = withAuthRequired(
           feed={algoFeed}
           renderEmptyState={renderWhatsHotEmptyState}
         />
+        {store.me.savedFeeds.pinned.map((f, index) => {
+          return (
+            <FeedPage
+              key={String(2 + index + 1)}
+              testID="customFeed"
+              isPageFocused={selectedPage === 2 + index}
+              feed={new PostsFeedModel(store, 'custom', {feed: f.getUri})}
+              renderEmptyState={renderFollowingEmptyState}
+            />
+          )
+        })}
       </Pager>
     )
   }),
diff --git a/src/view/screens/ModerationMutedAccounts.tsx b/src/view/screens/ModerationMutedAccounts.tsx
index ec732f682..22b8c0d33 100644
--- a/src/view/screens/ModerationMutedAccounts.tsx
+++ b/src/view/screens/ModerationMutedAccounts.tsx
@@ -100,7 +100,7 @@ export const ModerationMutedAccounts = withAuthRequired(
           <FlatList
             style={[!isDesktopWeb && styles.flex1]}
             data={mutedAccounts.mutes}
-            keyExtractor={(item: ActorDefs.ProfileView) => item.did}
+            keyExtractor={item => item.did}
             refreshControl={
               <RefreshControl
                 refreshing={mutedAccounts.isRefreshing}
diff --git a/src/view/screens/PinnedFeeds.tsx b/src/view/screens/PinnedFeeds.tsx
new file mode 100644
index 000000000..ac901ba71
--- /dev/null
+++ b/src/view/screens/PinnedFeeds.tsx
@@ -0,0 +1,181 @@
+import React, {useCallback, useMemo} from 'react'
+import {
+  RefreshControl,
+  StyleSheet,
+  View,
+  ActivityIndicator,
+  Pressable,
+  TouchableOpacity,
+} from 'react-native'
+import {useFocusEffect} from '@react-navigation/native'
+import {NativeStackScreenProps} from '@react-navigation/native-stack'
+import {useAnalytics} from 'lib/analytics'
+import {usePalette} from 'lib/hooks/usePalette'
+import {CommonNavigatorParams} from 'lib/routes/types'
+import {observer} from 'mobx-react-lite'
+import {useStores} from 'state/index'
+import {withAuthRequired} from 'view/com/auth/withAuthRequired'
+import {ViewHeader} from 'view/com/util/ViewHeader'
+import {CenteredView} from 'view/com/util/Views'
+import {Text} from 'view/com/util/text/Text'
+import {isDesktopWeb, isWeb} from 'platform/detection'
+import {s} from 'lib/styles'
+import DraggableFlatList, {
+  ShadowDecorator,
+  ScaleDecorator,
+} from 'react-native-draggable-flatlist'
+import {SavedFeedItem} from 'view/com/algos/SavedFeedItem'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {AlgoItemModel} from 'state/models/feeds/algo/algo-item'
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'PinnedFeeds'>
+
+export const PinnedFeeds = withAuthRequired(
+  observer(({}: Props) => {
+    // hooks for global items
+    const pal = usePalette('default')
+    const rootStore = useStores()
+    const {screen} = useAnalytics()
+
+    // hooks for local
+    const savedFeeds = useMemo(() => rootStore.me.savedFeeds, [rootStore])
+    useFocusEffect(
+      useCallback(() => {
+        screen('SavedFeeds')
+        rootStore.shell.setMinimalShellMode(false)
+        savedFeeds.refresh()
+      }, [screen, rootStore, savedFeeds]),
+    )
+    const _ListEmptyComponent = () => {
+      return (
+        <View
+          style={[
+            pal.border,
+            !isDesktopWeb && s.flex1,
+            pal.viewLight,
+            styles.empty,
+          ]}>
+          <Text type="lg" style={[pal.text]}>
+            You don't have any pinned feeds. To pin a feed, go back to the Saved
+            Feeds screen and click the pin icon!
+          </Text>
+        </View>
+      )
+    }
+    const _ListFooterComponent = () => {
+      return (
+        <View style={styles.footer}>
+          {savedFeeds.isLoading && <ActivityIndicator />}
+        </View>
+      )
+    }
+
+    return (
+      <CenteredView style={[s.flex1]}>
+        <ViewHeader title="Arrange Pinned Feeds" showOnDesktop />
+        <DraggableFlatList
+          containerStyle={[!isDesktopWeb && s.flex1]}
+          data={[...savedFeeds.pinned]} // make a copy so this FlatList re-renders when pinned changes
+          keyExtractor={item => item.data.uri}
+          refreshing={savedFeeds.isRefreshing}
+          refreshControl={
+            <RefreshControl
+              refreshing={savedFeeds.isRefreshing}
+              onRefresh={() => savedFeeds.refresh()}
+              tintColor={pal.colors.text}
+              titleColor={pal.colors.text}
+            />
+          }
+          renderItem={({item, drag}) => <PinnedItem item={item} drag={drag} />}
+          initialNumToRender={10}
+          ListFooterComponent={_ListFooterComponent}
+          ListEmptyComponent={_ListEmptyComponent}
+          extraData={savedFeeds.isLoading}
+          onDragEnd={({data}) => savedFeeds.reorderPinnedFeeds(data)}
+          // @ts-ignore our .web version only -prf
+          desktopFixedHeight
+        />
+      </CenteredView>
+    )
+  }),
+)
+
+const PinnedItem = observer(
+  ({item, drag}: {item: AlgoItemModel; drag: () => void}) => {
+    const pal = usePalette('default')
+    const rootStore = useStores()
+    const savedFeeds = useMemo(() => rootStore.me.savedFeeds, [rootStore])
+    return (
+      <ScaleDecorator>
+        <ShadowDecorator>
+          <Pressable
+            accessibilityRole="button"
+            onLongPress={drag}
+            style={styles.itemContainer}>
+            {isWeb ? (
+              <View style={styles.webArrowButtonsContainer}>
+                <TouchableOpacity
+                  accessibilityRole="button"
+                  onPress={() => {
+                    savedFeeds.movePinnedItem(item, 'up')
+                  }}>
+                  <FontAwesomeIcon
+                    icon="arrow-up"
+                    size={20}
+                    style={[styles.icon, pal.text, styles.webArrowUpButton]}
+                  />
+                </TouchableOpacity>
+                <TouchableOpacity
+                  accessibilityRole="button"
+                  onPress={() => {
+                    savedFeeds.movePinnedItem(item, 'down')
+                  }}>
+                  <FontAwesomeIcon
+                    icon="arrow-down"
+                    size={20}
+                    style={[styles.icon, pal.text]}
+                  />
+                </TouchableOpacity>
+              </View>
+            ) : (
+              <FontAwesomeIcon
+                icon="bars"
+                size={20}
+                style={[styles.icon, pal.text]}
+              />
+            )}
+            <SavedFeedItem item={item} savedFeeds={savedFeeds} />
+          </Pressable>
+        </ShadowDecorator>
+      </ScaleDecorator>
+    )
+  },
+)
+
+const styles = StyleSheet.create({
+  footer: {
+    paddingVertical: 20,
+  },
+  empty: {
+    paddingHorizontal: 20,
+    paddingVertical: 20,
+    borderRadius: 16,
+    marginHorizontal: 24,
+    marginTop: 10,
+  },
+  itemContainer: {
+    flex: 1,
+    flexDirection: 'row',
+    alignItems: 'center',
+    marginLeft: 18,
+  },
+  item: {
+    borderTopWidth: 0,
+  },
+  icon: {marginRight: 10},
+  webArrowButtonsContainer: {
+    flexDirection: 'column',
+    justifyContent: 'space-around',
+  },
+  webArrowUpButton: {marginBottom: 10},
+})
diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index b6d92e46b..9c8dd458c 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -25,6 +25,8 @@ import {FAB} from '../com/util/fab/FAB'
 import {s, colors} from 'lib/styles'
 import {useAnalytics} from 'lib/analytics'
 import {ComposeIcon2} from 'lib/icons'
+import AlgoItem from 'view/com/algos/AlgoItem'
+import {AlgoItemModel} from 'state/models/feeds/algo/algo-item'
 import {useSetTitle} from 'lib/hooks/useSetTitle'
 import {combinedDisplayName} from 'lib/strings/display-names'
 
@@ -186,6 +188,8 @@ export const ProfileScreen = withAuthRequired(
             return (
               <FeedSlice slice={item} ignoreMuteFor={uiState.profile.did} />
             )
+          } else if (item instanceof AlgoItemModel) {
+            return <AlgoItem item={item} />
           }
         }
         return <View />
diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx
new file mode 100644
index 000000000..c3a4542c6
--- /dev/null
+++ b/src/view/screens/SavedFeeds.tsx
@@ -0,0 +1,192 @@
+import React, {useCallback, useMemo} from 'react'
+import {
+  RefreshControl,
+  StyleSheet,
+  View,
+  ActivityIndicator,
+  FlatList,
+  TouchableOpacity,
+  ScrollView,
+} from 'react-native'
+import {useFocusEffect} from '@react-navigation/native'
+import {NativeStackScreenProps} from '@react-navigation/native-stack'
+import {useAnalytics} from 'lib/analytics'
+import {usePalette} from 'lib/hooks/usePalette'
+import {CommonNavigatorParams} from 'lib/routes/types'
+import {observer} from 'mobx-react-lite'
+import {useStores} from 'state/index'
+import {withAuthRequired} from 'view/com/auth/withAuthRequired'
+import {ViewHeader} from 'view/com/util/ViewHeader'
+import {CenteredView} from 'view/com/util/Views'
+import {Text} from 'view/com/util/text/Text'
+import {isDesktopWeb, isWeb} from 'platform/detection'
+import {s} from 'lib/styles'
+import {SavedFeedsModel} from 'state/models/feeds/algo/saved'
+import {Link} from 'view/com/util/Link'
+import {UserAvatar} from 'view/com/util/UserAvatar'
+import {SavedFeedItem} from 'view/com/algos/SavedFeedItem'
+import {AtUri} from '@atproto/api'
+
+type Props = NativeStackScreenProps<CommonNavigatorParams, 'SavedFeeds'>
+
+export const SavedFeeds = withAuthRequired(
+  observer(({navigation}: Props) => {
+    // hooks for global items
+    const pal = usePalette('default')
+    const rootStore = useStores()
+    const {screen} = useAnalytics()
+
+    // hooks for local
+    const savedFeeds = useMemo(() => rootStore.me.savedFeeds, [rootStore])
+    useFocusEffect(
+      useCallback(() => {
+        screen('SavedFeeds')
+        rootStore.shell.setMinimalShellMode(false)
+        savedFeeds.refresh()
+      }, [screen, rootStore, savedFeeds]),
+    )
+    const _ListEmptyComponent = () => {
+      return (
+        <View
+          style={[
+            pal.border,
+            !isDesktopWeb && s.flex1,
+            pal.viewLight,
+            styles.empty,
+          ]}>
+          <Text type="lg" style={[pal.text]}>
+            You don't have any saved feeds. To save a feed, click the save
+            button when a custom feed or algorithm shows up.
+          </Text>
+        </View>
+      )
+    }
+    const _ListFooterComponent = () => {
+      return (
+        <View style={styles.footer}>
+          {savedFeeds.isLoading && <ActivityIndicator />}
+        </View>
+      )
+    }
+
+    return (
+      <CenteredView style={[s.flex1]}>
+        <ViewHeader title="Saved Feeds" showOnDesktop />
+        <FlatList
+          style={[!isDesktopWeb && s.flex1]}
+          data={savedFeeds.feeds}
+          keyExtractor={item => item.data.uri}
+          refreshing={savedFeeds.isRefreshing}
+          refreshControl={
+            <RefreshControl
+              refreshing={savedFeeds.isRefreshing}
+              onRefresh={() => savedFeeds.refresh()}
+              tintColor={pal.colors.text}
+              titleColor={pal.colors.text}
+            />
+          }
+          renderItem={({item}) => (
+            <SavedFeedItem item={item} savedFeeds={savedFeeds} />
+          )}
+          initialNumToRender={10}
+          ListHeaderComponent={() => (
+            <ListHeaderComponent
+              savedFeeds={savedFeeds}
+              navigation={navigation}
+            />
+          )}
+          ListFooterComponent={_ListFooterComponent}
+          ListEmptyComponent={_ListEmptyComponent}
+          extraData={savedFeeds.isLoading}
+          // @ts-ignore our .web version only -prf
+          desktopFixedHeight
+        />
+      </CenteredView>
+    )
+  }),
+)
+
+const ListHeaderComponent = observer(
+  ({
+    savedFeeds,
+    navigation,
+  }: {
+    savedFeeds: SavedFeedsModel
+    navigation: Props['navigation']
+  }) => {
+    const pal = usePalette('default')
+    return (
+      <View style={styles.headerContainer}>
+        {savedFeeds.pinned.length > 0 ? (
+          <View style={styles.pinnedContainer}>
+            <View style={styles.pinnedHeader}>
+              <Text type="lg-bold" style={[pal.text]}>
+                Pinned Feeds
+              </Text>
+              <Link href="/settings/pinned-feeds">
+                <Text style={[styles.editPinned, pal.text]}>Edit</Text>
+              </Link>
+            </View>
+
+            <ScrollView
+              horizontal={true}
+              showsHorizontalScrollIndicator={isWeb}>
+              {savedFeeds.pinned.map(item => {
+                return (
+                  <TouchableOpacity
+                    key={item.data.uri}
+                    accessibilityRole="button"
+                    onPress={() => {
+                      navigation.navigate('CustomFeed', {
+                        name: item.data.creator.did,
+                        rkey: new AtUri(item.data.uri).rkey,
+                        displayName:
+                          item.data.displayName ??
+                          `${item.data.creator.displayName}'s feed`,
+                      })
+                    }}
+                    style={styles.pinnedItem}>
+                    <UserAvatar avatar={item.data.avatar} size={80} />
+                    <Text
+                      type="sm-medium"
+                      numberOfLines={1}
+                      style={[pal.text, styles.pinnedItemName]}>
+                      {item.data.displayName ??
+                        `${item.data.creator.displayName}'s feed`}
+                    </Text>
+                  </TouchableOpacity>
+                )
+              })}
+            </ScrollView>
+          </View>
+        ) : null}
+
+        <Text type="lg-bold">All Saved Feeds</Text>
+      </View>
+    )
+  },
+)
+
+const styles = StyleSheet.create({
+  footer: {
+    paddingVertical: 20,
+  },
+  empty: {
+    paddingHorizontal: 20,
+    paddingVertical: 20,
+    borderRadius: 16,
+    marginHorizontal: 24,
+    marginTop: 10,
+  },
+  headerContainer: {paddingHorizontal: 18, paddingTop: 18},
+  pinnedContainer: {marginBottom: 18, gap: 18},
+  pinnedHeader: {flexDirection: 'row', justifyContent: 'space-between'},
+  pinnedItem: {
+    flex: 1,
+    alignItems: 'center',
+    marginRight: 18,
+    maxWidth: 100,
+  },
+  pinnedItemName: {marginTop: 8, textAlign: 'center'},
+  editPinned: {textDecorationLine: 'underline'},
+})
diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx
index 1571a6142..a919f11b0 100644
--- a/src/view/screens/Settings.tsx
+++ b/src/view/screens/Settings.tsx
@@ -284,6 +284,23 @@ export const SettingsScreen = withAuthRequired(
 
           <View style={styles.spacer20} />
 
+          <Link
+            testID="bookmarkedAlgosBtn"
+            style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]}
+            accessibilityHint="Custom Algorithms"
+            accessibilityLabel="Opens screen with all bookmarked custom algorithms"
+            href="/settings/saved-feeds">
+            <View style={[styles.iconContainer, pal.btn]}>
+              <FontAwesomeIcon
+                icon="rss"
+                style={pal.text as FontAwesomeIconStyle}
+              />
+            </View>
+            <Text type="lg" style={pal.text}>
+              Custom Algorithms
+            </Text>
+          </Link>
+
           <Text type="xl-bold" style={[pal.text, styles.heading]}>
             Advanced
           </Text>