about summary refs log tree commit diff
path: root/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/api/feed-manip.ts47
-rw-r--r--src/lib/api/feed/author.ts45
-rw-r--r--src/lib/api/feed/custom.ts52
-rw-r--r--src/lib/api/feed/following.ts37
-rw-r--r--src/lib/api/feed/likes.ts45
-rw-r--r--src/lib/api/feed/merge.ts236
-rw-r--r--src/lib/api/feed/types.ts17
-rw-r--r--src/lib/icons.tsx64
-rw-r--r--src/lib/routes/types.ts1
-rw-r--r--src/lib/strings/url-helpers.ts9
10 files changed, 491 insertions, 62 deletions
diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts
index 149859ea9..ef57fc4f2 100644
--- a/src/lib/api/feed-manip.ts
+++ b/src/lib/api/feed-manip.ts
@@ -4,6 +4,7 @@ import {
   AppBskyEmbedRecordWithMedia,
   AppBskyEmbedRecord,
 } from '@atproto/api'
+import {FeedSourceInfo} from './feed/types'
 import {isPostInLanguage} from '../../locale/helpers'
 type FeedViewPost = AppBskyFeedDefs.FeedViewPost
 
@@ -64,6 +65,11 @@ export class FeedViewPostsSlice {
     )
   }
 
+  get source(): FeedSourceInfo | undefined {
+    return this.items.find(item => '__source' in item && !!item.__source)
+      ?.__source as FeedSourceInfo
+  }
+
   containsUri(uri: string) {
     return !!this.items.find(item => item.post.uri === uri)
   }
@@ -91,6 +97,23 @@ export class FeedViewPostsSlice {
       }
     }
   }
+
+  isFollowingAllAuthors(userDid: string) {
+    const item = this.rootItem
+    if (item.post.author.did === userDid) {
+      return true
+    }
+    if (AppBskyFeedDefs.isPostView(item.reply?.parent)) {
+      const parent = item.reply?.parent
+      if (parent?.author.did === userDid) {
+        return true
+      }
+      return (
+        parent?.author.viewer?.following && item.post.author.viewer?.following
+      )
+    }
+    return false
+  }
 }
 
 export class FeedTuner {
@@ -222,20 +245,34 @@ export class FeedTuner {
     return slices
   }
 
-  static likedRepliesOnly({repliesThreshold}: {repliesThreshold: number}) {
+  static thresholdRepliesOnly({
+    userDid,
+    minLikes,
+    followedOnly,
+  }: {
+    userDid: string
+    minLikes: number
+    followedOnly: boolean
+  }) {
     return (
       tuner: FeedTuner,
       slices: FeedViewPostsSlice[],
     ): FeedViewPostsSlice[] => {
-      // remove any replies without at least repliesThreshold likes
+      // remove any replies without at least minLikes likes
       for (let i = slices.length - 1; i >= 0; i--) {
-        if (slices[i].isFullThread || !slices[i].isReply) {
+        const slice = slices[i]
+        if (slice.isFullThread || !slice.isReply) {
           continue
         }
 
-        const item = slices[i].rootItem
+        const item = slice.rootItem
         const isRepost = Boolean(item.reason)
-        if (!isRepost && (item.post.likeCount || 0) < repliesThreshold) {
+        if (isRepost) {
+          continue
+        }
+        if ((item.post.likeCount || 0) < minLikes) {
+          slices.splice(i, 1)
+        } else if (followedOnly && !slice.isFollowingAllAuthors(userDid)) {
           slices.splice(i, 1)
         }
       }
diff --git a/src/lib/api/feed/author.ts b/src/lib/api/feed/author.ts
new file mode 100644
index 000000000..1ae925123
--- /dev/null
+++ b/src/lib/api/feed/author.ts
@@ -0,0 +1,45 @@
+import {
+  AppBskyFeedDefs,
+  AppBskyFeedGetAuthorFeed as GetAuthorFeed,
+} from '@atproto/api'
+import {RootStoreModel} from 'state/index'
+import {FeedAPI, FeedAPIResponse} from './types'
+
+export class AuthorFeedAPI implements FeedAPI {
+  cursor: string | undefined
+
+  constructor(
+    public rootStore: RootStoreModel,
+    public params: GetAuthorFeed.QueryParams,
+  ) {}
+
+  reset() {
+    this.cursor = undefined
+  }
+
+  async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
+    const res = await this.rootStore.agent.getAuthorFeed({
+      ...this.params,
+      limit: 1,
+    })
+    return res.data.feed[0]
+  }
+
+  async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
+    const res = await this.rootStore.agent.getAuthorFeed({
+      ...this.params,
+      cursor: this.cursor,
+      limit,
+    })
+    if (res.success) {
+      this.cursor = res.data.cursor
+      return {
+        cursor: res.data.cursor,
+        feed: res.data.feed,
+      }
+    }
+    return {
+      feed: [],
+    }
+  }
+}
diff --git a/src/lib/api/feed/custom.ts b/src/lib/api/feed/custom.ts
new file mode 100644
index 000000000..d05d5acd6
--- /dev/null
+++ b/src/lib/api/feed/custom.ts
@@ -0,0 +1,52 @@
+import {
+  AppBskyFeedDefs,
+  AppBskyFeedGetFeed as GetCustomFeed,
+} from '@atproto/api'
+import {RootStoreModel} from 'state/index'
+import {FeedAPI, FeedAPIResponse} from './types'
+
+export class CustomFeedAPI implements FeedAPI {
+  cursor: string | undefined
+
+  constructor(
+    public rootStore: RootStoreModel,
+    public params: GetCustomFeed.QueryParams,
+  ) {}
+
+  reset() {
+    this.cursor = undefined
+  }
+
+  async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
+    const res = await this.rootStore.agent.app.bsky.feed.getFeed({
+      ...this.params,
+      limit: 1,
+    })
+    return res.data.feed[0]
+  }
+
+  async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
+    const res = await this.rootStore.agent.app.bsky.feed.getFeed({
+      ...this.params,
+      cursor: this.cursor,
+      limit,
+    })
+    if (res.success) {
+      this.cursor = res.data.cursor
+      // NOTE
+      // some custom feeds fail to enforce the pagination limit
+      // so we manually truncate here
+      // -prf
+      if (res.data.feed.length > limit) {
+        res.data.feed = res.data.feed.slice(0, limit)
+      }
+      return {
+        cursor: res.data.cursor,
+        feed: res.data.feed,
+      }
+    }
+    return {
+      feed: [],
+    }
+  }
+}
diff --git a/src/lib/api/feed/following.ts b/src/lib/api/feed/following.ts
new file mode 100644
index 000000000..f14807a57
--- /dev/null
+++ b/src/lib/api/feed/following.ts
@@ -0,0 +1,37 @@
+import {AppBskyFeedDefs} from '@atproto/api'
+import {RootStoreModel} from 'state/index'
+import {FeedAPI, FeedAPIResponse} from './types'
+
+export class FollowingFeedAPI implements FeedAPI {
+  cursor: string | undefined
+
+  constructor(public rootStore: RootStoreModel) {}
+
+  reset() {
+    this.cursor = undefined
+  }
+
+  async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
+    const res = await this.rootStore.agent.getTimeline({
+      limit: 1,
+    })
+    return res.data.feed[0]
+  }
+
+  async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
+    const res = await this.rootStore.agent.getTimeline({
+      cursor: this.cursor,
+      limit,
+    })
+    if (res.success) {
+      this.cursor = res.data.cursor
+      return {
+        cursor: res.data.cursor,
+        feed: res.data.feed,
+      }
+    }
+    return {
+      feed: [],
+    }
+  }
+}
diff --git a/src/lib/api/feed/likes.ts b/src/lib/api/feed/likes.ts
new file mode 100644
index 000000000..e9bb14b0b
--- /dev/null
+++ b/src/lib/api/feed/likes.ts
@@ -0,0 +1,45 @@
+import {
+  AppBskyFeedDefs,
+  AppBskyFeedGetActorLikes as GetActorLikes,
+} from '@atproto/api'
+import {RootStoreModel} from 'state/index'
+import {FeedAPI, FeedAPIResponse} from './types'
+
+export class LikesFeedAPI implements FeedAPI {
+  cursor: string | undefined
+
+  constructor(
+    public rootStore: RootStoreModel,
+    public params: GetActorLikes.QueryParams,
+  ) {}
+
+  reset() {
+    this.cursor = undefined
+  }
+
+  async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
+    const res = await this.rootStore.agent.getActorLikes({
+      ...this.params,
+      limit: 1,
+    })
+    return res.data.feed[0]
+  }
+
+  async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
+    const res = await this.rootStore.agent.getActorLikes({
+      ...this.params,
+      cursor: this.cursor,
+      limit,
+    })
+    if (res.success) {
+      this.cursor = res.data.cursor
+      return {
+        cursor: res.data.cursor,
+        feed: res.data.feed,
+      }
+    }
+    return {
+      feed: [],
+    }
+  }
+}
diff --git a/src/lib/api/feed/merge.ts b/src/lib/api/feed/merge.ts
new file mode 100644
index 000000000..51a619589
--- /dev/null
+++ b/src/lib/api/feed/merge.ts
@@ -0,0 +1,236 @@
+import {AppBskyFeedDefs, AppBskyFeedGetTimeline} from '@atproto/api'
+import shuffle from 'lodash.shuffle'
+import {RootStoreModel} from 'state/index'
+import {timeout} from 'lib/async/timeout'
+import {bundleAsync} from 'lib/async/bundle'
+import {feedUriToHref} from 'lib/strings/url-helpers'
+import {FeedAPI, FeedAPIResponse, FeedSourceInfo} from './types'
+
+const REQUEST_WAIT_MS = 500 // 500ms
+const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours
+
+export class MergeFeedAPI implements FeedAPI {
+  following: MergeFeedSource_Following
+  customFeeds: MergeFeedSource_Custom[] = []
+  feedCursor = 0
+  itemCursor = 0
+  sampleCursor = 0
+
+  constructor(public rootStore: RootStoreModel) {
+    this.following = new MergeFeedSource_Following(this.rootStore)
+  }
+
+  reset() {
+    this.following = new MergeFeedSource_Following(this.rootStore)
+    this.customFeeds = [] // just empty the array, they will be captured in _fetchNext()
+    this.feedCursor = 0
+    this.itemCursor = 0
+    this.sampleCursor = 0
+  }
+
+  async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
+    const res = await this.rootStore.agent.getTimeline({
+      limit: 1,
+    })
+    return res.data.feed[0]
+  }
+
+  async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> {
+    // we capture here to ensure the data has loaded
+    this._captureFeedsIfNeeded()
+
+    const promises = []
+
+    // always keep following topped up
+    if (this.following.numReady < limit) {
+      promises.push(this.following.fetchNext(30))
+    }
+
+    // pick the next feeds to sample from
+    const feeds = this.customFeeds.slice(this.feedCursor, this.feedCursor + 3)
+    this.feedCursor += 3
+    if (this.feedCursor > this.customFeeds.length) {
+      this.feedCursor = 0
+    }
+
+    // top up the feeds
+    for (const feed of feeds) {
+      if (feed.numReady < 5) {
+        promises.push(feed.fetchNext(10))
+      }
+    }
+
+    // wait for requests (all capped at a fixed timeout)
+    await Promise.all(promises)
+
+    // assemble a response by sampling from feeds with content
+    const posts: AppBskyFeedDefs.FeedViewPost[] = []
+    while (posts.length < limit) {
+      let slice = this.sampleItem()
+      if (slice[0]) {
+        posts.push(slice[0])
+      } else {
+        break
+      }
+    }
+
+    return {
+      cursor: posts.length ? 'fake' : undefined,
+      feed: posts,
+    }
+  }
+
+  sampleItem() {
+    const i = this.itemCursor++
+    const candidateFeeds = this.customFeeds.filter(f => f.numReady > 0)
+    const canSample = candidateFeeds.length > 0
+    const hasFollows = this.following.numReady > 0
+
+    // this condition establishes the frequency that custom feeds are woven into follows
+    const shouldSample =
+      i >= 15 && candidateFeeds.length >= 2 && (i % 4 === 0 || i % 5 === 0)
+
+    if (!canSample && !hasFollows) {
+      // no data available
+      return []
+    }
+    if (shouldSample || !hasFollows) {
+      // time to sample, or the user isnt following anybody
+      return candidateFeeds[this.sampleCursor++ % candidateFeeds.length].take(1)
+    }
+    // not time to sample
+    return this.following.take(1)
+  }
+
+  _captureFeedsIfNeeded() {
+    if (!this.rootStore.preferences.homeFeedMergeFeedEnabled) {
+      return
+    }
+    if (this.customFeeds.length === 0) {
+      this.customFeeds = shuffle(
+        this.rootStore.me.savedFeeds.all.map(
+          feed =>
+            new MergeFeedSource_Custom(
+              this.rootStore,
+              feed.uri,
+              feed.displayName,
+            ),
+        ),
+      )
+    }
+  }
+}
+
+class MergeFeedSource {
+  sourceInfo: FeedSourceInfo | undefined
+  cursor: string | undefined = undefined
+  queue: AppBskyFeedDefs.FeedViewPost[] = []
+  hasMore = true
+
+  constructor(public rootStore: RootStoreModel) {}
+
+  get numReady() {
+    return this.queue.length
+  }
+
+  get needsFetch() {
+    return this.hasMore && this.queue.length === 0
+  }
+
+  reset() {
+    this.cursor = undefined
+    this.queue = []
+    this.hasMore = true
+  }
+
+  take(n: number): AppBskyFeedDefs.FeedViewPost[] {
+    return this.queue.splice(0, n)
+  }
+
+  async fetchNext(n: number) {
+    await Promise.race([this._fetchNextInner(n), timeout(REQUEST_WAIT_MS)])
+  }
+
+  _fetchNextInner = bundleAsync(async (n: number) => {
+    const res = await this._getFeed(this.cursor, n)
+    if (res.success) {
+      this.cursor = res.data.cursor
+      if (res.data.feed.length) {
+        this.queue = this.queue.concat(res.data.feed)
+      } else {
+        this.hasMore = false
+      }
+    } else {
+      this.hasMore = false
+    }
+  })
+
+  protected _getFeed(
+    _cursor: string | undefined,
+    _limit: number,
+  ): Promise<AppBskyFeedGetTimeline.Response> {
+    throw new Error('Must be overridden')
+  }
+}
+
+class MergeFeedSource_Following extends MergeFeedSource {
+  async fetchNext(n: number) {
+    return this._fetchNextInner(n)
+  }
+
+  protected async _getFeed(
+    cursor: string | undefined,
+    limit: number,
+  ): Promise<AppBskyFeedGetTimeline.Response> {
+    const res = await this.rootStore.agent.getTimeline({cursor, limit})
+    // filter out mutes pre-emptively to ensure better mixing
+    res.data.feed = res.data.feed.filter(
+      post => !post.post.author.viewer?.muted,
+    )
+    return res
+  }
+}
+
+class MergeFeedSource_Custom extends MergeFeedSource {
+  minDate: Date
+
+  constructor(
+    public rootStore: RootStoreModel,
+    public feedUri: string,
+    public feedDisplayName: string,
+  ) {
+    super(rootStore)
+    this.sourceInfo = {
+      displayName: feedDisplayName,
+      uri: feedUriToHref(feedUri),
+    }
+    this.minDate = new Date(Date.now() - POST_AGE_CUTOFF)
+  }
+
+  protected async _getFeed(
+    cursor: string | undefined,
+    limit: number,
+  ): Promise<AppBskyFeedGetTimeline.Response> {
+    const res = await this.rootStore.agent.app.bsky.feed.getFeed({
+      cursor,
+      limit,
+      feed: this.feedUri,
+    })
+    // NOTE
+    // some custom feeds fail to enforce the pagination limit
+    // so we manually truncate here
+    // -prf
+    if (limit && res.data.feed.length > limit) {
+      res.data.feed = res.data.feed.slice(0, limit)
+    }
+    // filter out older posts
+    res.data.feed = res.data.feed.filter(
+      post => new Date(post.post.indexedAt) > this.minDate,
+    )
+    // attach source info
+    for (const post of res.data.feed) {
+      post.__source = this.sourceInfo
+    }
+    return res
+  }
+}
diff --git a/src/lib/api/feed/types.ts b/src/lib/api/feed/types.ts
new file mode 100644
index 000000000..006344334
--- /dev/null
+++ b/src/lib/api/feed/types.ts
@@ -0,0 +1,17 @@
+import {AppBskyFeedDefs} from '@atproto/api'
+
+export interface FeedAPIResponse {
+  cursor?: string
+  feed: AppBskyFeedDefs.FeedViewPost[]
+}
+
+export interface FeedAPI {
+  reset(): void
+  peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost>
+  fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse>
+}
+
+export interface FeedSourceInfo {
+  uri: string
+  displayName: string
+}
diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx
index 233f8a473..fef7be2f3 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, Circle} from 'react-native-svg'
+import Svg, {Path, Rect, Line, Ellipse} from 'react-native-svg'
 
 export function GridIcon({
   style,
@@ -884,45 +884,7 @@ export function HandIcon({
   )
 }
 
-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({
+export function HashtagIcon({
   style,
   size,
   strokeWidth = 1.5,
@@ -934,26 +896,16 @@ export function SatelliteDishIcon({
   return (
     <Svg
       fill="none"
-      viewBox="0 0 22 22"
-      strokeWidth={strokeWidth}
       stroke="currentColor"
+      viewBox="0 0 30 30"
+      strokeWidth={strokeWidth}
       width={size}
       height={size}
       style={style}>
-      <Path d="M 12.705346,15.777547 C 14.4635,17.5315 14.7526,17.8509 14.9928,18.1812 c 0.2139,0.2943 0.3371,0.5275 0.3889,0.6822 C 14.0859,19.5872 12.5926,20 11,20 6.02944,20 2,15.9706 2,11 2,9.4151 2.40883,7.9285 3.12619,6.63699 3.304,6.69748 3.56745,6.84213 3.89275,7.08309 4.3705644,7.4380098 4.7486794,7.8160923 6.4999995,9.5689376 8.2513197,11.321783 10.947192,14.023595 12.705346,15.777547 Z" />
-      <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"
-        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"
-        strokeLinecap="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"
-      />
+      <Path d="M2 10H28" strokeLinecap="round" />
+      <Path d="M2 20H28" strokeLinecap="round" />
+      <Path d="M11 3L9 27" strokeLinecap="round" />
+      <Path d="M21 3L19 27" strokeLinecap="round" />
     </Svg>
   )
 }
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 7159bcb51..cc7a468e9 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -9,7 +9,6 @@ export type CommonNavigatorParams = {
   ModerationMuteLists: undefined
   ModerationMutedAccounts: undefined
   ModerationBlockedAccounts: undefined
-  DiscoverFeeds: undefined
   Settings: undefined
   Profile: {name: string; hideBackButton?: boolean}
   ProfileFollowers: {name: string}
diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts
index b509aad01..671dc9781 100644
--- a/src/lib/strings/url-helpers.ts
+++ b/src/lib/strings/url-helpers.ts
@@ -129,6 +129,15 @@ export function listUriToHref(url: string): string {
   }
 }
 
+export function feedUriToHref(url: string): string {
+  try {
+    const {hostname, rkey} = new AtUri(url)
+    return `/profile/${hostname}/feed/${rkey}`
+  } catch {
+    return ''
+  }
+}
+
 export function getYoutubeVideoId(link: string): string | undefined {
   let url
   try {