about summary refs log tree commit diff
path: root/src/state/models/feed-view.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/state/models/feed-view.ts')
-rw-r--r--src/state/models/feed-view.ts644
1 files changed, 0 insertions, 644 deletions
diff --git a/src/state/models/feed-view.ts b/src/state/models/feed-view.ts
deleted file mode 100644
index 349723fbb..000000000
--- a/src/state/models/feed-view.ts
+++ /dev/null
@@ -1,644 +0,0 @@
-import {makeAutoObservable, runInAction} from 'mobx'
-import {
-  AppBskyFeedGetTimeline as GetTimeline,
-  AppBskyFeedDefs,
-  AppBskyFeedPost,
-  AppBskyFeedGetAuthorFeed as GetAuthorFeed,
-  RichText,
-  jsonToLex,
-} from '@atproto/api'
-import AwaitLock from 'await-lock'
-import {bundleAsync} from 'lib/async/bundle'
-import sampleSize from 'lodash.samplesize'
-import {RootStoreModel} from './root-store'
-import {cleanError} from 'lib/strings/errors'
-import {SUGGESTED_FOLLOWS} from 'lib/constants'
-import {
-  getCombinedCursors,
-  getMultipleAuthorsPosts,
-  mergePosts,
-} from 'lib/api/build-suggested-posts'
-import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip'
-
-type FeedViewPost = AppBskyFeedDefs.FeedViewPost
-type ReasonRepost = AppBskyFeedDefs.ReasonRepost
-type PostView = AppBskyFeedDefs.PostView
-
-const PAGE_SIZE = 30
-let _idCounter = 0
-
-export class FeedItemModel {
-  // ui state
-  _reactKey: string = ''
-
-  // data
-  post: PostView
-  postRecord?: AppBskyFeedPost.Record
-  reply?: FeedViewPost['reply']
-  reason?: FeedViewPost['reason']
-  richText?: RichText
-
-  constructor(
-    public rootStore: RootStoreModel,
-    reactKey: string,
-    v: FeedViewPost,
-  ) {
-    this._reactKey = reactKey
-    this.post = v.post
-    if (AppBskyFeedPost.isRecord(this.post.record)) {
-      const valid = AppBskyFeedPost.validateRecord(this.post.record)
-      if (valid.success) {
-        this.postRecord = this.post.record
-        this.richText = new RichText(this.postRecord, {cleanNewlines: true})
-      } else {
-        this.postRecord = undefined
-        this.richText = undefined
-        rootStore.log.warn(
-          'Received an invalid app.bsky.feed.post record',
-          valid.error,
-        )
-      }
-    } else {
-      this.postRecord = undefined
-      this.richText = undefined
-      rootStore.log.warn(
-        'app.bsky.feed.getTimeline or app.bsky.feed.getAuthorFeed served an unexpected record type',
-        this.post.record,
-      )
-    }
-    this.reply = v.reply
-    this.reason = v.reason
-    makeAutoObservable(this, {rootStore: false})
-  }
-
-  copy(v: FeedViewPost) {
-    this.post = v.post
-    this.reply = v.reply
-    this.reason = v.reason
-  }
-
-  copyMetrics(v: FeedViewPost) {
-    this.post.replyCount = v.post.replyCount
-    this.post.repostCount = v.post.repostCount
-    this.post.likeCount = v.post.likeCount
-    this.post.viewer = v.post.viewer
-  }
-
-  get reasonRepost(): ReasonRepost | undefined {
-    if (this.reason?.$type === 'app.bsky.feed.feedViewPost#reasonRepost') {
-      return this.reason as ReasonRepost
-    }
-  }
-
-  async toggleLike() {
-    if (this.post.viewer?.like) {
-      await this.rootStore.agent.deleteLike(this.post.viewer.like)
-      runInAction(() => {
-        this.post.likeCount = this.post.likeCount || 0
-        this.post.viewer = this.post.viewer || {}
-        this.post.likeCount--
-        this.post.viewer.like = undefined
-      })
-    } else {
-      const res = await this.rootStore.agent.like(this.post.uri, this.post.cid)
-      runInAction(() => {
-        this.post.likeCount = this.post.likeCount || 0
-        this.post.viewer = this.post.viewer || {}
-        this.post.likeCount++
-        this.post.viewer.like = res.uri
-      })
-    }
-  }
-
-  async toggleRepost() {
-    if (this.post.viewer?.repost) {
-      await this.rootStore.agent.deleteRepost(this.post.viewer.repost)
-      runInAction(() => {
-        this.post.repostCount = this.post.repostCount || 0
-        this.post.viewer = this.post.viewer || {}
-        this.post.repostCount--
-        this.post.viewer.repost = undefined
-      })
-    } else {
-      const res = await this.rootStore.agent.repost(
-        this.post.uri,
-        this.post.cid,
-      )
-      runInAction(() => {
-        this.post.repostCount = this.post.repostCount || 0
-        this.post.viewer = this.post.viewer || {}
-        this.post.repostCount++
-        this.post.viewer.repost = res.uri
-      })
-    }
-  }
-
-  async delete() {
-    await this.rootStore.agent.deletePost(this.post.uri)
-    this.rootStore.emitPostDeleted(this.post.uri)
-  }
-}
-
-export class FeedSliceModel {
-  // ui state
-  _reactKey: string = ''
-
-  // data
-  items: FeedItemModel[] = []
-
-  constructor(
-    public rootStore: RootStoreModel,
-    reactKey: string,
-    slice: FeedViewPostsSlice,
-  ) {
-    this._reactKey = reactKey
-    for (const item of slice.items) {
-      this.items.push(
-        new FeedItemModel(rootStore, `item-${_idCounter++}`, item),
-      )
-    }
-    makeAutoObservable(this, {rootStore: false})
-  }
-
-  get uri() {
-    if (this.isReply) {
-      return this.items[1].post.uri
-    }
-    return this.items[0].post.uri
-  }
-
-  get isThread() {
-    return (
-      this.items.length > 1 &&
-      this.items.every(
-        item => item.post.author.did === this.items[0].post.author.did,
-      )
-    )
-  }
-
-  get isReply() {
-    return this.items.length > 1 && !this.isThread
-  }
-
-  get rootItem() {
-    if (this.isReply) {
-      return this.items[1]
-    }
-    return this.items[0]
-  }
-
-  containsUri(uri: string) {
-    return !!this.items.find(item => item.post.uri === uri)
-  }
-
-  isThreadParentAt(i: number) {
-    if (this.items.length === 1) {
-      return false
-    }
-    return i < this.items.length - 1
-  }
-
-  isThreadChildAt(i: number) {
-    if (this.items.length === 1) {
-      return false
-    }
-    return i > 0
-  }
-}
-
-export class FeedModel {
-  // state
-  isLoading = false
-  isRefreshing = false
-  hasNewLatest = false
-  hasLoaded = false
-  error = ''
-  params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams
-  hasMore = true
-  loadMoreCursor: string | undefined
-  pollCursor: string | undefined
-  tuner = new FeedTuner()
-
-  // used to linearize async modifications to state
-  lock = new AwaitLock()
-
-  // data
-  slices: FeedSliceModel[] = []
-  nextSlices: FeedSliceModel[] = []
-
-  constructor(
-    public rootStore: RootStoreModel,
-    public feedType: 'home' | 'author' | 'suggested' | 'goodstuff',
-    params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams,
-  ) {
-    makeAutoObservable(
-      this,
-      {
-        rootStore: false,
-        params: false,
-        loadMoreCursor: false,
-      },
-      {autoBind: true},
-    )
-    this.params = params
-  }
-
-  get hasContent() {
-    return this.slices.length !== 0
-  }
-
-  get hasError() {
-    return this.error !== ''
-  }
-
-  get isEmpty() {
-    return this.hasLoaded && !this.hasContent
-  }
-
-  get nonReplyFeed() {
-    if (this.feedType === 'author') {
-      return this.slices.filter(slice => {
-        const params = this.params as GetAuthorFeed.QueryParams
-        const item = slice.rootItem
-        const isRepost =
-          item?.reasonRepost?.by?.handle === params.actor ||
-          item?.reasonRepost?.by?.did === params.actor
-        return (
-          !item.reply || // not a reply
-          isRepost || // but allow if it's a repost
-          (slice.isThread && // or a thread by the user
-            item.reply?.root.author.did === item.post.author.did)
-        )
-      })
-    } else {
-      return this.slices
-    }
-  }
-
-  setHasNewLatest(v: boolean) {
-    this.hasNewLatest = v
-  }
-
-  // public api
-  // =
-
-  /**
-   * Nuke all data
-   */
-  clear() {
-    this.rootStore.log.debug('FeedModel:clear')
-    this.isLoading = false
-    this.isRefreshing = false
-    this.hasNewLatest = false
-    this.hasLoaded = false
-    this.error = ''
-    this.hasMore = true
-    this.loadMoreCursor = undefined
-    this.pollCursor = undefined
-    this.slices = []
-    this.nextSlices = []
-    this.tuner.reset()
-  }
-
-  switchFeedType(feedType: 'home' | 'suggested') {
-    if (this.feedType === feedType) {
-      return
-    }
-    this.feedType = feedType
-    return this.setup()
-  }
-
-  get feedTuners() {
-    if (this.feedType === 'goodstuff') {
-      return [
-        FeedTuner.dedupReposts,
-        FeedTuner.likedRepliesOnly,
-        FeedTuner.preferredLangOnly(
-          this.rootStore.preferences.contentLanguages,
-        ),
-      ]
-    }
-    if (this.feedType === 'home') {
-      return [FeedTuner.dedupReposts, FeedTuner.likedRepliesOnly]
-    }
-    return []
-  }
-
-  /**
-   * Load for first render
-   */
-  setup = bundleAsync(async (isRefreshing: boolean = false) => {
-    this.rootStore.log.debug('FeedModel:setup', {isRefreshing})
-    if (isRefreshing) {
-      this.isRefreshing = true // set optimistically for UI
-    }
-    await this.lock.acquireAsync()
-    try {
-      this.setHasNewLatest(false)
-      this.tuner.reset()
-      this._xLoading(isRefreshing)
-      try {
-        const res = await this._getFeed({limit: PAGE_SIZE})
-        await this._replaceAll(res)
-        this._xIdle()
-      } catch (e: any) {
-        this._xIdle(e)
-      }
-    } finally {
-      this.lock.release()
-    }
-  })
-
-  /**
-   * Register any event listeners. Returns a cleanup function.
-   */
-  registerListeners() {
-    const sub = this.rootStore.onPostDeleted(this.onPostDeleted.bind(this))
-    return () => sub.remove()
-  }
-
-  /**
-   * Reset and load
-   */
-  async refresh() {
-    await this.setup(true)
-  }
-
-  /**
-   * Load more posts to the end of the feed
-   */
-  loadMore = bundleAsync(async () => {
-    await this.lock.acquireAsync()
-    try {
-      if (!this.hasMore || this.hasError) {
-        return
-      }
-      this._xLoading()
-      try {
-        const res = await this._getFeed({
-          cursor: this.loadMoreCursor,
-          limit: PAGE_SIZE,
-        })
-        await this._appendAll(res)
-        this._xIdle()
-      } catch (e: any) {
-        this._xIdle() // don't bubble the error to the user
-        this.rootStore.log.error('FeedView: Failed to load more', {
-          params: this.params,
-          e,
-        })
-        this.hasMore = false
-      }
-    } finally {
-      this.lock.release()
-    }
-  })
-
-  /**
-   * Update content in-place
-   */
-  update = bundleAsync(async () => {
-    await this.lock.acquireAsync()
-    try {
-      if (!this.slices.length) {
-        return
-      }
-      this._xLoading()
-      let numToFetch = this.slices.length
-      let cursor
-      try {
-        do {
-          const res: GetTimeline.Response = await this._getFeed({
-            cursor,
-            limit: Math.min(numToFetch, 100),
-          })
-          if (res.data.feed.length === 0) {
-            break // sanity check
-          }
-          this._updateAll(res)
-          numToFetch -= res.data.feed.length
-          cursor = res.data.cursor
-        } while (cursor && numToFetch > 0)
-        this._xIdle()
-      } catch (e: any) {
-        this._xIdle() // don't bubble the error to the user
-        this.rootStore.log.error('FeedView: Failed to update', {
-          params: this.params,
-          e,
-        })
-      }
-    } finally {
-      this.lock.release()
-    }
-  })
-
-  /**
-   * Check if new posts are available
-   */
-  async checkForLatest({autoPrepend}: {autoPrepend?: boolean} = {}) {
-    if (this.hasNewLatest || this.feedType === 'suggested') {
-      return
-    }
-    const res = await this._getFeed({limit: PAGE_SIZE})
-    const tuner = new FeedTuner()
-    const nextSlices = tuner.tune(res.data.feed, this.feedTuners)
-    if (nextSlices[0]?.uri !== this.slices[0]?.uri) {
-      const nextSlicesModels = nextSlices.map(
-        slice =>
-          new FeedSliceModel(this.rootStore, `item-${_idCounter++}`, slice),
-      )
-      if (autoPrepend) {
-        runInAction(() => {
-          this.slices = nextSlicesModels.concat(
-            this.slices.filter(slice1 =>
-              nextSlicesModels.find(slice2 => slice1.uri === slice2.uri),
-            ),
-          )
-          this.setHasNewLatest(false)
-        })
-      } else {
-        runInAction(() => {
-          this.nextSlices = nextSlicesModels
-        })
-        this.setHasNewLatest(true)
-      }
-    } else {
-      this.setHasNewLatest(false)
-    }
-  }
-
-  /**
-   * Sets the current slices to the "next slices" loaded by checkForLatest
-   */
-  resetToLatest() {
-    if (this.nextSlices.length) {
-      this.slices = this.nextSlices
-    }
-    this.setHasNewLatest(false)
-  }
-
-  /**
-   * Removes posts from the feed upon deletion.
-   */
-  onPostDeleted(uri: string) {
-    let i
-    do {
-      i = this.slices.findIndex(slice => slice.containsUri(uri))
-      if (i !== -1) {
-        this.slices.splice(i, 1)
-      }
-    } while (i !== -1)
-  }
-
-  // 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('Posts feed request failed', err)
-    }
-  }
-
-  // helper functions
-  // =
-
-  async _replaceAll(res: GetTimeline.Response | GetAuthorFeed.Response) {
-    this.pollCursor = res.data.feed[0]?.post.uri
-    return this._appendAll(res, true)
-  }
-
-  async _appendAll(
-    res: GetTimeline.Response | GetAuthorFeed.Response,
-    replace = false,
-  ) {
-    this.loadMoreCursor = res.data.cursor
-    this.hasMore = !!this.loadMoreCursor
-
-    const slices = this.tuner.tune(res.data.feed, this.feedTuners)
-
-    const toAppend: FeedSliceModel[] = []
-    for (const slice of slices) {
-      const sliceModel = new FeedSliceModel(
-        this.rootStore,
-        `item-${_idCounter++}`,
-        slice,
-      )
-      toAppend.push(sliceModel)
-    }
-    runInAction(() => {
-      if (replace) {
-        this.slices = toAppend
-      } else {
-        this.slices = this.slices.concat(toAppend)
-      }
-    })
-  }
-
-  _updateAll(res: GetTimeline.Response | GetAuthorFeed.Response) {
-    for (const item of res.data.feed) {
-      const existingSlice = this.slices.find(slice =>
-        slice.containsUri(item.post.uri),
-      )
-      if (existingSlice) {
-        const existingItem = existingSlice.items.find(
-          item2 => item2.post.uri === item.post.uri,
-        )
-        if (existingItem) {
-          existingItem.copyMetrics(item)
-        }
-      }
-    }
-  }
-
-  protected async _getFeed(
-    params: GetTimeline.QueryParams | GetAuthorFeed.QueryParams = {},
-  ): Promise<GetTimeline.Response | GetAuthorFeed.Response> {
-    params = Object.assign({}, this.params, params)
-    if (this.feedType === 'suggested') {
-      const responses = await getMultipleAuthorsPosts(
-        this.rootStore,
-        sampleSize(SUGGESTED_FOLLOWS(String(this.rootStore.agent.service)), 20),
-        params.cursor,
-        20,
-      )
-      const combinedCursor = getCombinedCursors(responses)
-      const finalData = mergePosts(responses, {bestOfOnly: true})
-      const lastHeaders = responses[responses.length - 1].headers
-      return {
-        success: true,
-        data: {
-          feed: finalData,
-          cursor: combinedCursor,
-        },
-        headers: lastHeaders,
-      }
-    } else if (this.feedType === 'home') {
-      return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams)
-    } else if (this.feedType === 'goodstuff') {
-      const res = await getGoodStuff(
-        this.rootStore.session.currentSession?.accessJwt || '',
-        params as GetTimeline.QueryParams,
-      )
-      res.data.feed = (res.data.feed || []).filter(
-        item => !item.post.author.viewer?.muted,
-      )
-      return res
-    } else {
-      return this.rootStore.agent.getAuthorFeed(
-        params as GetAuthorFeed.QueryParams,
-      )
-    }
-  }
-}
-
-// HACK
-// temporary off-spec route to get the good stuff
-// -prf
-async function getGoodStuff(
-  accessJwt: string,
-  params: GetTimeline.QueryParams,
-): Promise<GetTimeline.Response> {
-  const controller = new AbortController()
-  const to = setTimeout(() => controller.abort(), 15e3)
-
-  const uri = new URL('https://bsky.social/xrpc/app.bsky.unspecced.getPopular')
-  let k: keyof GetTimeline.QueryParams
-  for (k in params) {
-    if (typeof params[k] !== 'undefined') {
-      uri.searchParams.set(k, String(params[k]))
-    }
-  }
-
-  const res = await fetch(String(uri), {
-    method: 'get',
-    headers: {
-      accept: 'application/json',
-      authorization: `Bearer ${accessJwt}`,
-    },
-    signal: controller.signal,
-  })
-
-  const resHeaders: Record<string, string> = {}
-  res.headers.forEach((value: string, key: string) => {
-    resHeaders[key] = value
-  })
-  let resBody = await res.json()
-
-  clearTimeout(to)
-
-  return {
-    success: res.status === 200,
-    headers: resHeaders,
-    data: jsonToLex(resBody),
-  }
-}