about summary refs log tree commit diff
path: root/src/view/com/lists/ListMembers.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/lists/ListMembers.tsx')
-rw-r--r--src/view/com/lists/ListMembers.tsx246
1 files changed, 246 insertions, 0 deletions
diff --git a/src/view/com/lists/ListMembers.tsx b/src/view/com/lists/ListMembers.tsx
new file mode 100644
index 000000000..4a25c53e6
--- /dev/null
+++ b/src/view/com/lists/ListMembers.tsx
@@ -0,0 +1,246 @@
+import React, {MutableRefObject} from 'react'
+import {
+  ActivityIndicator,
+  Dimensions,
+  RefreshControl,
+  StyleProp,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
+import {FlatList} from '../util/Views'
+import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
+import {ErrorMessage} from '../util/error/ErrorMessage'
+import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
+import {ProfileCard} from '../profile/ProfileCard'
+import {Button} from '../util/forms/Button'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {useListMembersQuery} from '#/state/queries/list-members'
+import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
+import {logger} from '#/logger'
+import {useModalControls} from '#/state/modals'
+import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
+import {useSession} from '#/state/session'
+import {cleanError} from '#/lib/strings/errors'
+
+const LOADING_ITEM = {_reactKey: '__loading__'}
+const EMPTY_ITEM = {_reactKey: '__empty__'}
+const ERROR_ITEM = {_reactKey: '__error__'}
+const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
+
+export function ListMembers({
+  list,
+  style,
+  scrollElRef,
+  onScroll,
+  onPressTryAgain,
+  renderHeader,
+  renderEmptyState,
+  testID,
+  scrollEventThrottle,
+  headerOffset = 0,
+  desktopFixedHeightOffset,
+}: {
+  list: string
+  style?: StyleProp<ViewStyle>
+  scrollElRef?: MutableRefObject<FlatList<any> | null>
+  onScroll: OnScrollHandler
+  onPressTryAgain?: () => void
+  renderHeader: () => JSX.Element
+  renderEmptyState: () => JSX.Element
+  testID?: string
+  scrollEventThrottle?: number
+  headerOffset?: number
+  desktopFixedHeightOffset?: number
+}) {
+  const pal = usePalette('default')
+  const {track} = useAnalytics()
+  const [isRefreshing, setIsRefreshing] = React.useState(false)
+  const {isMobile} = useWebMediaQueries()
+  const {openModal} = useModalControls()
+  const {currentAccount} = useSession()
+
+  const {
+    data,
+    isFetching,
+    isFetched,
+    isError,
+    error,
+    refetch,
+    fetchNextPage,
+    hasNextPage,
+  } = useListMembersQuery(list)
+  const isEmpty = !isFetching && !data?.pages[0].items.length
+  const isOwner =
+    currentAccount && data?.pages[0].list.creator.did === currentAccount.did
+
+  const items = React.useMemo(() => {
+    let items: any[] = []
+    if (isFetched) {
+      if (isEmpty && isError) {
+        items = items.concat([ERROR_ITEM])
+      }
+      if (isEmpty) {
+        items = items.concat([EMPTY_ITEM])
+      } else if (data) {
+        for (const page of data.pages) {
+          items = items.concat(page.items)
+        }
+      }
+      if (!isEmpty && isError) {
+        items = items.concat([LOAD_MORE_ERROR_ITEM])
+      }
+    } else if (isFetching) {
+      items = items.concat([LOADING_ITEM])
+    }
+    return items
+  }, [isFetched, isEmpty, isError, data, isFetching])
+
+  // events
+  // =
+
+  const onRefresh = React.useCallback(async () => {
+    track('Lists:onRefresh')
+    setIsRefreshing(true)
+    try {
+      await refetch()
+    } catch (err) {
+      logger.error('Failed to refresh lists', {error: err})
+    }
+    setIsRefreshing(false)
+  }, [refetch, track, setIsRefreshing])
+
+  const onEndReached = React.useCallback(async () => {
+    if (isFetching || !hasNextPage || isError) return
+    track('Lists:onEndReached')
+    try {
+      await fetchNextPage()
+    } catch (err) {
+      logger.error('Failed to load more lists', {error: err})
+    }
+  }, [isFetching, hasNextPage, isError, fetchNextPage, track])
+
+  const onPressRetryLoadMore = React.useCallback(() => {
+    fetchNextPage()
+  }, [fetchNextPage])
+
+  const onPressEditMembership = React.useCallback(
+    (profile: AppBskyActorDefs.ProfileViewBasic) => {
+      openModal({
+        name: 'user-add-remove-lists',
+        subject: profile.did,
+        displayName: profile.displayName || profile.handle,
+      })
+    },
+    [openModal],
+  )
+
+  // rendering
+  // =
+
+  const renderMemberButton = React.useCallback(
+    (profile: AppBskyActorDefs.ProfileViewBasic) => {
+      if (!isOwner) {
+        return null
+      }
+      return (
+        <Button
+          testID={`user-${profile.handle}-editBtn`}
+          type="default"
+          label="Edit"
+          onPress={() => onPressEditMembership(profile)}
+        />
+      )
+    },
+    [isOwner, onPressEditMembership],
+  )
+
+  const renderItem = React.useCallback(
+    ({item}: {item: any}) => {
+      if (item === EMPTY_ITEM) {
+        return renderEmptyState()
+      } else if (item === ERROR_ITEM) {
+        return (
+          <ErrorMessage
+            message={cleanError(error)}
+            onPressTryAgain={onPressTryAgain}
+          />
+        )
+      } else if (item === LOAD_MORE_ERROR_ITEM) {
+        return (
+          <LoadMoreRetryBtn
+            label="There was an issue fetching the list. Tap here to try again."
+            onPress={onPressRetryLoadMore}
+          />
+        )
+      } else if (item === LOADING_ITEM) {
+        return <ProfileCardFeedLoadingPlaceholder />
+      }
+      return (
+        <ProfileCard
+          testID={`user-${
+            (item as AppBskyGraphDefs.ListItemView).subject.handle
+          }`}
+          profile={(item as AppBskyGraphDefs.ListItemView).subject}
+          renderButton={renderMemberButton}
+          style={{paddingHorizontal: isMobile ? 8 : 14, paddingVertical: 4}}
+        />
+      )
+    },
+    [
+      renderMemberButton,
+      renderEmptyState,
+      error,
+      onPressTryAgain,
+      onPressRetryLoadMore,
+      isMobile,
+    ],
+  )
+
+  const Footer = React.useCallback(
+    () => (
+      <View style={{paddingTop: 20, paddingBottom: 200}}>
+        {isFetching && <ActivityIndicator />}
+      </View>
+    ),
+    [isFetching],
+  )
+
+  const scrollHandler = useAnimatedScrollHandler(onScroll)
+  return (
+    <View testID={testID} style={style}>
+      <FlatList
+        testID={testID ? `${testID}-flatlist` : undefined}
+        ref={scrollElRef}
+        data={items}
+        keyExtractor={(item: any) => item.uri || item._reactKey}
+        renderItem={renderItem}
+        ListHeaderComponent={renderHeader}
+        ListFooterComponent={Footer}
+        refreshControl={
+          <RefreshControl
+            refreshing={isRefreshing}
+            onRefresh={onRefresh}
+            tintColor={pal.colors.text}
+            titleColor={pal.colors.text}
+            progressViewOffset={headerOffset}
+          />
+        }
+        contentContainerStyle={{
+          minHeight: Dimensions.get('window').height * 1.5,
+        }}
+        style={{paddingTop: headerOffset}}
+        onScroll={scrollHandler}
+        onEndReached={onEndReached}
+        onEndReachedThreshold={0.6}
+        scrollEventThrottle={scrollEventThrottle}
+        removeClippedSubviews={true}
+        contentOffset={{x: 0, y: headerOffset * -1}}
+        // @ts-ignore our .web version only -prf
+        desktopFixedHeight={desktopFixedHeightOffset || true}
+      />
+    </View>
+  )
+}