about summary refs log tree commit diff
path: root/src/view/com/lists
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/lists')
-rw-r--r--src/view/com/lists/ListCard.tsx155
-rw-r--r--src/view/com/lists/ListItems.tsx387
-rw-r--r--src/view/com/lists/ListsList.tsx240
3 files changed, 782 insertions, 0 deletions
diff --git a/src/view/com/lists/ListCard.tsx b/src/view/com/lists/ListCard.tsx
new file mode 100644
index 000000000..7cbdaaf64
--- /dev/null
+++ b/src/view/com/lists/ListCard.tsx
@@ -0,0 +1,155 @@
+import React from 'react'
+import {StyleSheet, View} from 'react-native'
+import {AtUri, AppBskyGraphDefs, RichText} from '@atproto/api'
+import {Link} from '../util/Link'
+import {Text} from '../util/text/Text'
+import {RichText as RichTextCom} from '../util/text/RichText'
+import {UserAvatar} from '../util/UserAvatar'
+import {s} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useStores} from 'state/index'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+
+export const ListCard = ({
+  testID,
+  list,
+  noBg,
+  noBorder,
+  renderButton,
+}: {
+  testID?: string
+  list: AppBskyGraphDefs.ListView
+  noBg?: boolean
+  noBorder?: boolean
+  renderButton?: () => JSX.Element
+}) => {
+  const pal = usePalette('default')
+  const store = useStores()
+
+  const rkey = React.useMemo(() => {
+    try {
+      const urip = new AtUri(list.uri)
+      return urip.rkey
+    } catch {
+      return ''
+    }
+  }, [list])
+
+  const descriptionRichText = React.useMemo(() => {
+    if (list.description) {
+      return new RichText({
+        text: list.description,
+        facets: list.descriptionFacets,
+      })
+    }
+    return undefined
+  }, [list])
+
+  return (
+    <Link
+      testID={testID}
+      style={[
+        styles.outer,
+        pal.border,
+        noBorder && styles.outerNoBorder,
+        !noBg && pal.view,
+      ]}
+      href={`/profile/${list.creator.did}/lists/${rkey}`}
+      title={list.name}
+      asAnchor
+      anchorNoUnderline>
+      <View style={styles.layout}>
+        <View style={styles.layoutAvi}>
+          <UserAvatar size={40} avatar={list.avatar} />
+        </View>
+        <View style={styles.layoutContent}>
+          <Text
+            type="lg"
+            style={[s.bold, pal.text]}
+            numberOfLines={1}
+            lineHeight={1.2}>
+            {sanitizeDisplayName(list.name)}
+          </Text>
+          <Text type="md" style={[pal.textLight]} numberOfLines={1}>
+            {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'} by{' '}
+            {list.creator.did === store.me.did
+              ? 'you'
+              : `@${list.creator.handle}`}
+          </Text>
+          {!!list.viewer?.muted && (
+            <View style={s.flexRow}>
+              <View style={[s.mt5, pal.btn, styles.pill]}>
+                <Text type="xs" style={pal.text}>
+                  Subscribed
+                </Text>
+              </View>
+            </View>
+          )}
+        </View>
+        {renderButton ? (
+          <View style={styles.layoutButton}>{renderButton()}</View>
+        ) : undefined}
+      </View>
+      {descriptionRichText ? (
+        <View style={styles.details}>
+          <RichTextCom
+            style={pal.text}
+            numberOfLines={20}
+            richText={descriptionRichText}
+          />
+        </View>
+      ) : undefined}
+    </Link>
+  )
+}
+
+const styles = StyleSheet.create({
+  outer: {
+    borderTopWidth: 1,
+    paddingHorizontal: 6,
+  },
+  outerNoBorder: {
+    borderTopWidth: 0,
+  },
+  layout: {
+    flexDirection: 'row',
+    alignItems: 'center',
+  },
+  layoutAvi: {
+    width: 54,
+    paddingLeft: 4,
+    paddingTop: 8,
+    paddingBottom: 10,
+  },
+  avi: {
+    width: 40,
+    height: 40,
+    borderRadius: 20,
+    resizeMode: 'cover',
+  },
+  layoutContent: {
+    flex: 1,
+    paddingRight: 10,
+    paddingTop: 10,
+    paddingBottom: 10,
+  },
+  layoutButton: {
+    paddingRight: 10,
+  },
+  details: {
+    paddingLeft: 54,
+    paddingRight: 10,
+    paddingBottom: 10,
+  },
+  pill: {
+    borderRadius: 4,
+    paddingHorizontal: 6,
+    paddingVertical: 2,
+  },
+  btn: {
+    paddingVertical: 7,
+    borderRadius: 50,
+    marginLeft: 6,
+    paddingHorizontal: 14,
+  },
+})
diff --git a/src/view/com/lists/ListItems.tsx b/src/view/com/lists/ListItems.tsx
new file mode 100644
index 000000000..52b728cb9
--- /dev/null
+++ b/src/view/com/lists/ListItems.tsx
@@ -0,0 +1,387 @@
+import React, {MutableRefObject} from 'react'
+import {
+  ActivityIndicator,
+  RefreshControl,
+  StyleProp,
+  StyleSheet,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {AppBskyActorDefs, AppBskyGraphDefs, RichText} from '@atproto/api'
+import {observer} from 'mobx-react-lite'
+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 {Text} from '../util/text/Text'
+import {RichText as RichTextCom} from '../util/text/RichText'
+import {UserAvatar} from '../util/UserAvatar'
+import {TextLink} from '../util/Link'
+import {ListModel} from 'state/models/content/list'
+import {useAnalytics} from 'lib/analytics'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useStores} from 'state/index'
+import {s} from 'lib/styles'
+import {isDesktopWeb} from 'platform/detection'
+
+const LOADING_ITEM = {_reactKey: '__loading__'}
+const HEADER_ITEM = {_reactKey: '__header__'}
+const EMPTY_ITEM = {_reactKey: '__empty__'}
+const ERROR_ITEM = {_reactKey: '__error__'}
+const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
+
+export const ListItems = observer(
+  ({
+    list,
+    style,
+    scrollElRef,
+    onPressTryAgain,
+    onToggleSubscribed,
+    onPressEditList,
+    onPressDeleteList,
+    renderEmptyState,
+    testID,
+    headerOffset = 0,
+  }: {
+    list: ListModel
+    style?: StyleProp<ViewStyle>
+    scrollElRef?: MutableRefObject<FlatList<any> | null>
+    onPressTryAgain?: () => void
+    onToggleSubscribed?: () => void
+    onPressEditList?: () => void
+    onPressDeleteList?: () => void
+    renderEmptyState?: () => JSX.Element
+    testID?: string
+    headerOffset?: number
+  }) => {
+    const pal = usePalette('default')
+    const store = useStores()
+    const {track} = useAnalytics()
+    const [isRefreshing, setIsRefreshing] = React.useState(false)
+
+    const data = React.useMemo(() => {
+      let items: any[] = [HEADER_ITEM]
+      if (list.hasLoaded) {
+        if (list.hasError) {
+          items = items.concat([ERROR_ITEM])
+        }
+        if (list.isEmpty) {
+          items = items.concat([EMPTY_ITEM])
+        } else {
+          items = items.concat(list.items)
+        }
+        if (list.loadMoreError) {
+          items = items.concat([LOAD_MORE_ERROR_ITEM])
+        }
+      } else if (list.isLoading) {
+        items = items.concat([LOADING_ITEM])
+      }
+      return items
+    }, [
+      list.hasError,
+      list.hasLoaded,
+      list.isLoading,
+      list.isEmpty,
+      list.items,
+      list.loadMoreError,
+    ])
+
+    // events
+    // =
+
+    const onRefresh = React.useCallback(async () => {
+      track('Lists:onRefresh')
+      setIsRefreshing(true)
+      try {
+        await list.refresh()
+      } catch (err) {
+        list.rootStore.log.error('Failed to refresh lists', err)
+      }
+      setIsRefreshing(false)
+    }, [list, track, setIsRefreshing])
+
+    const onEndReached = React.useCallback(async () => {
+      track('Lists:onEndReached')
+      try {
+        await list.loadMore()
+      } catch (err) {
+        list.rootStore.log.error('Failed to load more lists', err)
+      }
+    }, [list, track])
+
+    const onPressRetryLoadMore = React.useCallback(() => {
+      list.retryLoadMore()
+    }, [list])
+
+    const onPressEditMembership = React.useCallback(
+      (profile: AppBskyActorDefs.ProfileViewBasic) => {
+        store.shell.openModal({
+          name: 'list-add-remove-user',
+          subject: profile.did,
+          displayName: profile.displayName || profile.handle,
+          onUpdate() {
+            list.refresh()
+          },
+        })
+      },
+      [store, list],
+    )
+
+    // rendering
+    // =
+
+    const renderMemberButton = React.useCallback(
+      (profile: AppBskyActorDefs.ProfileViewBasic) => {
+        if (!list.isOwner) {
+          return null
+        }
+        return (
+          <Button
+            type="default"
+            label="Edit"
+            onPress={() => onPressEditMembership(profile)}
+          />
+        )
+      },
+      [list, onPressEditMembership],
+    )
+
+    const renderItem = React.useCallback(
+      ({item}: {item: any}) => {
+        if (item === EMPTY_ITEM) {
+          if (renderEmptyState) {
+            return renderEmptyState()
+          }
+          return <View />
+        } else if (item === HEADER_ITEM) {
+          return list.list ? (
+            <ListHeader
+              list={list.list}
+              isOwner={list.isOwner}
+              onToggleSubscribed={onToggleSubscribed}
+              onPressEditList={onPressEditList}
+              onPressDeleteList={onPressDeleteList}
+            />
+          ) : null
+        } else if (item === ERROR_ITEM) {
+          return (
+            <ErrorMessage
+              message={list.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}
+          />
+        )
+      },
+      [
+        list,
+        onPressTryAgain,
+        onPressRetryLoadMore,
+        renderMemberButton,
+        onPressEditList,
+        onPressDeleteList,
+        onToggleSubscribed,
+        renderEmptyState,
+      ],
+    )
+
+    const Footer = React.useCallback(
+      () =>
+        list.isLoading ? (
+          <View style={styles.feedFooter}>
+            <ActivityIndicator />
+          </View>
+        ) : (
+          <View />
+        ),
+      [list],
+    )
+
+    return (
+      <View testID={testID} style={style}>
+        {data.length > 0 && (
+          <FlatList
+            testID={testID ? `${testID}-flatlist` : undefined}
+            ref={scrollElRef}
+            data={data}
+            keyExtractor={item => item._reactKey}
+            renderItem={renderItem}
+            ListFooterComponent={Footer}
+            refreshControl={
+              <RefreshControl
+                refreshing={isRefreshing}
+                onRefresh={onRefresh}
+                tintColor={pal.colors.text}
+                titleColor={pal.colors.text}
+                progressViewOffset={headerOffset}
+              />
+            }
+            contentContainerStyle={s.contentContainer}
+            style={{paddingTop: headerOffset}}
+            onEndReached={onEndReached}
+            onEndReachedThreshold={0.6}
+            removeClippedSubviews={true}
+            contentOffset={{x: 0, y: headerOffset * -1}}
+            // @ts-ignore our .web version only -prf
+            desktopFixedHeight
+          />
+        )}
+      </View>
+    )
+  },
+)
+
+const ListHeader = observer(
+  ({
+    list,
+    isOwner,
+    onToggleSubscribed,
+    onPressEditList,
+    onPressDeleteList,
+  }: {
+    list: AppBskyGraphDefs.ListView
+    isOwner: boolean
+    onToggleSubscribed?: () => void
+    onPressEditList?: () => void
+    onPressDeleteList?: () => void
+  }) => {
+    const pal = usePalette('default')
+    const store = useStores()
+    const descriptionRT = React.useMemo(
+      () =>
+        list?.description &&
+        new RichText({text: list.description, facets: list.descriptionFacets}),
+      [list],
+    )
+    return (
+      <>
+        <View style={[styles.header, pal.border]}>
+          <View style={s.flex1}>
+            <Text testID="listName" type="title-xl" style={[pal.text, s.bold]}>
+              {list.name}
+            </Text>
+            {list && (
+              <Text type="md" style={[pal.textLight]} numberOfLines={1}>
+                {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list '}
+                by{' '}
+                {list.creator.did === store.me.did ? (
+                  'you'
+                ) : (
+                  <TextLink
+                    text={`@${list.creator.handle}`}
+                    href={`/profile/${list.creator.did}`}
+                  />
+                )}
+              </Text>
+            )}
+            {descriptionRT && (
+              <RichTextCom
+                testID="listDescription"
+                style={[pal.text, styles.headerDescription]}
+                richText={descriptionRT}
+              />
+            )}
+            {isDesktopWeb && (
+              <View style={styles.headerBtns}>
+                {list.viewer?.muted ? (
+                  <Button
+                    type="inverted"
+                    label="Unsubscribe"
+                    accessibilityLabel="Unsubscribe from this list"
+                    accessibilityHint="Stops muting the users included in this list"
+                    onPress={onToggleSubscribed}
+                  />
+                ) : (
+                  <Button
+                    type="primary"
+                    label="Subscribe & Mute"
+                    accessibilityLabel="Subscribe to this list"
+                    accessibilityHint="Mutes the users included in this list"
+                    onPress={onToggleSubscribed}
+                  />
+                )}
+                {isOwner && (
+                  <Button
+                    type="default"
+                    label="Edit List"
+                    accessibilityLabel="Edit list"
+                    accessibilityHint="Opens a modal to edit the mutelist"
+                    onPress={onPressEditList}
+                  />
+                )}
+                {isOwner && (
+                  <Button
+                    type="default"
+                    label="Delete List"
+                    accessibilityLabel="Delete list"
+                    accessibilityHint="Deletes the mutelist"
+                    onPress={onPressDeleteList}
+                  />
+                )}
+              </View>
+            )}
+          </View>
+          <View>
+            <UserAvatar avatar={list.avatar} size={64} />
+          </View>
+        </View>
+        <View style={[styles.fakeSelector, pal.border]}>
+          <View
+            style={[styles.fakeSelectorItem, {borderColor: pal.colors.link}]}>
+            <Text type="md-medium" style={[pal.text]}>
+              Muted users
+            </Text>
+          </View>
+        </View>
+      </>
+    )
+  },
+)
+
+const styles = StyleSheet.create({
+  header: {
+    flexDirection: 'row',
+    gap: 12,
+    paddingHorizontal: 16,
+    paddingTop: 12,
+    paddingBottom: 16,
+    borderTopWidth: 1,
+  },
+  headerDescription: {
+    marginTop: 8,
+  },
+  headerBtns: {
+    flexDirection: 'row',
+    gap: 8,
+    marginTop: 12,
+  },
+  fakeSelector: {
+    flexDirection: 'row',
+    paddingHorizontal: isDesktopWeb ? 16 : 6,
+  },
+  fakeSelectorItem: {
+    paddingHorizontal: 12,
+    paddingBottom: 8,
+    borderBottomWidth: 3,
+  },
+  feedFooter: {paddingTop: 20},
+})
diff --git a/src/view/com/lists/ListsList.tsx b/src/view/com/lists/ListsList.tsx
new file mode 100644
index 000000000..88b71acc0
--- /dev/null
+++ b/src/view/com/lists/ListsList.tsx
@@ -0,0 +1,240 @@
+import React, {MutableRefObject} from 'react'
+import {
+  ActivityIndicator,
+  RefreshControl,
+  StyleProp,
+  StyleSheet,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {observer} from 'mobx-react-lite'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
+import {AppBskyGraphDefs as GraphDefs} from '@atproto/api'
+import {FlatList} from '../util/Views'
+import {ListCard} from './ListCard'
+import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder'
+import {ErrorMessage} from '../util/error/ErrorMessage'
+import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
+import {Button} from '../util/forms/Button'
+import {Text} from '../util/text/Text'
+import {ListsListModel} from 'state/models/lists/lists-list'
+import {useAnalytics} from 'lib/analytics'
+import {usePalette} from 'lib/hooks/usePalette'
+import {s} from 'lib/styles'
+
+const LOADING_ITEM = {_reactKey: '__loading__'}
+const CREATENEW_ITEM = {_reactKey: '__loading__'}
+const EMPTY_ITEM = {_reactKey: '__empty__'}
+const ERROR_ITEM = {_reactKey: '__error__'}
+const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
+
+export const ListsList = observer(
+  ({
+    listsList,
+    showAddBtns,
+    style,
+    scrollElRef,
+    onPressTryAgain,
+    onPressCreateNew,
+    renderItem,
+    renderEmptyState,
+    testID,
+    headerOffset = 0,
+  }: {
+    listsList: ListsListModel
+    showAddBtns?: boolean
+    style?: StyleProp<ViewStyle>
+    scrollElRef?: MutableRefObject<FlatList<any> | null>
+    onPressCreateNew: () => void
+    onPressTryAgain?: () => void
+    renderItem?: (list: GraphDefs.ListView) => JSX.Element
+    renderEmptyState?: () => JSX.Element
+    testID?: string
+    headerOffset?: number
+  }) => {
+    const pal = usePalette('default')
+    const {track} = useAnalytics()
+    const [isRefreshing, setIsRefreshing] = React.useState(false)
+
+    const data = React.useMemo(() => {
+      let items: any[] = []
+      if (listsList.hasLoaded) {
+        if (listsList.hasError) {
+          items = items.concat([ERROR_ITEM])
+        }
+        if (listsList.isEmpty) {
+          items = items.concat([EMPTY_ITEM])
+        } else {
+          if (showAddBtns) {
+            items = items.concat([CREATENEW_ITEM])
+          }
+          items = items.concat(listsList.lists)
+        }
+        if (listsList.loadMoreError) {
+          items = items.concat([LOAD_MORE_ERROR_ITEM])
+        }
+      } else if (listsList.isLoading) {
+        items = items.concat([LOADING_ITEM])
+      }
+      return items
+    }, [
+      listsList.hasError,
+      listsList.hasLoaded,
+      listsList.isLoading,
+      listsList.isEmpty,
+      listsList.lists,
+      listsList.loadMoreError,
+      showAddBtns,
+    ])
+
+    // events
+    // =
+
+    const onRefresh = React.useCallback(async () => {
+      track('Lists:onRefresh')
+      setIsRefreshing(true)
+      try {
+        await listsList.refresh()
+      } catch (err) {
+        listsList.rootStore.log.error('Failed to refresh lists', err)
+      }
+      setIsRefreshing(false)
+    }, [listsList, track, setIsRefreshing])
+
+    const onEndReached = React.useCallback(async () => {
+      track('Lists:onEndReached')
+      try {
+        await listsList.loadMore()
+      } catch (err) {
+        listsList.rootStore.log.error('Failed to load more lists', err)
+      }
+    }, [listsList, track])
+
+    const onPressRetryLoadMore = React.useCallback(() => {
+      listsList.retryLoadMore()
+    }, [listsList])
+
+    // rendering
+    // =
+
+    const renderItemInner = React.useCallback(
+      ({item}: {item: any}) => {
+        if (item === EMPTY_ITEM) {
+          if (renderEmptyState) {
+            return renderEmptyState()
+          }
+          return <View />
+        } else if (item === CREATENEW_ITEM) {
+          return <CreateNewItem onPress={onPressCreateNew} />
+        } else if (item === ERROR_ITEM) {
+          return (
+            <ErrorMessage
+              message={listsList.error}
+              onPressTryAgain={onPressTryAgain}
+            />
+          )
+        } else if (item === LOAD_MORE_ERROR_ITEM) {
+          return (
+            <LoadMoreRetryBtn
+              label="There was an issue fetching your lists. Tap here to try again."
+              onPress={onPressRetryLoadMore}
+            />
+          )
+        } else if (item === LOADING_ITEM) {
+          return <ProfileCardFeedLoadingPlaceholder />
+        }
+        return renderItem ? (
+          renderItem(item)
+        ) : (
+          <ListCard list={item} testID={`list-${item.name}`} />
+        )
+      },
+      [
+        listsList,
+        onPressTryAgain,
+        onPressRetryLoadMore,
+        onPressCreateNew,
+        renderItem,
+        renderEmptyState,
+      ],
+    )
+
+    const Footer = React.useCallback(
+      () =>
+        listsList.isLoading ? (
+          <View style={styles.feedFooter}>
+            <ActivityIndicator />
+          </View>
+        ) : (
+          <View />
+        ),
+      [listsList],
+    )
+
+    return (
+      <View testID={testID} style={style}>
+        {data.length > 0 && (
+          <FlatList
+            testID={testID ? `${testID}-flatlist` : undefined}
+            ref={scrollElRef}
+            data={data}
+            keyExtractor={item => item._reactKey}
+            renderItem={renderItemInner}
+            ListFooterComponent={Footer}
+            refreshControl={
+              <RefreshControl
+                refreshing={isRefreshing}
+                onRefresh={onRefresh}
+                tintColor={pal.colors.text}
+                titleColor={pal.colors.text}
+                progressViewOffset={headerOffset}
+              />
+            }
+            contentContainerStyle={s.contentContainer}
+            style={{paddingTop: headerOffset}}
+            onEndReached={onEndReached}
+            onEndReachedThreshold={0.6}
+            removeClippedSubviews={true}
+            contentOffset={{x: 0, y: headerOffset * -1}}
+            // @ts-ignore our .web version only -prf
+            desktopFixedHeight
+          />
+        )}
+      </View>
+    )
+  },
+)
+
+function CreateNewItem({onPress}: {onPress: () => void}) {
+  const pal = usePalette('default')
+
+  return (
+    <View style={[styles.createNewContainer]}>
+      <Button type="default" onPress={onPress} style={styles.createNewButton}>
+        <FontAwesomeIcon icon="plus" style={pal.text as FontAwesomeIconStyle} />
+        <Text type="button" style={pal.text}>
+          New Mute List
+        </Text>
+      </Button>
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  createNewContainer: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    paddingHorizontal: 18,
+    paddingTop: 18,
+    paddingBottom: 16,
+  },
+  createNewButton: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    gap: 8,
+  },
+  feedFooter: {paddingTop: 20},
+})