diff options
Diffstat (limited to 'src/view/com/lists/ListItems.tsx')
-rw-r--r-- | src/view/com/lists/ListItems.tsx | 387 |
1 files changed, 387 insertions, 0 deletions
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}, +}) |