about summary refs log tree commit diff
path: root/src/lib/api
diff options
context:
space:
mode:
Diffstat (limited to 'src/lib/api')
-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
7 files changed, 474 insertions, 5 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
+}