about summary refs log tree commit diff
path: root/src/state/models/content/feed-source.ts
diff options
context:
space:
mode:
authorPaul Frazee <pfrazee@gmail.com>2023-11-01 16:15:40 -0700
committerGitHub <noreply@github.com>2023-11-01 16:15:40 -0700
commitf57a8cf8ba0cd10a54abf35d960d8fb90266fa6b (patch)
treea9da6032bcbd587d92fd1030e698aea2dbef9f72 /src/state/models/content/feed-source.ts
parentf9944b55e26fe6109bc2e7a25b88979111470ed9 (diff)
downloadvoidsky-f57a8cf8ba0cd10a54abf35d960d8fb90266fa6b.tar.zst
Lists updates: curate lists and blocklists (#1689)
* Add lists screen

* Update Lists screen and List create/edit modal to support curate lists

* Rework the ProfileList screen and add curatelist support

* More ProfileList progress

* Update list modals

* Rename mutelists to modlists

* Layout updates/fixes

* More layout fixes

* Modal fixes

* List list screen updates

* Update feed page to give more info

* Layout fixes to ListAddUser modal

* Layout fixes to FlatList and Feed on desktop

* Layout fix to LoadLatestBtn on Web

* Handle did resolution before showing the ProfileList screen

* Rename the CustomFeed routes to ProfileFeed for consistency

* Fix layout issues with the pager and feeds

* Factor out some common code

* Fix UIs for mobile

* Fix user list rendering

* Fix: dont bubble custom feed errors in the merge feed

* Refactor feed models to reduce usage of the SavedFeeds model

* Replace CustomFeedModel with FeedSourceModel which abstracts feed-generators and lists

* Add the ability to pin lists

* Add pinned lists to mobile

* Remove dead code

* Rework the ProfileScreenHeader to create more real-estate for action buttons

* Improve layout behavior on web mobile breakpoints

* Refactor feed & list pages to use new Tabs layout component

* Refactor to ProfileSubpageHeader

* Implement modlist block and mute

* Switch to new api and just modify state on modlist actions

* Fix some UI overflows

* Fix: dont show edit buttons on lists you dont own

* Fix alignment issue on long titles

* Improve loading and error states for feeds & lists

* Update list dropdown icons for ios

* Fetch feed display names in the mergefeed

* Improve rendering off offline feeds in the feed-listing page

* Update Feeds listing UI to react to changes in saved/pinned state

* Refresh list and feed on posts tab press

* Fix pinned feed ordering UI

* Fixes to list pinning

* Remove view=simple qp

* Add list to feed tuners

* Render richtext

* Add list href

* Add 'view avatar'

* Remove unused import

* Fix missing import

* Correctly reflect block by list state

* Replace the <Tabs> component with the more effective <PagerWithHeader> component

* Improve the responsiveness of the PagerWithHeader

* Fix visual jank in the feed loading state

* Improve performance of the PagerWithHeader

* Fix a case that would cause the header to animate too aggressively

* Add the ability to scroll to top by tapping the selected tab

* Fix unit test runner

* Update modlists test

* Add curatelist tests

* Fix: remove link behavior in ListAddUser modal

* Fix some layout jank in the PagerWithHeader on iOS

* Simplify ListItems header rendering

* Wait for the appview to recognize the list before proceeding with list creation

* Fix glitch in the onPageSelecting index of the Pager

* Fix until()

* Copy fix

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

---------

Co-authored-by: Eric Bailey <git@esb.lol>
Diffstat (limited to 'src/state/models/content/feed-source.ts')
-rw-r--r--src/state/models/content/feed-source.ts223
1 files changed, 223 insertions, 0 deletions
diff --git a/src/state/models/content/feed-source.ts b/src/state/models/content/feed-source.ts
new file mode 100644
index 000000000..8dac9b56f
--- /dev/null
+++ b/src/state/models/content/feed-source.ts
@@ -0,0 +1,223 @@
+import {AtUri, RichText, AppBskyFeedDefs, AppBskyGraphDefs} from '@atproto/api'
+import {makeAutoObservable, runInAction} from 'mobx'
+import {RootStoreModel} from 'state/models/root-store'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {bundleAsync} from 'lib/async/bundle'
+import {cleanError} from 'lib/strings/errors'
+import {track} from 'lib/analytics/analytics'
+
+export class FeedSourceModel {
+  // state
+  _reactKey: string
+  hasLoaded = false
+  error: string | undefined
+
+  // data
+  uri: string
+  cid: string = ''
+  type: 'feed-generator' | 'list' | 'unsupported' = 'unsupported'
+  avatar: string | undefined = ''
+  displayName: string = ''
+  descriptionRT: RichText | null = null
+  creatorDid: string = ''
+  creatorHandle: string = ''
+  likeCount: number | undefined = 0
+  likeUri: string | undefined = ''
+
+  constructor(public rootStore: RootStoreModel, uri: string) {
+    this._reactKey = uri
+    this.uri = uri
+
+    try {
+      const urip = new AtUri(uri)
+      if (urip.collection === 'app.bsky.feed.generator') {
+        this.type = 'feed-generator'
+      } else if (urip.collection === 'app.bsky.graph.list') {
+        this.type = 'list'
+      }
+    } catch {}
+    this.displayName = uri.split('/').pop() || ''
+
+    makeAutoObservable(
+      this,
+      {
+        rootStore: false,
+      },
+      {autoBind: true},
+    )
+  }
+
+  get href() {
+    const urip = new AtUri(this.uri)
+    const collection =
+      urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'lists'
+    return `/profile/${urip.hostname}/${collection}/${urip.rkey}`
+  }
+
+  get isSaved() {
+    return this.rootStore.preferences.savedFeeds.includes(this.uri)
+  }
+
+  get isPinned() {
+    return this.rootStore.preferences.isPinnedFeed(this.uri)
+  }
+
+  get isLiked() {
+    return !!this.likeUri
+  }
+
+  get isOwner() {
+    return this.creatorDid === this.rootStore.me.did
+  }
+
+  setup = bundleAsync(async () => {
+    try {
+      if (this.type === 'feed-generator') {
+        const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({
+          feed: this.uri,
+        })
+        this.hydrateFeedGenerator(res.data.view)
+      } else if (this.type === 'list') {
+        const res = await this.rootStore.agent.app.bsky.graph.getList({
+          list: this.uri,
+          limit: 1,
+        })
+        this.hydrateList(res.data.list)
+      }
+    } catch (e) {
+      runInAction(() => {
+        this.error = cleanError(e)
+      })
+    }
+  })
+
+  hydrateFeedGenerator(view: AppBskyFeedDefs.GeneratorView) {
+    this.uri = view.uri
+    this.cid = view.cid
+    this.avatar = view.avatar
+    this.displayName = view.displayName
+      ? sanitizeDisplayName(view.displayName)
+      : `Feed by ${sanitizeHandle(view.creator.handle, '@')}`
+    this.descriptionRT = new RichText({
+      text: view.description || '',
+      facets: (view.descriptionFacets || [])?.slice(),
+    })
+    this.creatorDid = view.creator.did
+    this.creatorHandle = view.creator.handle
+    this.likeCount = view.likeCount
+    this.likeUri = view.viewer?.like
+    this.hasLoaded = true
+  }
+
+  hydrateList(view: AppBskyGraphDefs.ListView) {
+    this.uri = view.uri
+    this.cid = view.cid
+    this.avatar = view.avatar
+    this.displayName = view.name
+      ? sanitizeDisplayName(view.name)
+      : `User List by ${sanitizeHandle(view.creator.handle, '@')}`
+    this.descriptionRT = new RichText({
+      text: view.description || '',
+      facets: (view.descriptionFacets || [])?.slice(),
+    })
+    this.creatorDid = view.creator.did
+    this.creatorHandle = view.creator.handle
+    this.likeCount = undefined
+    this.hasLoaded = true
+  }
+
+  async save() {
+    if (this.type !== 'feed-generator') {
+      return
+    }
+    try {
+      await this.rootStore.preferences.addSavedFeed(this.uri)
+    } catch (error) {
+      this.rootStore.log.error('Failed to save feed', error)
+    } finally {
+      track('CustomFeed:Save')
+    }
+  }
+
+  async unsave() {
+    if (this.type !== 'feed-generator') {
+      return
+    }
+    try {
+      await this.rootStore.preferences.removeSavedFeed(this.uri)
+    } catch (error) {
+      this.rootStore.log.error('Failed to unsave feed', error)
+    } finally {
+      track('CustomFeed:Unsave')
+    }
+  }
+
+  async pin() {
+    try {
+      await this.rootStore.preferences.addPinnedFeed(this.uri)
+    } catch (error) {
+      this.rootStore.log.error('Failed to pin feed', error)
+    } finally {
+      track('CustomFeed:Pin', {
+        name: this.displayName,
+        uri: this.uri,
+      })
+    }
+  }
+
+  async togglePin() {
+    if (!this.isPinned) {
+      track('CustomFeed:Pin', {
+        name: this.displayName,
+        uri: this.uri,
+      })
+      return this.rootStore.preferences.addPinnedFeed(this.uri)
+    } else {
+      track('CustomFeed:Unpin', {
+        name: this.displayName,
+        uri: this.uri,
+      })
+      return this.rootStore.preferences.removePinnedFeed(this.uri)
+    }
+  }
+
+  async like() {
+    if (this.type !== 'feed-generator') {
+      return
+    }
+    try {
+      this.likeUri = 'pending'
+      this.likeCount = (this.likeCount || 0) + 1
+      const res = await this.rootStore.agent.like(this.uri, this.cid)
+      this.likeUri = res.uri
+    } catch (e: any) {
+      this.likeUri = undefined
+      this.likeCount = (this.likeCount || 1) - 1
+      this.rootStore.log.error('Failed to like feed', e)
+    } finally {
+      track('CustomFeed:Like')
+    }
+  }
+
+  async unlike() {
+    if (this.type !== 'feed-generator') {
+      return
+    }
+    if (!this.likeUri) {
+      return
+    }
+    const uri = this.likeUri
+    try {
+      this.likeUri = undefined
+      this.likeCount = (this.likeCount || 1) - 1
+      await this.rootStore.agent.deleteLike(uri!)
+    } catch (e: any) {
+      this.likeUri = uri
+      this.likeCount = (this.likeCount || 0) + 1
+      this.rootStore.log.error('Failed to unlike feed', e)
+    } finally {
+      track('CustomFeed:Unlike')
+    }
+  }
+}