about summary refs log tree commit diff
path: root/src/lib/api/feed/merge.ts
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2023-09-18 11:44:29 -0700
committerGitHub <noreply@github.com>2023-09-18 11:44:29 -0700
commitea885339cf3a5cba4aa82fbe5e0176052c3b68e1 (patch)
treea02b0581c42a1a0aae4442a75391c99a1719ec3e /src/lib/api/feed/merge.ts
parent3118e3e93338c62d2466699b9f339544d3273823 (diff)
downloadvoidsky-ea885339cf3a5cba4aa82fbe5e0176052c3b68e1.tar.zst
Feed UI update working branch [WIP] (#1420)
* Feeds navigation on right side of desktop (#1403)

* Remove home feed header on desktop

* Add feeds to right sidebar

* Add simple non-moving header to desktop

* Improve loading state of custom feed header

* Remove log

Co-authored-by: Eric Bailey <git@esb.lol>

* Remove dead comment

---------

Co-authored-by: Eric Bailey <git@esb.lol>

* Redesign feeds tab (#1439)

* consolidate saved feeds and discover into one screen

* Add hoverStyle behavior to <Link>

* More UI work on SavedFeeds

* Replace satellite icon with a hashtag

* Tune My Feeds mobile ui

* Handle no results in my feeds

* Remove old DiscoverFeeds screen

* Remove multifeed

* Remove DiscoverFeeds from router

* Improve loading placeholders

* Small fixes

* Fix types

* Fix overflow issue on firefox

* Add icons prompting to open feeds

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

* Merge feed prototype [WIP] (#1398)

* POC WIP for the mergefeed

* Add feed API wrapper and move mergefeed into it

* Show feed source in mergefeed

* Add lodash.random dep

* Improve mergefeed sampling and reliability

* Tune source ui element

* Improve mergefeed edge condition handling

* Remove in-place update of feeds for performance

* Fix link on native

* Fix bad ref

* Improve variety in mergefeed sampling

* Fix types

* Fix rebase error

* Add missing source field (got dropped in merge)

* Update find more link

* Simplify the right hand feeds nav

* Bring back load latest button on desktop & unify impl

* Add 'From' to source

* Add simple headers to desktop home & notifications

* Fix thread view jumping around horizontally

* Add unread indicators to desktop headers

* Add home feed preference for enabling the mergefeed

* Add a preference for showing replies among followed users only (#1448)

* Add a preference for showing replies among followed users only

* Simplify the reply filter UI

* Fix typo

* Simplified custom feed header

* Add soft reset to custom feed screen

* Drop all the in-post translate links except when expanded (#1455)

* Update mobile feed settings links to match desktop

* Fixes to feeds screen loading states

* Bolder active state of feeds tab on mobile web

* Fix dark mode issue

---------

Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Ansh <anshnanda10@gmail.com>
Diffstat (limited to 'src/lib/api/feed/merge.ts')
-rw-r--r--src/lib/api/feed/merge.ts236
1 files changed, 236 insertions, 0 deletions
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
+  }
+}