about summary refs log tree commit diff
path: root/src/lib
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/lib
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/lib')
-rw-r--r--src/lib/analytics/types.ts11
-rw-r--r--src/lib/api/feed/list.ts45
-rw-r--r--src/lib/api/feed/merge.ts74
-rw-r--r--src/lib/async/accumulate.ts25
-rw-r--r--src/lib/async/until.ts24
-rw-r--r--src/lib/hooks/useCustomFeed.ts21
-rw-r--r--src/lib/hooks/useDesktopRightNavItems.ts51
-rw-r--r--src/lib/hooks/useHomeTabs.ts29
-rw-r--r--src/lib/icons.tsx27
-rw-r--r--src/lib/moderation.ts15
-rw-r--r--src/lib/routes/links.ts12
-rw-r--r--src/lib/routes/types.ts7
12 files changed, 283 insertions, 58 deletions
diff --git a/src/lib/analytics/types.ts b/src/lib/analytics/types.ts
index d10475908..b2c6f15d6 100644
--- a/src/lib/analytics/types.ts
+++ b/src/lib/analytics/types.ts
@@ -97,10 +97,13 @@ interface TrackPropertiesMap {
   // LISTS events
   'Lists:onRefresh': {}
   'Lists:onEndReached': {}
-  'CreateMuteList:AvatarSelected': {}
-  'CreateMuteList:Save': {} // CAN BE SERVER
-  'Lists:Subscribe': {} // CAN BE SERVER
-  'Lists:Unsubscribe': {} // CAN BE SERVER
+  'CreateList:AvatarSelected': {}
+  'CreateList:SaveCurateList': {} // CAN BE SERVER
+  'CreateList:SaveModList': {} // CAN BE SERVER
+  'Lists:Mute': {} // CAN BE SERVER
+  'Lists:Unmute': {} // CAN BE SERVER
+  'Lists:Block': {} // CAN BE SERVER
+  'Lists:Unblock': {} // CAN BE SERVER
   // CUSTOM FEED events
   'CustomFeed:Save': {}
   'CustomFeed:Unsave': {}
diff --git a/src/lib/api/feed/list.ts b/src/lib/api/feed/list.ts
new file mode 100644
index 000000000..e58494675
--- /dev/null
+++ b/src/lib/api/feed/list.ts
@@ -0,0 +1,45 @@
+import {
+  AppBskyFeedDefs,
+  AppBskyFeedGetListFeed as GetListFeed,
+} from '@atproto/api'
+import {RootStoreModel} from 'state/index'
+import {FeedAPI, FeedAPIResponse} from './types'
+
+export class ListFeedAPI implements FeedAPI {
+  cursor: string | undefined
+
+  constructor(
+    public rootStore: RootStoreModel,
+    public params: GetListFeed.QueryParams,
+  ) {}
+
+  reset() {
+    this.cursor = undefined
+  }
+
+  async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
+    const res = await this.rootStore.agent.app.bsky.feed.getListFeed({
+      ...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.getListFeed({
+      ...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
index 31e27fece..e0fbcecd8 100644
--- a/src/lib/api/feed/merge.ts
+++ b/src/lib/api/feed/merge.ts
@@ -114,13 +114,8 @@ export class MergeFeedAPI implements FeedAPI {
     }
     if (this.customFeeds.length === 0) {
       this.customFeeds = shuffle(
-        this.rootStore.me.savedFeeds.all.map(
-          feed =>
-            new MergeFeedSource_Custom(
-              this.rootStore,
-              feed.uri,
-              feed.displayName,
-            ),
+        this.rootStore.preferences.savedFeeds.map(
+          feedUri => new MergeFeedSource_Custom(this.rootStore, feedUri),
         ),
       )
     }
@@ -213,43 +208,56 @@ class MergeFeedSource_Following extends MergeFeedSource {
 class MergeFeedSource_Custom extends MergeFeedSource {
   minDate: Date
 
-  constructor(
-    public rootStore: RootStoreModel,
-    public feedUri: string,
-    public feedDisplayName: string,
-  ) {
+  constructor(public rootStore: RootStoreModel, public feedUri: string) {
     super(rootStore)
     this.sourceInfo = {
-      displayName: feedDisplayName,
+      displayName: feedUri.split('/').pop() || '',
       uri: feedUriToHref(feedUri),
     }
     this.minDate = new Date(Date.now() - POST_AGE_CUTOFF)
+    this.rootStore.agent.app.bsky.feed
+      .getFeedGenerator({
+        feed: feedUri,
+      })
+      .then(
+        res => {
+          if (this.sourceInfo) {
+            this.sourceInfo.displayName = res.data.view.displayName
+          }
+        },
+        _err => {},
+      )
   }
 
   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
+    try {
+      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
+    } catch {
+      // dont bubble custom-feed errors
+      return {success: false, headers: {}, data: {feed: []}}
     }
-    return res
   }
 }
diff --git a/src/lib/async/accumulate.ts b/src/lib/async/accumulate.ts
new file mode 100644
index 000000000..99226418e
--- /dev/null
+++ b/src/lib/async/accumulate.ts
@@ -0,0 +1,25 @@
+export interface AccumulateResponse<T> {
+  cursor?: string
+  items: T[]
+}
+
+export type AccumulateFetchFn<T> = (
+  cursor: string | undefined,
+) => Promise<AccumulateResponse<T>>
+
+export async function accumulate<T>(
+  fn: AccumulateFetchFn<T>,
+  pageLimit = 100,
+): Promise<T[]> {
+  let cursor: string | undefined
+  let acc: T[] = []
+  for (let i = 0; i < pageLimit; i++) {
+    const res = await fn(cursor)
+    cursor = res.cursor
+    acc = acc.concat(res.items)
+    if (!cursor) {
+      break
+    }
+  }
+  return acc
+}
diff --git a/src/lib/async/until.ts b/src/lib/async/until.ts
new file mode 100644
index 000000000..db53c9218
--- /dev/null
+++ b/src/lib/async/until.ts
@@ -0,0 +1,24 @@
+import {timeout} from './timeout'
+
+export async function until(
+  retries: number,
+  delay: number,
+  cond: (v: any, err: any) => boolean,
+  fn: () => Promise<any>,
+): Promise<boolean> {
+  while (retries > 0) {
+    try {
+      const v = await fn()
+      if (cond(v, undefined)) {
+        return true
+      }
+    } catch (e: any) {
+      if (cond(undefined, e)) {
+        return true
+      }
+    }
+    await timeout(delay)
+    retries--
+  }
+  return false
+}
diff --git a/src/lib/hooks/useCustomFeed.ts b/src/lib/hooks/useCustomFeed.ts
index d7a27050d..04201b9a1 100644
--- a/src/lib/hooks/useCustomFeed.ts
+++ b/src/lib/hooks/useCustomFeed.ts
@@ -1,24 +1,15 @@
 import {useEffect, useState} from 'react'
 import {useStores} from 'state/index'
-import {CustomFeedModel} from 'state/models/feeds/custom-feed'
+import {FeedSourceModel} from 'state/models/content/feed-source'
 
-export function useCustomFeed(uri: string): CustomFeedModel | undefined {
+export function useCustomFeed(uri: string): FeedSourceModel | undefined {
   const store = useStores()
-  const [item, setItem] = useState<CustomFeedModel | undefined>()
+  const [item, setItem] = useState<FeedSourceModel | undefined>()
   useEffect(() => {
-    async function fetchView() {
-      const res = await store.agent.app.bsky.feed.getFeedGenerator({
-        feed: uri,
-      })
-      const view = res.data.view
-      return view
-    }
     async function buildFeedItem() {
-      const view = await fetchView()
-      if (view) {
-        const temp = new CustomFeedModel(store, view)
-        setItem(temp)
-      }
+      const model = new FeedSourceModel(store, uri)
+      await model.setup()
+      setItem(model)
     }
     buildFeedItem()
   }, [store, uri])
diff --git a/src/lib/hooks/useDesktopRightNavItems.ts b/src/lib/hooks/useDesktopRightNavItems.ts
new file mode 100644
index 000000000..f27efd28f
--- /dev/null
+++ b/src/lib/hooks/useDesktopRightNavItems.ts
@@ -0,0 +1,51 @@
+import {useEffect, useState} from 'react'
+import {useStores} from 'state/index'
+import isEqual from 'lodash.isequal'
+import {AtUri} from '@atproto/api'
+import {FeedSourceModel} from 'state/models/content/feed-source'
+
+interface RightNavItem {
+  uri: string
+  href: string
+  hostname: string
+  collection: string
+  rkey: string
+  displayName: string
+}
+
+export function useDesktopRightNavItems(uris: string[]): RightNavItem[] {
+  const store = useStores()
+  const [items, setItems] = useState<RightNavItem[]>([])
+  const [lastUris, setLastUris] = useState<string[]>([])
+
+  useEffect(() => {
+    if (isEqual(uris, lastUris)) {
+      // no changes
+      return
+    }
+
+    async function fetchFeedInfo() {
+      const models = uris
+        .slice(0, 25)
+        .map(uri => new FeedSourceModel(store, uri))
+      await Promise.all(models.map(m => m.setup()))
+      setItems(
+        models.map(model => {
+          const {hostname, collection, rkey} = new AtUri(model.uri)
+          return {
+            uri: model.uri,
+            href: model.href,
+            hostname,
+            collection,
+            rkey,
+            displayName: model.displayName,
+          }
+        }),
+      )
+      setLastUris(uris)
+    }
+    fetchFeedInfo()
+  }, [store, uris, lastUris, setLastUris, setItems])
+
+  return items
+}
diff --git a/src/lib/hooks/useHomeTabs.ts b/src/lib/hooks/useHomeTabs.ts
new file mode 100644
index 000000000..69183e627
--- /dev/null
+++ b/src/lib/hooks/useHomeTabs.ts
@@ -0,0 +1,29 @@
+import {useEffect, useState} from 'react'
+import {useStores} from 'state/index'
+import isEqual from 'lodash.isequal'
+import {FeedSourceModel} from 'state/models/content/feed-source'
+
+export function useHomeTabs(uris: string[]): string[] {
+  const store = useStores()
+  const [tabs, setTabs] = useState<string[]>(['Following'])
+  const [lastUris, setLastUris] = useState<string[]>([])
+
+  useEffect(() => {
+    if (isEqual(uris, lastUris)) {
+      // no changes
+      return
+    }
+
+    async function fetchFeedInfo() {
+      const models = uris
+        .slice(0, 25)
+        .map(uri => new FeedSourceModel(store, uri))
+      await Promise.all(models.map(m => m.setup()))
+      setTabs(['Following'].concat(models.map(f => f.displayName)))
+      setLastUris(uris)
+    }
+    fetchFeedInfo()
+  }, [store, uris, lastUris, setLastUris, setTabs])
+
+  return tabs
+}
diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx
index fef7be2f3..7ae88806f 100644
--- a/src/lib/icons.tsx
+++ b/src/lib/icons.tsx
@@ -947,3 +947,30 @@ export function ShieldExclamation({
     </Svg>
   )
 }
+
+export function ListIcon({
+  style,
+  size,
+  strokeWidth = 1.5,
+}: {
+  style?: StyleProp<TextStyle>
+  size?: string | number
+  strokeWidth?: number
+}) {
+  return (
+    <Svg
+      fill="none"
+      viewBox="0 0 24 24"
+      strokeWidth={strokeWidth || 1.5}
+      stroke="currentColor"
+      width={size}
+      height={size}
+      style={style}>
+      <Path
+        strokeLinecap="round"
+        strokeLinejoin="round"
+        d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm-.375 5.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
+      />
+    </Svg>
+  )
+}
diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts
index aadee0e74..6c08606ee 100644
--- a/src/lib/moderation.ts
+++ b/src/lib/moderation.ts
@@ -17,9 +17,18 @@ export function describeModerationCause(
     }
   }
   if (cause.type === 'blocking') {
-    return {
-      name: 'User Blocked',
-      description: 'You have blocked this user. You cannot view their content.',
+    if (cause.source.type === 'list') {
+      return {
+        name: `User Blocked by "${cause.source.list.name}"`,
+        description:
+          'You have blocked this user. You cannot view their content.',
+      }
+    } else {
+      return {
+        name: 'User Blocked',
+        description:
+          'You have blocked this user. You cannot view their content.',
+      }
     }
   }
   if (cause.type === 'blocked-by') {
diff --git a/src/lib/routes/links.ts b/src/lib/routes/links.ts
index cc543b6b7..397a5916c 100644
--- a/src/lib/routes/links.ts
+++ b/src/lib/routes/links.ts
@@ -13,3 +13,15 @@ export function makeProfileLink(
     ...segments,
   ].join('/')
 }
+
+export function makeCustomFeedLink(
+  did: string,
+  rkey: string,
+  ...segments: string[]
+) {
+  return [`/profile`, did, 'feed', rkey, ...segments].join('/')
+}
+
+export function makeListLink(did: string, rkey: string, ...segments: string[]) {
+  return [`/profile`, did, 'lists', rkey, ...segments].join('/')
+}
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 35a379d48..c157c0ab3 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -5,8 +5,9 @@ export type {NativeStackScreenProps} from '@react-navigation/native-stack'
 
 export type CommonNavigatorParams = {
   NotFound: undefined
+  Lists: undefined
   Moderation: undefined
-  ModerationMuteLists: undefined
+  ModerationModlists: undefined
   ModerationMutedAccounts: undefined
   ModerationBlockedAccounts: undefined
   Settings: undefined
@@ -18,8 +19,8 @@ export type CommonNavigatorParams = {
   PostThread: {name: string; rkey: string}
   PostLikedBy: {name: string; rkey: string}
   PostRepostedBy: {name: string; rkey: string}
-  CustomFeed: {name: string; rkey: string}
-  CustomFeedLikedBy: {name: string; rkey: string}
+  ProfileFeed: {name: string; rkey: string}
+  ProfileFeedLikedBy: {name: string; rkey: string}
   Debug: undefined
   Log: undefined
   Support: undefined