about summary refs log tree commit diff
path: root/src/view/screens/ProfileList.tsx
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/view/screens/ProfileList.tsx
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/view/screens/ProfileList.tsx')
-rw-r--r--src/view/screens/ProfileList.tsx850
1 files changed, 743 insertions, 107 deletions
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index 11a847db3..859f50bef 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -1,166 +1,802 @@
-import React from 'react'
-import {StyleSheet} from 'react-native'
+import React, {useCallback, useMemo} from 'react'
+import {
+  ActivityIndicator,
+  FlatList,
+  Pressable,
+  StyleSheet,
+  View,
+} from 'react-native'
 import {useFocusEffect} from '@react-navigation/native'
 import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
 import {useNavigation} from '@react-navigation/native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {observer} from 'mobx-react-lite'
+import {RichText as RichTextAPI} from '@atproto/api'
 import {withAuthRequired} from 'view/com/auth/withAuthRequired'
-import {ViewHeader} from 'view/com/util/ViewHeader'
+import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
+import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader'
+import {Feed} from 'view/com/posts/Feed'
+import {Text} from 'view/com/util/text/Text'
+import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown'
 import {CenteredView} from 'view/com/util/Views'
-import {ListItems} from 'view/com/lists/ListItems'
 import {EmptyState} from 'view/com/util/EmptyState'
+import {RichText} from 'view/com/util/text/RichText'
+import {Button} from 'view/com/util/forms/Button'
+import {TextLink} from 'view/com/util/Link'
 import * as Toast from 'view/com/util/Toast'
+import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
+import {FAB} from 'view/com/util/fab/FAB'
+import {Haptics} from 'lib/haptics'
 import {ListModel} from 'state/models/content/list'
+import {PostsFeedModel} from 'state/models/feeds/posts'
 import {useStores} from 'state/index'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useSetTitle} from 'lib/hooks/useSetTitle'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {OnScrollCb} from 'lib/hooks/useOnMainScroll'
 import {NavigationProp} from 'lib/routes/types'
 import {toShareUrl} from 'lib/strings/url-helpers'
 import {shareUrl} from 'lib/sharing'
-import {ListActions} from 'view/com/lists/ListActions'
+import {resolveName} from 'lib/api'
 import {s} from 'lib/styles'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {makeProfileLink, makeListLink} from 'lib/routes/links'
+import {ComposeIcon2} from 'lib/icons'
+import {ListItems} from 'view/com/lists/ListItems'
+
+const SECTION_TITLES_CURATE = ['Posts', 'About']
+const SECTION_TITLES_MOD = ['About']
+
+interface SectionRef {
+  scrollToTop: () => void
+}
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'>
 export const ProfileListScreen = withAuthRequired(
-  observer(function ProfileListScreenImpl({route}: Props) {
+  observer(function ProfileListScreenImpl(props: Props) {
+    const pal = usePalette('default')
     const store = useStores()
     const navigation = useNavigation<NavigationProp>()
-    const {isTabletOrDesktop} = useWebMediaQueries()
-    const pal = usePalette('default')
-    const {name, rkey} = route.params
 
-    const list: ListModel = React.useMemo(() => {
+    const {name: handleOrDid} = props.route.params
+
+    const [listOwnerDid, setListOwnerDid] = React.useState<string | undefined>()
+    const [error, setError] = React.useState<string | undefined>()
+
+    const onPressBack = useCallback(() => {
+      if (navigation.canGoBack()) {
+        navigation.goBack()
+      } else {
+        navigation.navigate('Home')
+      }
+    }, [navigation])
+
+    React.useEffect(() => {
+      /*
+       * We must resolve the DID of the list owner before we can fetch the list.
+       */
+      async function fetchDid() {
+        try {
+          const did = await resolveName(store, handleOrDid)
+          setListOwnerDid(did)
+        } catch (e) {
+          setError(
+            `We're sorry, but we were unable to resolve this list. If this persists, please contact the list creator, @${handleOrDid}.`,
+          )
+        }
+      }
+
+      fetchDid()
+    }, [store, handleOrDid, setListOwnerDid])
+
+    if (error) {
+      return (
+        <CenteredView>
+          <View
+            style={[
+              pal.view,
+              pal.border,
+              {
+                margin: 10,
+                paddingHorizontal: 18,
+                paddingVertical: 14,
+                borderRadius: 6,
+              },
+            ]}>
+            <Text type="title-lg" style={[pal.text, s.mb10]}>
+              Could not load list
+            </Text>
+            <Text type="md" style={[pal.text, s.mb20]}>
+              {error}
+            </Text>
+
+            <View style={{flexDirection: 'row'}}>
+              <Button
+                type="default"
+                accessibilityLabel="Go Back"
+                accessibilityHint="Return to previous page"
+                onPress={onPressBack}
+                style={{flexShrink: 1}}>
+                <Text type="button" style={pal.text}>
+                  Go Back
+                </Text>
+              </Button>
+            </View>
+          </View>
+        </CenteredView>
+      )
+    }
+
+    return listOwnerDid ? (
+      <ProfileListScreenInner {...props} listOwnerDid={listOwnerDid} />
+    ) : (
+      <CenteredView>
+        <View style={s.p20}>
+          <ActivityIndicator size="large" />
+        </View>
+      </CenteredView>
+    )
+  }),
+)
+
+export const ProfileListScreenInner = observer(
+  function ProfileListScreenInnerImpl({
+    route,
+    listOwnerDid,
+  }: Props & {listOwnerDid: string}) {
+    const store = useStores()
+    const {rkey} = route.params
+    const feedSectionRef = React.useRef<SectionRef>(null)
+    const aboutSectionRef = React.useRef<SectionRef>(null)
+
+    const list: ListModel = useMemo(() => {
       const model = new ListModel(
         store,
-        `at://${name}/app.bsky.graph.list/${rkey}`,
+        `at://${listOwnerDid}/app.bsky.graph.list/${rkey}`,
       )
       return model
-    }, [store, name, rkey])
-    useSetTitle(list.list?.name)
+    }, [store, listOwnerDid, rkey])
+    const feed = useMemo(
+      () => new PostsFeedModel(store, 'list', {list: list.uri}),
+      [store, list],
+    )
+    useSetTitle(list.data?.name)
 
     useFocusEffect(
-      React.useCallback(() => {
+      useCallback(() => {
         store.shell.setMinimalShellMode(false)
-        list.loadMore(true)
-      }, [store, list]),
+        list.loadMore(true).then(() => {
+          if (list.isCuratelist) {
+            feed.setup()
+          }
+        })
+      }, [store, list, feed]),
     )
 
-    const onToggleSubscribed = React.useCallback(async () => {
-      try {
-        if (list.list?.viewer?.muted) {
-          await list.unsubscribe()
-        } else {
-          await list.subscribe()
-        }
-      } catch (err) {
-        Toast.show(
-          'There was an an issue updating your subscription, please check your internet connection and try again.',
-        )
-        store.log.error('Failed up update subscription', {err})
-      }
-    }, [store, list])
-
-    const onPressEditList = React.useCallback(() => {
+    const onPressAddUser = useCallback(() => {
       store.shell.openModal({
-        name: 'create-or-edit-mute-list',
+        name: 'list-add-user',
         list,
-        onSave() {
-          list.refresh()
+        onAdd() {
+          if (list.isCuratelist) {
+            feed.refresh()
+          }
         },
       })
-    }, [store, list])
+    }, [store, list, feed])
 
-    const onPressDeleteList = React.useCallback(() => {
-      store.shell.openModal({
-        name: 'confirm',
-        title: 'Delete List',
-        message: 'Are you sure?',
-        async onPressConfirm() {
-          await list.delete()
-          if (navigation.canGoBack()) {
-            navigation.goBack()
-          } else {
-            navigation.navigate('Home')
-          }
+    const onCurrentPageSelected = React.useCallback(
+      (index: number) => {
+        if (index === 0) {
+          feedSectionRef.current?.scrollToTop()
+        }
+        if (index === 1) {
+          aboutSectionRef.current?.scrollToTop()
+        }
+      },
+      [feedSectionRef],
+    )
+
+    const renderHeader = useCallback(() => {
+      return <Header rkey={rkey} list={list} />
+    }, [rkey, list])
+
+    if (list.isCuratelist) {
+      return (
+        <View style={s.hContentRegion}>
+          <PagerWithHeader
+            items={SECTION_TITLES_CURATE}
+            renderHeader={renderHeader}
+            onCurrentPageSelected={onCurrentPageSelected}>
+            {({onScroll, headerHeight, isScrolledDown}) => (
+              <FeedSection
+                key="1"
+                ref={feedSectionRef}
+                feed={feed}
+                onScroll={onScroll}
+                headerHeight={headerHeight}
+                isScrolledDown={isScrolledDown}
+              />
+            )}
+            {({onScroll, headerHeight, isScrolledDown}) => (
+              <AboutSection
+                key="2"
+                ref={aboutSectionRef}
+                list={list}
+                descriptionRT={list.descriptionRT}
+                creator={list.data ? list.data.creator : undefined}
+                isCurateList={list.isCuratelist}
+                isOwner={list.isOwner}
+                onPressAddUser={onPressAddUser}
+                onScroll={onScroll}
+                headerHeight={headerHeight}
+                isScrolledDown={isScrolledDown}
+              />
+            )}
+          </PagerWithHeader>
+          <FAB
+            testID="composeFAB"
+            onPress={() => store.shell.openComposer({})}
+            icon={
+              <ComposeIcon2
+                strokeWidth={1.5}
+                size={29}
+                style={{color: 'white'}}
+              />
+            }
+            accessibilityRole="button"
+            accessibilityLabel="New post"
+            accessibilityHint=""
+          />
+        </View>
+      )
+    }
+    if (list.isModlist) {
+      return (
+        <View style={s.hContentRegion}>
+          <PagerWithHeader
+            items={SECTION_TITLES_MOD}
+            renderHeader={renderHeader}>
+            {({onScroll, headerHeight, isScrolledDown}) => (
+              <AboutSection
+                key="2"
+                list={list}
+                descriptionRT={list.descriptionRT}
+                creator={list.data ? list.data.creator : undefined}
+                isCurateList={list.isCuratelist}
+                isOwner={list.isOwner}
+                onPressAddUser={onPressAddUser}
+                onScroll={onScroll}
+                headerHeight={headerHeight}
+                isScrolledDown={isScrolledDown}
+              />
+            )}
+          </PagerWithHeader>
+          <FAB
+            testID="composeFAB"
+            onPress={() => store.shell.openComposer({})}
+            icon={
+              <ComposeIcon2
+                strokeWidth={1.5}
+                size={29}
+                style={{color: 'white'}}
+              />
+            }
+            accessibilityRole="button"
+            accessibilityLabel="New post"
+            accessibilityHint=""
+          />
+        </View>
+      )
+    }
+    return <Header rkey={rkey} list={list} />
+  },
+)
+
+const Header = observer(function HeaderImpl({
+  rkey,
+  list,
+}: {
+  rkey: string
+  list: ListModel
+}) {
+  const pal = usePalette('default')
+  const palInverted = usePalette('inverted')
+  const store = useStores()
+  const navigation = useNavigation<NavigationProp>()
+
+  const onTogglePinned = useCallback(async () => {
+    Haptics.default()
+    list.togglePin().catch(e => {
+      Toast.show('There was an issue contacting the server')
+      store.log.error('Failed to toggle pinned list', {e})
+    })
+  }, [store, list])
+
+  const onSubscribeMute = useCallback(() => {
+    store.shell.openModal({
+      name: 'confirm',
+      title: 'Mute these accounts?',
+      message:
+        'Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.',
+      confirmBtnText: 'Mute this List',
+      async onPressConfirm() {
+        try {
+          await list.mute()
+          Toast.show('List muted')
+        } catch {
+          Toast.show(
+            'There was an issue. Please check your internet connection and try again.',
+          )
+        }
+      },
+      onPressCancel() {
+        store.shell.closeModal()
+      },
+    })
+  }, [store, list])
+
+  const onUnsubscribeMute = useCallback(async () => {
+    try {
+      await list.unmute()
+      Toast.show('List unmuted')
+    } catch {
+      Toast.show(
+        'There was an issue. Please check your internet connection and try again.',
+      )
+    }
+  }, [list])
+
+  const onSubscribeBlock = useCallback(() => {
+    store.shell.openModal({
+      name: 'confirm',
+      title: 'Block these accounts?',
+      message:
+        'Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.',
+      confirmBtnText: 'Block this List',
+      async onPressConfirm() {
+        try {
+          await list.block()
+          Toast.show('List blocked')
+        } catch {
+          Toast.show(
+            'There was an issue. Please check your internet connection and try again.',
+          )
+        }
+      },
+      onPressCancel() {
+        store.shell.closeModal()
+      },
+    })
+  }, [store, list])
+
+  const onUnsubscribeBlock = useCallback(async () => {
+    try {
+      await list.unblock()
+      Toast.show('List unblocked')
+    } catch {
+      Toast.show(
+        'There was an issue. Please check your internet connection and try again.',
+      )
+    }
+  }, [list])
+
+  const onPressEdit = useCallback(() => {
+    store.shell.openModal({
+      name: 'create-or-edit-list',
+      list,
+      onSave() {
+        list.refresh()
+      },
+    })
+  }, [store, list])
+
+  const onPressDelete = useCallback(() => {
+    store.shell.openModal({
+      name: 'confirm',
+      title: 'Delete List',
+      message: 'Are you sure?',
+      async onPressConfirm() {
+        await list.delete()
+        Toast.show('List deleted')
+        if (navigation.canGoBack()) {
+          navigation.goBack()
+        } else {
+          navigation.navigate('Home')
+        }
+      },
+    })
+  }, [store, list, navigation])
+
+  const onPressReport = useCallback(() => {
+    if (!list.data) return
+    store.shell.openModal({
+      name: 'report',
+      uri: list.uri,
+      cid: list.data.cid,
+    })
+  }, [store, list])
+
+  const onPressShare = useCallback(() => {
+    const url = toShareUrl(`/profile/${list.creatorDid}/lists/${rkey}`)
+    shareUrl(url)
+  }, [list.creatorDid, rkey])
+
+  const dropdownItems: DropdownItem[] = useMemo(() => {
+    if (!list.hasLoaded) {
+      return []
+    }
+    let items: DropdownItem[] = [
+      {
+        testID: 'listHeaderDropdownShareBtn',
+        label: 'Share',
+        onPress: onPressShare,
+        icon: {
+          ios: {
+            name: 'square.and.arrow.up',
+          },
+          android: '',
+          web: 'share',
+        },
+      },
+    ]
+    if (list.isOwner) {
+      items.push({label: 'separator'})
+      items.push({
+        testID: 'listHeaderDropdownEditBtn',
+        label: 'Edit List Details',
+        onPress: onPressEdit,
+        icon: {
+          ios: {
+            name: 'pencil',
+          },
+          android: '',
+          web: 'pen',
         },
       })
-    }, [store, list, navigation])
-
-    const onPressReportList = React.useCallback(() => {
-      if (!list.list) return
-      store.shell.openModal({
-        name: 'report',
-        uri: list.uri,
-        cid: list.list.cid,
+      items.push({
+        testID: 'listHeaderDropdownDeleteBtn',
+        label: 'Delete List',
+        onPress: onPressDelete,
+        icon: {
+          ios: {
+            name: 'trash',
+          },
+          android: '',
+          web: ['far', 'trash-can'],
+        },
       })
-    }, [store, list])
+    } else {
+      items.push({label: 'separator'})
+      items.push({
+        testID: 'listHeaderDropdownReportBtn',
+        label: 'Report List',
+        onPress: onPressReport,
+        icon: {
+          ios: {
+            name: 'exclamationmark.triangle',
+          },
+          android: '',
+          web: 'circle-exclamation',
+        },
+      })
+    }
+    return items
+  }, [
+    list.hasLoaded,
+    list.isOwner,
+    onPressShare,
+    onPressEdit,
+    onPressDelete,
+    onPressReport,
+  ])
+
+  const subscribeDropdownItems: DropdownItem[] = useMemo(() => {
+    return [
+      {
+        testID: 'subscribeDropdownMuteBtn',
+        label: 'Mute accounts',
+        onPress: onSubscribeMute,
+        icon: {
+          ios: {
+            name: 'speaker.slash',
+          },
+          android: '',
+          web: 'user-slash',
+        },
+      },
+      {
+        testID: 'subscribeDropdownBlockBtn',
+        label: 'Block accounts',
+        onPress: onSubscribeBlock,
+        icon: {
+          ios: {
+            name: 'person.fill.xmark',
+          },
+          android: '',
+          web: 'ban',
+        },
+      },
+    ]
+  }, [onSubscribeMute, onSubscribeBlock])
+
+  return (
+    <ProfileSubpageHeader
+      isLoading={!list.hasLoaded}
+      href={makeListLink(
+        list.data?.creator.handle || list.data?.creator.did || '',
+        rkey,
+      )}
+      title={list.data?.name || 'User list'}
+      avatar={list.data?.avatar}
+      isOwner={list.isOwner}
+      creator={list.data?.creator}
+      avatarType="list">
+      {list.isCuratelist ? (
+        <Button
+          testID={list.isPinned ? 'unpinBtn' : 'pinBtn'}
+          type={list.isPinned ? 'default' : 'inverted'}
+          label={list.isPinned ? 'Unpin' : 'Pin to home'}
+          onPress={onTogglePinned}
+        />
+      ) : list.isModlist ? (
+        list.isBlocking ? (
+          <Button
+            testID="unblockBtn"
+            type="default"
+            label="Unblock"
+            onPress={onUnsubscribeBlock}
+          />
+        ) : list.isMuting ? (
+          <Button
+            testID="unmuteBtn"
+            type="default"
+            label="Unmute"
+            onPress={onUnsubscribeMute}
+          />
+        ) : (
+          <NativeDropdown
+            testID="subscribeBtn"
+            items={subscribeDropdownItems}
+            accessibilityLabel="Subscribe to this list"
+            accessibilityHint="">
+            <View style={[palInverted.view, styles.btn]}>
+              <Text style={palInverted.text}>Subscribe</Text>
+            </View>
+          </NativeDropdown>
+        )
+      ) : null}
+      <NativeDropdown
+        testID="headerDropdownBtn"
+        items={dropdownItems}
+        accessibilityLabel="More options"
+        accessibilityHint="">
+        <View style={[pal.viewLight, styles.btn]}>
+          <FontAwesomeIcon icon="ellipsis" size={20} color={pal.colors.text} />
+        </View>
+      </NativeDropdown>
+    </ProfileSubpageHeader>
+  )
+})
+
+interface FeedSectionProps {
+  feed: PostsFeedModel
+  onScroll: OnScrollCb
+  headerHeight: number
+  isScrolledDown: boolean
+}
+const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
+  function FeedSectionImpl(
+    {feed, onScroll, headerHeight, isScrolledDown},
+    ref,
+  ) {
+    const hasNew = feed.hasNewLatest && !feed.isRefreshing
+    const scrollElRef = React.useRef<FlatList>(null)
 
-    const onPressShareList = React.useCallback(() => {
-      const url = toShareUrl(`/profile/${list.creatorDid}/lists/${rkey}`)
-      shareUrl(url)
-    }, [list.creatorDid, rkey])
+    const onScrollToTop = useCallback(() => {
+      scrollElRef.current?.scrollToOffset({offset: -headerHeight})
+    }, [scrollElRef, headerHeight])
 
-    const renderEmptyState = React.useCallback(() => {
-      return <EmptyState icon="users-slash" message="This list is empty!" />
+    const onPressLoadLatest = React.useCallback(() => {
+      onScrollToTop()
+      feed.refresh()
+    }, [feed, onScrollToTop])
+
+    React.useImperativeHandle(ref, () => ({
+      scrollToTop: onScrollToTop,
+    }))
+
+    const renderPostsEmpty = useCallback(() => {
+      return <EmptyState icon="feed" message="This feed is empty!" />
     }, [])
 
-    const renderHeaderBtns = React.useCallback(() => {
-      return (
-        <ListActions
-          muted={list.list?.viewer?.muted}
-          isOwner={list.isOwner}
-          onPressDeleteList={onPressDeleteList}
-          onPressEditList={onPressEditList}
-          onToggleSubscribed={onToggleSubscribed}
-          onPressShareList={onPressShareList}
-          onPressReportList={onPressReportList}
-          reversed={true}
+    return (
+      <View>
+        <Feed
+          testID="listFeed"
+          feed={feed}
+          scrollElRef={scrollElRef}
+          onScroll={onScroll}
+          scrollEventThrottle={1}
+          renderEmptyState={renderPostsEmpty}
+          headerOffset={headerHeight}
         />
+        {(isScrolledDown || hasNew) && (
+          <LoadLatestBtn
+            onPress={onPressLoadLatest}
+            label="Load new posts"
+            showIndicator={hasNew}
+          />
+        )}
+      </View>
+    )
+  },
+)
+
+interface AboutSectionProps {
+  list: ListModel
+  descriptionRT: RichTextAPI | null
+  creator: {did: string; handle: string} | undefined
+  isCurateList: boolean | undefined
+  isOwner: boolean | undefined
+  onPressAddUser: () => void
+  onScroll: OnScrollCb
+  headerHeight: number
+  isScrolledDown: boolean
+}
+const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
+  function AboutSectionImpl(
+    {
+      list,
+      descriptionRT,
+      creator,
+      isCurateList,
+      isOwner,
+      onPressAddUser,
+      onScroll,
+      headerHeight,
+      isScrolledDown,
+    },
+    ref,
+  ) {
+    const pal = usePalette('default')
+    const {isMobile} = useWebMediaQueries()
+    const scrollElRef = React.useRef<FlatList>(null)
+
+    const onScrollToTop = useCallback(() => {
+      scrollElRef.current?.scrollToOffset({offset: -headerHeight})
+    }, [scrollElRef, headerHeight])
+
+    React.useImperativeHandle(ref, () => ({
+      scrollToTop: onScrollToTop,
+    }))
+
+    const renderHeader = React.useCallback(() => {
+      if (!list.data) {
+        return <View />
+      }
+      return (
+        <View>
+          <View
+            style={[
+              {
+                borderTopWidth: 1,
+                padding: isMobile ? 14 : 20,
+                gap: 12,
+              },
+              pal.border,
+            ]}>
+            {descriptionRT ? (
+              <RichText
+                testID="listDescription"
+                type="lg"
+                style={pal.text}
+                richText={descriptionRT}
+              />
+            ) : (
+              <Text
+                testID="listDescriptionEmpty"
+                type="lg"
+                style={[{fontStyle: 'italic'}, pal.textLight]}>
+                No description
+              </Text>
+            )}
+            <Text type="md" style={[pal.textLight]} numberOfLines={1}>
+              {isCurateList ? 'User list' : 'Moderation list'} by{' '}
+              {isOwner ? (
+                'you'
+              ) : (
+                <TextLink
+                  text={sanitizeHandle(creator?.handle || '', '@')}
+                  href={creator ? makeProfileLink(creator) : ''}
+                  style={pal.textLight}
+                />
+              )}
+            </Text>
+          </View>
+          <View
+            style={[
+              {
+                flexDirection: 'row',
+                alignItems: 'center',
+                justifyContent: 'space-between',
+                paddingHorizontal: isMobile ? 14 : 20,
+                paddingBottom: isMobile ? 14 : 18,
+              },
+            ]}>
+            <Text type="lg-bold">Users</Text>
+            {isOwner && (
+              <Pressable
+                testID="addUserBtn"
+                accessibilityRole="button"
+                accessibilityLabel="Add a user to this list"
+                accessibilityHint=""
+                onPress={onPressAddUser}
+                style={{flexDirection: 'row', alignItems: 'center', gap: 6}}>
+                <FontAwesomeIcon
+                  icon="user-plus"
+                  color={pal.colors.link}
+                  size={16}
+                />
+                <Text style={pal.link}>Add</Text>
+              </Pressable>
+            )}
+          </View>
+        </View>
       )
     }, [
-      list.isOwner,
-      list.list?.viewer?.muted,
-      onPressDeleteList,
-      onPressEditList,
-      onPressShareList,
-      onToggleSubscribed,
-      onPressReportList,
+      pal,
+      list.data,
+      isMobile,
+      descriptionRT,
+      creator,
+      isCurateList,
+      isOwner,
+      onPressAddUser,
     ])
 
+    const renderEmptyState = useCallback(() => {
+      return (
+        <EmptyState
+          icon="users-slash"
+          message="This list is empty!"
+          style={{paddingTop: 40}}
+        />
+      )
+    }, [])
+
     return (
-      <CenteredView
-        style={[
-          styles.container,
-          isTabletOrDesktop && styles.containerDesktop,
-          pal.view,
-          pal.border,
-        ]}
-        testID="moderationMutelistsScreen">
-        <ViewHeader title="" renderButton={renderHeaderBtns} />
+      <View>
         <ListItems
-          list={list}
+          testID="listItems"
+          scrollElRef={scrollElRef}
+          renderHeader={renderHeader}
           renderEmptyState={renderEmptyState}
-          onToggleSubscribed={onToggleSubscribed}
-          onPressEditList={onPressEditList}
-          onPressDeleteList={onPressDeleteList}
-          onPressReportList={onPressReportList}
-          onPressShareList={onPressShareList}
-          style={[s.flex1]}
+          list={list}
+          headerOffset={headerHeight}
+          onScroll={onScroll}
+          scrollEventThrottle={1}
         />
-      </CenteredView>
+        {isScrolledDown && (
+          <LoadLatestBtn
+            onPress={onScrollToTop}
+            label="Scroll to top"
+            showIndicator={false}
+          />
+        )}
+      </View>
     )
-  }),
+  },
 )
 
 const styles = StyleSheet.create({
-  container: {
-    flex: 1,
-    paddingBottom: 100,
-  },
-  containerDesktop: {
-    borderLeftWidth: 1,
-    borderRightWidth: 1,
-    paddingBottom: 0,
+  btn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 6,
+    paddingVertical: 7,
+    paddingHorizontal: 14,
+    borderRadius: 50,
+    marginLeft: 6,
   },
 })