diff options
Diffstat (limited to 'src/view')
-rw-r--r-- | src/view/com/lists/ListCard.tsx | 6 | ||||
-rw-r--r-- | src/view/com/lists/ListMembers.tsx (renamed from src/view/com/lists/ListItems.tsx) | 94 | ||||
-rw-r--r-- | src/view/com/lists/ListsList.tsx | 70 | ||||
-rw-r--r-- | src/view/com/modals/CreateOrEditList.tsx | 32 | ||||
-rw-r--r-- | src/view/com/modals/ListAddRemoveUsers.tsx (renamed from src/view/com/modals/ListAddUser.tsx) | 134 | ||||
-rw-r--r-- | src/view/com/modals/Modal.tsx | 4 | ||||
-rw-r--r-- | src/view/com/modals/Modal.web.tsx | 4 | ||||
-rw-r--r-- | src/view/com/modals/UserAddRemoveLists.tsx | 309 | ||||
-rw-r--r-- | src/view/screens/Lists.tsx | 18 | ||||
-rw-r--r-- | src/view/screens/ModerationModlists.tsx | 18 | ||||
-rw-r--r-- | src/view/screens/ProfileList.tsx | 494 |
11 files changed, 543 insertions, 640 deletions
diff --git a/src/view/com/lists/ListCard.tsx b/src/view/com/lists/ListCard.tsx index a481902d8..774e9e916 100644 --- a/src/view/com/lists/ListCard.tsx +++ b/src/view/com/lists/ListCard.tsx @@ -7,7 +7,7 @@ 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 {useSession} from '#/state/session' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {makeProfileLink} from 'lib/routes/links' @@ -28,7 +28,7 @@ export const ListCard = ({ style?: StyleProp<ViewStyle> }) => { const pal = usePalette('default') - const store = useStores() + const {currentAccount} = useSession() const rkey = React.useMemo(() => { try { @@ -80,7 +80,7 @@ export const ListCard = ({ {list.purpose === 'app.bsky.graph.defs#modlist' && 'Moderation list '} by{' '} - {list.creator.did === store.me.did + {list.creator.did === currentAccount?.did ? 'you' : sanitizeHandle(list.creator.handle, '@')} </Text> diff --git a/src/view/com/lists/ListItems.tsx b/src/view/com/lists/ListMembers.tsx index cf6fd3b42..4a25c53e6 100644 --- a/src/view/com/lists/ListItems.tsx +++ b/src/view/com/lists/ListMembers.tsx @@ -9,27 +9,28 @@ import { } from 'react-native' import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api' import {FlatList} from '../util/Views' -import {observer} from 'mobx-react-lite' 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 {ListModel} from 'state/models/content/list' 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 const ListItems = observer(function ListItemsImpl({ +export function ListMembers({ list, style, scrollElRef, @@ -42,7 +43,7 @@ export const ListItems = observer(function ListItemsImpl({ headerOffset = 0, desktopFixedHeightOffset, }: { - list: ListModel + list: string style?: StyleProp<ViewStyle> scrollElRef?: MutableRefObject<FlatList<any> | null> onScroll: OnScrollHandler @@ -59,33 +60,43 @@ export const ListItems = observer(function ListItemsImpl({ const [isRefreshing, setIsRefreshing] = React.useState(false) const {isMobile} = useWebMediaQueries() const {openModal} = useModalControls() + const {currentAccount} = useSession() - const data = React.useMemo(() => { + 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 (list.hasLoaded) { - if (list.hasError) { + if (isFetched) { + if (isEmpty && isError) { items = items.concat([ERROR_ITEM]) } - if (list.isEmpty) { + if (isEmpty) { items = items.concat([EMPTY_ITEM]) - } else { - items = items.concat(list.items) + } else if (data) { + for (const page of data.pages) { + items = items.concat(page.items) + } } - if (list.loadMoreError) { + if (!isEmpty && isError) { items = items.concat([LOAD_MORE_ERROR_ITEM]) } - } else if (list.isLoading) { + } else if (isFetching) { items = items.concat([LOADING_ITEM]) } return items - }, [ - list.hasError, - list.hasLoaded, - list.isLoading, - list.isEmpty, - list.items, - list.loadMoreError, - ]) + }, [isFetched, isEmpty, isError, data, isFetching]) // events // = @@ -94,25 +105,26 @@ export const ListItems = observer(function ListItemsImpl({ track('Lists:onRefresh') setIsRefreshing(true) try { - await list.refresh() + await refetch() } catch (err) { logger.error('Failed to refresh lists', {error: err}) } setIsRefreshing(false) - }, [list, track, setIsRefreshing]) + }, [refetch, track, setIsRefreshing]) const onEndReached = React.useCallback(async () => { + if (isFetching || !hasNextPage || isError) return track('Lists:onEndReached') try { - await list.loadMore() + await fetchNextPage() } catch (err) { logger.error('Failed to load more lists', {error: err}) } - }, [list, track]) + }, [isFetching, hasNextPage, isError, fetchNextPage, track]) const onPressRetryLoadMore = React.useCallback(() => { - list.retryLoadMore() - }, [list]) + fetchNextPage() + }, [fetchNextPage]) const onPressEditMembership = React.useCallback( (profile: AppBskyActorDefs.ProfileViewBasic) => { @@ -120,19 +132,9 @@ export const ListItems = observer(function ListItemsImpl({ name: 'user-add-remove-lists', subject: profile.did, displayName: profile.displayName || profile.handle, - onAdd(listUri: string) { - if (listUri === list.uri) { - list.cacheAddMember(profile) - } - }, - onRemove(listUri: string) { - if (listUri === list.uri) { - list.cacheRemoveMember(profile) - } - }, }) }, - [openModal, list], + [openModal], ) // rendering @@ -140,7 +142,7 @@ export const ListItems = observer(function ListItemsImpl({ const renderMemberButton = React.useCallback( (profile: AppBskyActorDefs.ProfileViewBasic) => { - if (!list.isOwner) { + if (!isOwner) { return null } return ( @@ -152,7 +154,7 @@ export const ListItems = observer(function ListItemsImpl({ /> ) }, - [list, onPressEditMembership], + [isOwner, onPressEditMembership], ) const renderItem = React.useCallback( @@ -162,7 +164,7 @@ export const ListItems = observer(function ListItemsImpl({ } else if (item === ERROR_ITEM) { return ( <ErrorMessage - message={list.error} + message={cleanError(error)} onPressTryAgain={onPressTryAgain} /> ) @@ -190,7 +192,7 @@ export const ListItems = observer(function ListItemsImpl({ [ renderMemberButton, renderEmptyState, - list.error, + error, onPressTryAgain, onPressRetryLoadMore, isMobile, @@ -200,10 +202,10 @@ export const ListItems = observer(function ListItemsImpl({ const Footer = React.useCallback( () => ( <View style={{paddingTop: 20, paddingBottom: 200}}> - {list.isLoading && <ActivityIndicator />} + {isFetching && <ActivityIndicator />} </View> ), - [list.isLoading], + [isFetching], ) const scrollHandler = useAnimatedScrollHandler(onScroll) @@ -212,8 +214,8 @@ export const ListItems = observer(function ListItemsImpl({ <FlatList testID={testID ? `${testID}-flatlist` : undefined} ref={scrollElRef} - data={data} - keyExtractor={(item: any) => item._reactKey} + data={items} + keyExtractor={(item: any) => item.uri || item._reactKey} renderItem={renderItem} ListHeaderComponent={renderHeader} ListFooterComponent={Footer} @@ -241,4 +243,4 @@ export const ListItems = observer(function ListItemsImpl({ /> </View> ) -}) +} diff --git a/src/view/com/lists/ListsList.tsx b/src/view/com/lists/ListsList.tsx index 2883a31d5..100e0d609 100644 --- a/src/view/com/lists/ListsList.tsx +++ b/src/view/com/lists/ListsList.tsx @@ -8,68 +8,59 @@ import { View, ViewStyle, } from 'react-native' -import {observer} from 'mobx-react-lite' import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' import {ListCard} from './ListCard' +import {MyListsFilter, useMyListsQuery} from '#/state/queries/my-lists' import {ErrorMessage} from '../util/error/ErrorMessage' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' import {Text} from '../util/text/Text' -import {ListsListModel} from 'state/models/lists/lists-list' import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' import {FlatList} from '../util/Views' import {s} from 'lib/styles' import {logger} from '#/logger' import {Trans} from '@lingui/macro' +import {cleanError} from '#/lib/strings/errors' const LOADING = {_reactKey: '__loading__'} const EMPTY = {_reactKey: '__empty__'} const ERROR_ITEM = {_reactKey: '__error__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} -export const ListsList = observer(function ListsListImpl({ - listsList, +export function ListsList({ + filter, inline, style, - onPressTryAgain, renderItem, testID, }: { - listsList: ListsListModel + filter: MyListsFilter inline?: boolean style?: StyleProp<ViewStyle> - onPressTryAgain?: () => void renderItem?: (list: GraphDefs.ListView, index: number) => JSX.Element testID?: string }) { const pal = usePalette('default') const {track} = useAnalytics() const [isRefreshing, setIsRefreshing] = React.useState(false) + const {data, isFetching, isFetched, isError, error, refetch} = + useMyListsQuery(filter) + const isEmpty = !isFetching && !data?.length - const data = React.useMemo(() => { + const items = React.useMemo(() => { let items: any[] = [] - if (listsList.hasError) { + if (isError && isEmpty) { items = items.concat([ERROR_ITEM]) } - if (!listsList.hasLoaded && listsList.isLoading) { + if (!isFetched && isFetching) { items = items.concat([LOADING]) - } else if (listsList.isEmpty) { + } else if (isEmpty) { items = items.concat([EMPTY]) } else { - items = items.concat(listsList.lists) - } - if (listsList.loadMoreError) { - items = items.concat([LOAD_MORE_ERROR_ITEM]) + items = items.concat(data) } return items - }, [ - listsList.hasError, - listsList.hasLoaded, - listsList.isLoading, - listsList.lists, - listsList.isEmpty, - listsList.loadMoreError, - ]) + }, [isError, isEmpty, isFetched, isFetching, data]) // events // = @@ -78,25 +69,12 @@ export const ListsList = observer(function ListsListImpl({ track('Lists:onRefresh') setIsRefreshing(true) try { - await listsList.refresh() + await refetch() } catch (err) { logger.error('Failed to refresh lists', {error: err}) } setIsRefreshing(false) - }, [listsList, track, setIsRefreshing]) - - const onEndReached = React.useCallback(async () => { - track('Lists:onEndReached') - try { - await listsList.loadMore() - } catch (err) { - logger.error('Failed to load more lists', {error: err}) - } - }, [listsList, track]) - - const onPressRetryLoadMore = React.useCallback(() => { - listsList.retryLoadMore() - }, [listsList]) + }, [refetch, track, setIsRefreshing]) // rendering // = @@ -116,15 +94,15 @@ export const ListsList = observer(function ListsListImpl({ } else if (item === ERROR_ITEM) { return ( <ErrorMessage - message={listsList.error} - onPressTryAgain={onPressTryAgain} + message={cleanError(error)} + onPressTryAgain={onRefresh} /> ) } else if (item === LOAD_MORE_ERROR_ITEM) { return ( <LoadMoreRetryBtn label="There was an issue fetching your lists. Tap here to try again." - onPress={onPressRetryLoadMore} + onPress={onRefresh} /> ) } else if (item === LOADING) { @@ -144,16 +122,16 @@ export const ListsList = observer(function ListsListImpl({ /> ) }, - [listsList, onPressTryAgain, onPressRetryLoadMore, renderItem, pal], + [error, onRefresh, renderItem, pal], ) const FlatListCom = inline ? RNFlatList : FlatList return ( <View testID={testID} style={style}> - {data.length > 0 && ( + {items.length > 0 && ( <FlatListCom testID={testID ? `${testID}-flatlist` : undefined} - data={data} + data={items} keyExtractor={(item: any) => item._reactKey} renderItem={renderItemInner} refreshControl={ @@ -165,8 +143,6 @@ export const ListsList = observer(function ListsListImpl({ /> } contentContainerStyle={[s.contentContainer]} - onEndReached={onEndReached} - onEndReachedThreshold={0.6} removeClippedSubviews={true} // @ts-ignore our .web version only -prf desktopFixedHeight @@ -174,7 +150,7 @@ export const ListsList = observer(function ListsListImpl({ )} </View> ) -}) +} const styles = StyleSheet.create({ item: { diff --git a/src/view/com/modals/CreateOrEditList.tsx b/src/view/com/modals/CreateOrEditList.tsx index cfd0f7569..8d13cdf2f 100644 --- a/src/view/com/modals/CreateOrEditList.tsx +++ b/src/view/com/modals/CreateOrEditList.tsx @@ -1,5 +1,4 @@ import React, {useState, useCallback, useMemo} from 'react' -import * as Toast from '../util/Toast' import { ActivityIndicator, KeyboardAvoidingView, @@ -9,12 +8,12 @@ import { TouchableOpacity, View, } from 'react-native' +import {AppBskyGraphDefs} from '@atproto/api' import LinearGradient from 'react-native-linear-gradient' import {Image as RNImage} from 'react-native-image-crop-picker' import {Text} from '../util/text/Text' import {ErrorMessage} from '../util/error/ErrorMessage' -import {useStores} from 'state/index' -import {ListModel} from 'state/models/content/list' +import * as Toast from '../util/Toast' import {s, colors, gradients} from 'lib/styles' import {enforceLen} from 'lib/strings/helpers' import {compressIfNeeded} from 'lib/media/manip' @@ -27,6 +26,10 @@ import {cleanError, isNetworkError} from 'lib/strings/errors' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useModalControls} from '#/state/modals' +import { + useListCreateMutation, + useListMetadataMutation, +} from '#/state/queries/list' const MAX_NAME = 64 // todo const MAX_DESCRIPTION = 300 // todo @@ -40,9 +43,8 @@ export function Component({ }: { purpose?: string onSave?: (uri: string) => void - list?: ListModel + list?: AppBskyGraphDefs.ListView }) { - const store = useStores() const {closeModal} = useModalControls() const {isMobile} = useWebMediaQueries() const [error, setError] = useState<string>('') @@ -50,10 +52,12 @@ export function Component({ const theme = useTheme() const {track} = useAnalytics() const {_} = useLingui() + const listCreateMutation = useListCreateMutation() + const listMetadataMutation = useListMetadataMutation() const activePurpose = useMemo(() => { - if (list?.data?.purpose) { - return list.data.purpose + if (list?.purpose) { + return list.purpose } if (purpose) { return purpose @@ -64,11 +68,11 @@ export function Component({ const purposeLabel = isCurateList ? 'User' : 'Moderation' const [isProcessing, setProcessing] = useState<boolean>(false) - const [name, setName] = useState<string>(list?.data?.name || '') + const [name, setName] = useState<string>(list?.name || '') const [description, setDescription] = useState<string>( - list?.data?.description || '', + list?.description || '', ) - const [avatar, setAvatar] = useState<string | undefined>(list?.data?.avatar) + const [avatar, setAvatar] = useState<string | undefined>(list?.avatar) const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>() const onPressCancel = useCallback(() => { @@ -111,7 +115,8 @@ export function Component({ } try { if (list) { - await list.updateMetadata({ + await listMetadataMutation.mutateAsync({ + uri: list.uri, name: nameTrimmed, description: description.trim(), avatar: newAvatar, @@ -119,7 +124,7 @@ export function Component({ Toast.show(`${purposeLabel} list updated`) onSave?.(list.uri) } else { - const res = await ListModel.createList(store, { + const res = await listCreateMutation.mutateAsync({ purpose: activePurpose, name, description, @@ -145,7 +150,6 @@ export function Component({ setError, error, onSave, - store, closeModal, activePurpose, isCurateList, @@ -154,6 +158,8 @@ export function Component({ description, newAvatar, list, + listMetadataMutation, + listCreateMutation, ]) return ( diff --git a/src/view/com/modals/ListAddUser.tsx b/src/view/com/modals/ListAddRemoveUsers.tsx index e59ab90df..d34194c41 100644 --- a/src/view/com/modals/ListAddUser.tsx +++ b/src/view/com/modals/ListAddRemoveUsers.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useCallback, useState, useMemo} from 'react' +import React, {useCallback, useState} from 'react' import { ActivityIndicator, Pressable, @@ -6,17 +6,13 @@ import { StyleSheet, View, } from 'react-native' -import {AppBskyActorDefs} from '@atproto/api' +import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api' import {ScrollView, TextInput} from './util' -import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Text} from '../util/text/Text' import {Button} from '../util/forms/Button' import {UserAvatar} from '../util/UserAvatar' import * as Toast from '../util/Toast' -import {useStores} from 'state/index' -import {ListModel} from 'state/models/content/list' -import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {isWeb} from 'platform/detection' @@ -29,49 +25,37 @@ import {HITSLOP_20} from '#/lib/constants' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useModalControls} from '#/state/modals' +import { + useDangerousListMembershipsQuery, + getMembership, + ListMembersip, + useListMembershipAddMutation, + useListMembershipRemoveMutation, +} from '#/state/queries/list-memberships' +import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' export const snapPoints = ['90%'] -export const Component = observer(function Component({ +export function Component({ list, - onAdd, + onChange, }: { - list: ListModel - onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void + list: AppBskyGraphDefs.ListView + onChange?: ( + type: 'add' | 'remove', + profile: AppBskyActorDefs.ProfileViewBasic, + ) => void }) { const pal = usePalette('default') - const store = useStores() const {_} = useLingui() const {closeModal} = useModalControls() const {isMobile} = useWebMediaQueries() const [query, setQuery] = useState('') - const autocompleteView = useMemo<UserAutocompleteModel>( - () => new UserAutocompleteModel(store), - [store], - ) + const autocomplete = useActorAutocompleteQuery(query) + const {data: memberships} = useDangerousListMembershipsQuery() const [isKeyboardVisible] = useIsKeyboardVisible() - // initial setup - useEffect(() => { - autocompleteView.setup().then(() => { - autocompleteView.setPrefix('') - }) - autocompleteView.setActive(true) - list.loadAll() - }, [autocompleteView, list]) - - const onChangeQuery = useCallback( - (text: string) => { - setQuery(text) - autocompleteView.setPrefix(text) - }, - [setQuery, autocompleteView], - ) - - const onPressCancelSearch = useCallback( - () => onChangeQuery(''), - [onChangeQuery], - ) + const onPressCancelSearch = useCallback(() => setQuery(''), [setQuery]) return ( <SafeAreaView @@ -86,7 +70,7 @@ export const Component = observer(function Component({ placeholder="Search for users" placeholderTextColor={pal.colors.textLight} value={query} - onChangeText={onChangeQuery} + onChangeText={setQuery} accessible={true} accessibilityLabel={_(msg`Search`)} accessibilityHint="" @@ -116,19 +100,20 @@ export const Component = observer(function Component({ style={[s.flex1]} keyboardDismissMode="none" keyboardShouldPersistTaps="always"> - {autocompleteView.isLoading ? ( + {autocomplete.isLoading ? ( <View style={{marginVertical: 20}}> <ActivityIndicator /> </View> - ) : autocompleteView.suggestions.length ? ( + ) : autocomplete.data?.length ? ( <> - {autocompleteView.suggestions.slice(0, 40).map((item, i) => ( + {autocomplete.data.slice(0, 40).map((item, i) => ( <UserResult key={item.did} list={list} profile={item} + memberships={memberships} noBorder={i === 0} - onAdd={onAdd} + onChange={onChange} /> ))} </> @@ -139,7 +124,7 @@ export const Component = observer(function Component({ pal.textLight, {paddingHorizontal: 12, paddingVertical: 16}, ]}> - <Trans>No results found for {autocompleteView.prefix}</Trans> + <Trans>No results found for {query}</Trans> </Text> )} </ScrollView> @@ -162,36 +147,71 @@ export const Component = observer(function Component({ </View> </SafeAreaView> ) -}) +} function UserResult({ profile, list, + memberships, noBorder, - onAdd, + onChange, }: { profile: AppBskyActorDefs.ProfileViewBasic - list: ListModel + list: AppBskyGraphDefs.ListView + memberships: ListMembersip[] | undefined noBorder: boolean - onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void | undefined + onChange?: ( + type: 'add' | 'remove', + profile: AppBskyActorDefs.ProfileViewBasic, + ) => void | undefined }) { const pal = usePalette('default') + const {_} = useLingui() const [isProcessing, setIsProcessing] = useState(false) - const [isAdded, setIsAdded] = useState(list.isMember(profile.did)) + const membership = React.useMemo( + () => getMembership(memberships, list.uri, profile.did), + [memberships, list.uri, profile.did], + ) + const listMembershipAddMutation = useListMembershipAddMutation() + const listMembershipRemoveMutation = useListMembershipRemoveMutation() - const onPressAdd = useCallback(async () => { + const onToggleMembership = useCallback(async () => { + if (typeof membership === 'undefined') { + return + } setIsProcessing(true) try { - await list.addMember(profile) - Toast.show('Added to list') - setIsAdded(true) - onAdd?.(profile) + if (membership === false) { + await listMembershipAddMutation.mutateAsync({ + listUri: list.uri, + actorDid: profile.did, + }) + Toast.show(_(msg`Added to list`)) + onChange?.('add', profile) + } else { + await listMembershipRemoveMutation.mutateAsync({ + listUri: list.uri, + actorDid: profile.did, + membershipUri: membership, + }) + Toast.show(_(msg`Removed from list`)) + onChange?.('remove', profile) + } } catch (e) { Toast.show(cleanError(e)) } finally { setIsProcessing(false) } - }, [list, profile, setIsProcessing, setIsAdded, onAdd]) + }, [ + _, + list, + profile, + membership, + setIsProcessing, + onChange, + listMembershipAddMutation, + listMembershipRemoveMutation, + ]) return ( <View @@ -233,16 +253,14 @@ function UserResult({ {!!profile.viewer?.followedBy && <View style={s.flexRow} />} </View> <View> - {isAdded ? ( - <FontAwesomeIcon icon="check" /> - ) : isProcessing ? ( + {isProcessing || typeof membership === 'undefined' ? ( <ActivityIndicator /> ) : ( <Button testID={`user-${profile.handle}-addBtn`} type="default" - label="Add" - onPress={onPressAdd} + label={membership === false ? _(msg`Add`) : _(msg`Remove`)} + onPress={onToggleMembership} /> )} </View> diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index c1999c5d6..38c8bc7ba 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -18,7 +18,7 @@ import * as RepostModal from './Repost' import * as SelfLabelModal from './SelfLabel' import * as CreateOrEditListModal from './CreateOrEditList' import * as UserAddRemoveListsModal from './UserAddRemoveLists' -import * as ListAddUserModal from './ListAddUser' +import * as ListAddUserModal from './ListAddRemoveUsers' import * as AltImageModal from './AltImage' import * as EditImageModal from './AltImage' import * as ReportModal from './report/Modal' @@ -108,7 +108,7 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'user-add-remove-lists') { snapPoints = UserAddRemoveListsModal.snapPoints element = <UserAddRemoveListsModal.Component {...activeModal} /> - } else if (activeModal?.name === 'list-add-user') { + } else if (activeModal?.name === 'list-add-remove-users') { snapPoints = ListAddUserModal.snapPoints element = <ListAddUserModal.Component {...activeModal} /> } else if (activeModal?.name === 'delete-account') { diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 65c4ee444..28f6c36c9 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -13,7 +13,7 @@ import * as ServerInputModal from './ServerInput' import * as ReportModal from './report/Modal' import * as CreateOrEditListModal from './CreateOrEditList' import * as UserAddRemoveLists from './UserAddRemoveLists' -import * as ListAddUserModal from './ListAddUser' +import * as ListAddUserModal from './ListAddRemoveUsers' import * as DeleteAccountModal from './DeleteAccount' import * as RepostModal from './Repost' import * as SelfLabelModal from './SelfLabel' @@ -85,7 +85,7 @@ function Modal({modal}: {modal: ModalIface}) { element = <CreateOrEditListModal.Component {...modal} /> } else if (modal.name === 'user-add-remove-lists') { element = <UserAddRemoveLists.Component {...modal} /> - } else if (modal.name === 'list-add-user') { + } else if (modal.name === 'list-add-remove-users') { element = <ListAddUserModal.Component {...modal} /> } else if (modal.name === 'crop-image') { element = <CropImageModal.Component {...modal} /> diff --git a/src/view/com/modals/UserAddRemoveLists.tsx b/src/view/com/modals/UserAddRemoveLists.tsx index efcfc43be..73b1bc744 100644 --- a/src/view/com/modals/UserAddRemoveLists.tsx +++ b/src/view/com/modals/UserAddRemoveLists.tsx @@ -1,33 +1,32 @@ import React, {useCallback} from 'react' -import {observer} from 'mobx-react-lite' -import {ActivityIndicator, Pressable, StyleSheet, View} from 'react-native' +import {ActivityIndicator, StyleSheet, View} from 'react-native' import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' import {Text} from '../util/text/Text' import {UserAvatar} from '../util/UserAvatar' import {ListsList} from '../lists/ListsList' -import {ListsListModel} from 'state/models/lists/lists-list' -import {ListMembershipModel} from 'state/models/content/list-membership' import {Button} from '../util/forms/Button' import * as Toast from '../util/Toast' -import {useStores} from 'state/index' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {s} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {isWeb, isAndroid} from 'platform/detection' -import isEqual from 'lodash.isequal' -import {logger} from '#/logger' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useModalControls} from '#/state/modals' +import { + useDangerousListMembershipsQuery, + getMembership, + ListMembersip, + useListMembershipAddMutation, + useListMembershipRemoveMutation, +} from '#/state/queries/list-memberships' +import {cleanError} from '#/lib/strings/errors' +import {useSession} from '#/state/session' export const snapPoints = ['fullscreen'] -export const Component = observer(function UserAddRemoveListsImpl({ +export function Component({ subject, displayName, onAdd, @@ -38,193 +37,161 @@ export const Component = observer(function UserAddRemoveListsImpl({ onAdd?: (listUri: string) => void onRemove?: (listUri: string) => void }) { - const store = useStores() const {closeModal} = useModalControls() const pal = usePalette('default') const {_} = useLingui() - const palPrimary = usePalette('primary') - const palInverted = usePalette('inverted') - const [originalSelections, setOriginalSelections] = React.useState<string[]>( - [], - ) - const [selected, setSelected] = React.useState<string[]>([]) - const [membershipsLoaded, setMembershipsLoaded] = React.useState(false) + const {data: memberships} = useDangerousListMembershipsQuery() - const listsList: ListsListModel = React.useMemo( - () => new ListsListModel(store, store.me.did), - [store], - ) - const memberships: ListMembershipModel = React.useMemo( - () => new ListMembershipModel(store, subject), - [store, subject], - ) - React.useEffect(() => { - listsList.refresh() - memberships.fetch().then( - () => { - const ids = memberships.memberships.map(m => m.value.list) - setOriginalSelections(ids) - setSelected(ids) - setMembershipsLoaded(true) - }, - err => { - logger.error('Failed to fetch memberships', {error: err}) - }, - ) - }, [memberships, listsList, store, setSelected, setMembershipsLoaded]) - - const onPressCancel = useCallback(() => { + const onPressDone = useCallback(() => { closeModal() }, [closeModal]) - const onPressSave = useCallback(async () => { - let changes - try { - changes = await memberships.updateTo(selected) - } catch (err) { - logger.error('Failed to update memberships', {error: err}) - return - } - Toast.show('Lists updated') - for (const uri of changes.added) { - onAdd?.(uri) - } - for (const uri of changes.removed) { - onRemove?.(uri) - } - closeModal() - }, [closeModal, selected, memberships, onAdd, onRemove]) - - const onToggleSelected = useCallback( - (uri: string) => { - if (selected.includes(uri)) { - setSelected(selected.filter(uri2 => uri2 !== uri)) - } else { - setSelected([...selected, uri]) - } - }, - [selected, setSelected], - ) - - const renderItem = useCallback( - (list: GraphDefs.ListView, index: number) => { - const isSelected = selected.includes(list.uri) - return ( - <Pressable - testID={`toggleBtn-${list.name}`} - style={[ - styles.listItem, - pal.border, - { - opacity: membershipsLoaded ? 1 : 0.5, - borderTopWidth: index === 0 ? 0 : 1, - }, - ]} - accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${ - list.name - }`} - accessibilityHint="" - disabled={!membershipsLoaded} - onPress={() => onToggleSelected(list.uri)}> - <View style={styles.listItemAvi}> - <UserAvatar size={40} avatar={list.avatar} /> - </View> - <View style={styles.listItemContent}> - <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#curatelist' && - 'User list '} - {list.purpose === 'app.bsky.graph.defs#modlist' && - 'Moderation list '} - by{' '} - {list.creator.did === store.me.did - ? 'you' - : sanitizeHandle(list.creator.handle, '@')} - </Text> - </View> - {membershipsLoaded && ( - <View - style={ - isSelected - ? [styles.checkbox, palPrimary.border, palPrimary.view] - : [styles.checkbox, pal.borderDark] - }> - {isSelected && ( - <FontAwesomeIcon - icon="check" - style={palInverted.text as FontAwesomeIconStyle} - /> - )} - </View> - )} - </Pressable> - ) - }, - [ - pal, - palPrimary, - palInverted, - onToggleSelected, - selected, - store.me.did, - membershipsLoaded, - ], - ) - - // Only show changes button if there are some items on the list to choose from AND user has made changes in selection - const canSaveChanges = - !listsList.isEmpty && !isEqual(selected, originalSelections) - return ( <View testID="userAddRemoveListsModal" style={s.hContentRegion}> <Text style={[styles.title, pal.text]}> <Trans>Update {displayName} in Lists</Trans> </Text> <ListsList - listsList={listsList} + filter="all" inline - renderItem={renderItem} + renderItem={(list, index) => ( + <ListItem + index={index} + list={list} + memberships={memberships} + subject={subject} + onAdd={onAdd} + onRemove={onRemove} + /> + )} style={[styles.list, pal.border]} /> <View style={[styles.btns, pal.border]}> <Button - testID="cancelBtn" + testID="doneBtn" type="default" - onPress={onPressCancel} + onPress={onPressDone} style={styles.footerBtn} - accessibilityLabel={_(msg`Cancel`)} + accessibilityLabel={_(msg`Done`)} accessibilityHint="" - onAccessibilityEscape={onPressCancel} - label="Cancel" + onAccessibilityEscape={onPressDone} + label="Done" /> - {canSaveChanges && ( + </View> + </View> + ) +} + +function ListItem({ + index, + list, + memberships, + subject, + onAdd, + onRemove, +}: { + index: number + list: GraphDefs.ListView + memberships: ListMembersip[] | undefined + subject: string + onAdd?: (listUri: string) => void + onRemove?: (listUri: string) => void +}) { + const pal = usePalette('default') + const {_} = useLingui() + const {currentAccount} = useSession() + const [isProcessing, setIsProcessing] = React.useState(false) + const membership = React.useMemo( + () => getMembership(memberships, list.uri, subject), + [memberships, list.uri, subject], + ) + const listMembershipAddMutation = useListMembershipAddMutation() + const listMembershipRemoveMutation = useListMembershipRemoveMutation() + + const onToggleMembership = useCallback(async () => { + if (typeof membership === 'undefined') { + return + } + setIsProcessing(true) + try { + if (membership === false) { + await listMembershipAddMutation.mutateAsync({ + listUri: list.uri, + actorDid: subject, + }) + Toast.show(_(msg`Added to list`)) + onAdd?.(list.uri) + } else { + await listMembershipRemoveMutation.mutateAsync({ + listUri: list.uri, + actorDid: subject, + membershipUri: membership, + }) + Toast.show(_(msg`Removed from list`)) + onRemove?.(list.uri) + } + } catch (e) { + Toast.show(cleanError(e)) + } finally { + setIsProcessing(false) + } + }, [ + _, + list, + subject, + membership, + setIsProcessing, + onAdd, + onRemove, + listMembershipAddMutation, + listMembershipRemoveMutation, + ]) + + return ( + <View + testID={`toggleBtn-${list.name}`} + style={[ + styles.listItem, + pal.border, + { + borderTopWidth: index === 0 ? 0 : 1, + }, + ]}> + <View style={styles.listItemAvi}> + <UserAvatar size={40} avatar={list.avatar} /> + </View> + <View style={styles.listItemContent}> + <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#curatelist' && 'User list '} + {list.purpose === 'app.bsky.graph.defs#modlist' && 'Moderation list '} + by{' '} + {list.creator.did === currentAccount?.did + ? 'you' + : sanitizeHandle(list.creator.handle, '@')} + </Text> + </View> + <View> + {isProcessing || typeof membership === 'undefined' ? ( + <ActivityIndicator /> + ) : ( <Button - testID="saveBtn" - type="primary" - onPress={onPressSave} - style={styles.footerBtn} - accessibilityLabel={_(msg`Save changes`)} - accessibilityHint="" - onAccessibilityEscape={onPressSave} - label="Save Changes" + testID={`user-${subject}-addBtn`} + type="default" + label={membership === false ? _(msg`Add`) : _(msg`Remove`)} + onPress={onToggleMembership} /> )} - - {(listsList.isLoading || !membershipsLoaded) && ( - <View style={styles.loadingContainer}> - <ActivityIndicator /> - </View> - )} </View> </View> ) -}) +} const styles = StyleSheet.create({ container: { diff --git a/src/view/screens/Lists.tsx b/src/view/screens/Lists.tsx index a29b0d6c8..906fb5e5b 100644 --- a/src/view/screens/Lists.tsx +++ b/src/view/screens/Lists.tsx @@ -3,11 +3,8 @@ import {View} from 'react-native' import {useFocusEffect, useNavigation} from '@react-navigation/native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {AtUri} from '@atproto/api' -import {observer} from 'mobx-react-lite' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {useStores} from 'state/index' -import {ListsListModel} from 'state/models/lists/lists-list' import {ListsList} from 'view/com/lists/ListsList' import {Text} from 'view/com/util/text/Text' import {Button} from 'view/com/util/forms/Button' @@ -21,24 +18,17 @@ import {useModalControls} from '#/state/modals' type Props = NativeStackScreenProps<CommonNavigatorParams, 'Lists'> export const ListsScreen = withAuthRequired( - observer(function ListsScreenImpl({}: Props) { + function ListsScreenImpl({}: Props) { const pal = usePalette('default') - const store = useStores() const setMinimalShellMode = useSetMinimalShellMode() const {isMobile} = useWebMediaQueries() const navigation = useNavigation<NavigationProp>() const {openModal} = useModalControls() - const listsLists: ListsListModel = React.useMemo( - () => new ListsListModel(store, 'my-curatelists'), - [store], - ) - useFocusEffect( React.useCallback(() => { setMinimalShellMode(false) - listsLists.refresh() - }, [listsLists, setMinimalShellMode]), + }, [setMinimalShellMode]), ) const onPressNewList = React.useCallback(() => { @@ -89,8 +79,8 @@ export const ListsScreen = withAuthRequired( </Button> </View> </SimpleViewHeader> - <ListsList listsList={listsLists} style={s.flexGrow1} /> + <ListsList filter="curate" style={s.flexGrow1} /> </View> ) - }), + }, ) diff --git a/src/view/screens/ModerationModlists.tsx b/src/view/screens/ModerationModlists.tsx index 3892e47c7..098d93cdc 100644 --- a/src/view/screens/ModerationModlists.tsx +++ b/src/view/screens/ModerationModlists.tsx @@ -3,11 +3,8 @@ import {View} from 'react-native' import {useFocusEffect, useNavigation} from '@react-navigation/native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {AtUri} from '@atproto/api' -import {observer} from 'mobx-react-lite' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {useStores} from 'state/index' -import {ListsListModel} from 'state/models/lists/lists-list' import {ListsList} from 'view/com/lists/ListsList' import {Text} from 'view/com/util/text/Text' import {Button} from 'view/com/util/forms/Button' @@ -21,24 +18,17 @@ import {useModalControls} from '#/state/modals' type Props = NativeStackScreenProps<CommonNavigatorParams, 'ModerationModlists'> export const ModerationModlistsScreen = withAuthRequired( - observer(function ModerationModlistsScreenImpl({}: Props) { + function ModerationModlistsScreenImpl({}: Props) { const pal = usePalette('default') - const store = useStores() const setMinimalShellMode = useSetMinimalShellMode() const {isMobile} = useWebMediaQueries() const navigation = useNavigation<NavigationProp>() const {openModal} = useModalControls() - const mutelists: ListsListModel = React.useMemo( - () => new ListsListModel(store, 'my-modlists'), - [store], - ) - useFocusEffect( React.useCallback(() => { setMinimalShellMode(false) - mutelists.refresh() - }, [mutelists, setMinimalShellMode]), + }, [setMinimalShellMode]), ) const onPressNewList = React.useCallback(() => { @@ -89,8 +79,8 @@ export const ModerationModlistsScreen = withAuthRequired( </Button> </View> </SimpleViewHeader> - <ListsList listsList={mutelists} style={s.flexGrow1} /> + <ListsList filter="mod" style={s.flexGrow1} /> </View> ) - }), + }, ) diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 03c1703c1..18665f519 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -10,8 +10,7 @@ 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 {AppBskyGraphDefs, AtUri, RichText as RichTextAPI} from '@atproto/api' import {useQueryClient} from '@tanstack/react-query' import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' @@ -28,7 +27,6 @@ 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 {FeedDescriptor} from '#/state/queries/post-feed' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' @@ -39,17 +37,24 @@ import {OnScrollHandler} from 'lib/hooks/useOnMainScroll' import {NavigationProp} from 'lib/routes/types' import {toShareUrl} from 'lib/strings/url-helpers' import {shareUrl} from 'lib/sharing' -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' -import {logger} from '#/logger' +import {ListMembers} from '#/view/com/lists/ListMembers' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useSetMinimalShellMode} from '#/state/shell' import {useModalControls} from '#/state/modals' +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import { + useListQuery, + useListMuteMutation, + useListBlockMutation, + useListDeleteMutation, +} from '#/state/queries/list' +import {cleanError} from '#/lib/strings/errors' +import {useSession} from '#/state/session' const SECTION_TITLES_CURATE = ['Posts', 'About'] const SECTION_TITLES_MOD = ['About'] @@ -60,40 +65,32 @@ interface SectionRef { type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'> export const ProfileListScreen = withAuthRequired( - observer(function ProfileListScreenImpl(props: Props) { - const store = useStores() - const {name: handleOrDid} = props.route.params - const [listOwnerDid, setListOwnerDid] = React.useState<string | undefined>() - const [error, setError] = React.useState<string | undefined>() - - 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]) + function ProfileListScreenImpl(props: Props) { + const {name: handleOrDid, rkey} = props.route.params + const {data: resolvedUri, error: resolveError} = useResolveUriQuery( + AtUri.make(handleOrDid, 'app.bsky.graph.list', rkey).toString(), + ) + const {data: list, error: listError} = useListQuery(resolvedUri) - if (error) { + if (resolveError) { + return ( + <CenteredView> + <ErrorScreen + error={`We're sorry, but we were unable to resolve this list. If this persists, please contact the list creator, @${handleOrDid}.`} + /> + </CenteredView> + ) + } + if (listError) { return ( <CenteredView> - <ErrorScreen error={error} /> + <ErrorScreen error={cleanError(listError)} /> </CenteredView> ) } - return listOwnerDid ? ( - <ProfileListScreenInner {...props} listOwnerDid={listOwnerDid} /> + return resolvedUri && list ? ( + <ProfileListScreenLoaded {...props} uri={resolvedUri} list={list} /> ) : ( <CenteredView> <View style={s.p20}> @@ -101,192 +98,172 @@ export const ProfileListScreen = withAuthRequired( </View> </CenteredView> ) - }), + }, ) -export const ProfileListScreenInner = observer( - function ProfileListScreenInnerImpl({ - route, - listOwnerDid, - }: Props & {listOwnerDid: string}) { - const store = useStores() - const {_} = useLingui() - const queryClient = useQueryClient() - const setMinimalShellMode = useSetMinimalShellMode() - const {rkey} = route.params - const listUri = `at://${listOwnerDid}/app.bsky.graph.list/${rkey}` - const feedSectionRef = React.useRef<SectionRef>(null) - const aboutSectionRef = React.useRef<SectionRef>(null) - const {openModal} = useModalControls() - - const list: ListModel = useMemo(() => { - const model = new ListModel(store, listUri) - return model - }, [store, listUri]) - useSetTitle(list.data?.name) - - useFocusEffect( - useCallback(() => { - setMinimalShellMode(false) - list.loadMore(true) - }, [setMinimalShellMode, list]), - ) - - const onPressAddUser = useCallback(() => { - openModal({ - name: 'list-add-user', - list, - onAdd() { - if (list.isCuratelist) { - queryClient.invalidateQueries({ - queryKey: FEED_RQKEY(`list|${listUri}`), - }) - } - }, - }) - }, [openModal, list, queryClient, listUri]) +function ProfileListScreenLoaded({ + route, + uri, + list, +}: Props & {uri: string; list: AppBskyGraphDefs.ListView}) { + const store = useStores() + const {_} = useLingui() + const queryClient = useQueryClient() + const setMinimalShellMode = useSetMinimalShellMode() + const {rkey} = route.params + const feedSectionRef = React.useRef<SectionRef>(null) + const aboutSectionRef = React.useRef<SectionRef>(null) + const {openModal} = useModalControls() + const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist' + + useSetTitle(list.name) + + useFocusEffect( + useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) - const onCurrentPageSelected = React.useCallback( - (index: number) => { - if (index === 0) { - feedSectionRef.current?.scrollToTop() - } - if (index === 1) { - aboutSectionRef.current?.scrollToTop() + const onPressAddUser = useCallback(() => { + openModal({ + name: 'list-add-remove-users', + list, + onChange() { + if (isCurateList) { + queryClient.invalidateQueries({ + // TODO(eric) should construct these strings with a fn too + queryKey: FEED_RQKEY(`list|${list.uri}`), + }) } }, - [feedSectionRef], - ) + }) + }, [openModal, list, isCurateList, queryClient]) - const renderHeader = useCallback(() => { - return <Header rkey={rkey} list={list} /> - }, [rkey, list]) + const onCurrentPageSelected = React.useCallback( + (index: number) => { + if (index === 0) { + feedSectionRef.current?.scrollToTop() + } + if (index === 1) { + aboutSectionRef.current?.scrollToTop() + } + }, + [feedSectionRef], + ) - if (list.isCuratelist) { - return ( - <View style={s.hContentRegion}> - <PagerWithHeader - items={SECTION_TITLES_CURATE} - isHeaderReady={list.hasLoaded} - renderHeader={renderHeader} - onCurrentPageSelected={onCurrentPageSelected}> - {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( - <FeedSection - ref={feedSectionRef} - feed={`list|${listUri}`} - scrollElRef={ - scrollElRef as React.MutableRefObject<FlatList<any> | null> - } - onScroll={onScroll} - headerHeight={headerHeight} - isScrolledDown={isScrolledDown} - /> - )} - {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( - <AboutSection - ref={aboutSectionRef} - scrollElRef={ - scrollElRef as React.MutableRefObject<FlatList<any> | null> - } - 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={_(msg`New post`)} - accessibilityHint="" - /> - </View> - ) - } - if (list.isModlist) { - return ( - <View style={s.hContentRegion}> - <PagerWithHeader - items={SECTION_TITLES_MOD} - isHeaderReady={list.hasLoaded} - renderHeader={renderHeader}> - {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( - <AboutSection - list={list} - scrollElRef={ - scrollElRef as React.MutableRefObject<FlatList<any> | null> - } - 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={_(msg`New post`)} - accessibilityHint="" - /> - </View> - ) - } + const renderHeader = useCallback(() => { + return <Header rkey={rkey} list={list} /> + }, [rkey, list]) + + if (isCurateList) { return ( - <CenteredView sideBorders style={s.hContentRegion}> - <Header rkey={rkey} list={list} /> - {list.error ? <ErrorScreen error={list.error} /> : null} - </CenteredView> + <View style={s.hContentRegion}> + <PagerWithHeader + items={SECTION_TITLES_CURATE} + isHeaderReady={true} + renderHeader={renderHeader} + onCurrentPageSelected={onCurrentPageSelected}> + {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( + <FeedSection + ref={feedSectionRef} + feed={`list|${uri}`} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + onScroll={onScroll} + headerHeight={headerHeight} + isScrolledDown={isScrolledDown} + /> + )} + {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( + <AboutSection + ref={aboutSectionRef} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + list={list} + 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={_(msg`New post`)} + accessibilityHint="" + /> + </View> ) - }, -) + } + return ( + <View style={s.hContentRegion}> + <PagerWithHeader + items={SECTION_TITLES_MOD} + isHeaderReady={true} + renderHeader={renderHeader}> + {({onScroll, headerHeight, isScrolledDown, scrollElRef}) => ( + <AboutSection + list={list} + scrollElRef={ + scrollElRef as React.MutableRefObject<FlatList<any> | null> + } + 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={_(msg`New post`)} + accessibilityHint="" + /> + </View> + ) +} -const Header = observer(function HeaderImpl({ - rkey, - list, -}: { - rkey: string - list: ListModel -}) { +function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { const pal = usePalette('default') const palInverted = usePalette('inverted') const {_} = useLingui() const navigation = useNavigation<NavigationProp>() + const {currentAccount} = useSession() const {openModal, closeModal} = useModalControls() + const listMuteMutation = useListMuteMutation() + const listBlockMutation = useListBlockMutation() + const listDeleteMutation = useListDeleteMutation() + const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist' + const isModList = list.purpose === 'app.bsky.graph.defs#modlist' + const isPinned = false // TODO + const isBlocking = !!list.viewer?.blocked + const isMuting = !!list.viewer?.muted + const isOwner = list.creator.did === currentAccount?.did const onTogglePinned = useCallback(async () => { Haptics.default() - list.togglePin().catch(e => { - Toast.show('There was an issue contacting the server') - logger.error('Failed to toggle pinned list', {error: e}) - }) - }, [list]) + // TODO + // list.togglePin().catch(e => { + // Toast.show('There was an issue contacting the server') + // logger.error('Failed to toggle pinned list', {error: e}) + // }) + }, []) const onSubscribeMute = useCallback(() => { openModal({ @@ -297,7 +274,7 @@ const Header = observer(function HeaderImpl({ confirmBtnText: 'Mute this List', async onPressConfirm() { try { - await list.mute() + await listMuteMutation.mutateAsync({uri: list.uri, mute: true}) Toast.show('List muted') } catch { Toast.show( @@ -309,18 +286,18 @@ const Header = observer(function HeaderImpl({ closeModal() }, }) - }, [openModal, closeModal, list]) + }, [openModal, closeModal, list, listMuteMutation]) const onUnsubscribeMute = useCallback(async () => { try { - await list.unmute() + await listMuteMutation.mutateAsync({uri: list.uri, mute: false}) Toast.show('List unmuted') } catch { Toast.show( 'There was an issue. Please check your internet connection and try again.', ) } - }, [list]) + }, [list, listMuteMutation]) const onSubscribeBlock = useCallback(() => { openModal({ @@ -331,7 +308,7 @@ const Header = observer(function HeaderImpl({ confirmBtnText: 'Block this List', async onPressConfirm() { try { - await list.block() + await listBlockMutation.mutateAsync({uri: list.uri, block: true}) Toast.show('List blocked') } catch { Toast.show( @@ -343,26 +320,23 @@ const Header = observer(function HeaderImpl({ closeModal() }, }) - }, [openModal, closeModal, list]) + }, [openModal, closeModal, list, listBlockMutation]) const onUnsubscribeBlock = useCallback(async () => { try { - await list.unblock() + await listBlockMutation.mutateAsync({uri: list.uri, block: false}) Toast.show('List unblocked') } catch { Toast.show( 'There was an issue. Please check your internet connection and try again.', ) } - }, [list]) + }, [list, listBlockMutation]) const onPressEdit = useCallback(() => { openModal({ name: 'create-or-edit-list', list, - onSave() { - list.refresh() - }, }) }, [openModal, list]) @@ -372,7 +346,7 @@ const Header = observer(function HeaderImpl({ title: 'Delete List', message: 'Are you sure?', async onPressConfirm() { - await list.delete() + await listDeleteMutation.mutateAsync({uri: list.uri}) Toast.show('List deleted') if (navigation.canGoBack()) { navigation.goBack() @@ -381,26 +355,22 @@ const Header = observer(function HeaderImpl({ } }, }) - }, [openModal, list, navigation]) + }, [openModal, list, listDeleteMutation, navigation]) const onPressReport = useCallback(() => { - if (!list.data) return openModal({ name: 'report', uri: list.uri, - cid: list.data.cid, + cid: list.cid, }) }, [openModal, list]) const onPressShare = useCallback(() => { - const url = toShareUrl(`/profile/${list.creatorDid}/lists/${rkey}`) + const url = toShareUrl(`/profile/${list.creator.did}/lists/${rkey}`) shareUrl(url) - }, [list.creatorDid, rkey]) + }, [list, rkey]) const dropdownItems: DropdownItem[] = useMemo(() => { - if (!list.hasLoaded) { - return [] - } let items: DropdownItem[] = [ { testID: 'listHeaderDropdownShareBtn', @@ -415,7 +385,7 @@ const Header = observer(function HeaderImpl({ }, }, ] - if (list.isOwner) { + if (isOwner) { items.push({label: 'separator'}) items.push({ testID: 'listHeaderDropdownEditBtn', @@ -457,14 +427,7 @@ const Header = observer(function HeaderImpl({ }) } return items - }, [ - list.hasLoaded, - list.isOwner, - onPressShare, - onPressEdit, - onPressDelete, - onPressReport, - ]) + }, [isOwner, onPressShare, onPressEdit, onPressDelete, onPressReport]) const subscribeDropdownItems: DropdownItem[] = useMemo(() => { return [ @@ -497,32 +460,28 @@ const Header = observer(function HeaderImpl({ 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} + href={makeListLink(list.creator.handle || list.creator.did || '', rkey)} + title={list.name} + avatar={list.avatar} + isOwner={list.creator.did === currentAccount?.did} + creator={list.creator} avatarType="list"> - {list.isCuratelist || list.isPinned ? ( + {isCurateList || isPinned ? ( <Button testID={list.isPinned ? 'unpinBtn' : 'pinBtn'} type={list.isPinned ? 'default' : 'inverted'} label={list.isPinned ? 'Unpin' : 'Pin to home'} onPress={onTogglePinned} /> - ) : list.isModlist ? ( - list.isBlocking ? ( + ) : isModList ? ( + isBlocking ? ( <Button testID="unblockBtn" type="default" label="Unblock" onPress={onUnsubscribeBlock} /> - ) : list.isMuting ? ( + ) : isMuting ? ( <Button testID="unmuteBtn" type="default" @@ -554,7 +513,7 @@ const Header = observer(function HeaderImpl({ </NativeDropdown> </ProfileSubpageHeader> ) -}) +} interface FeedSectionProps { feed: FeedDescriptor @@ -610,11 +569,7 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( ) interface AboutSectionProps { - list: ListModel - descriptionRT: RichTextAPI | null - creator: {did: string; handle: string} | undefined - isCurateList: boolean | undefined - isOwner: boolean | undefined + list: AppBskyGraphDefs.ListView onPressAddUser: () => void onScroll: OnScrollHandler headerHeight: number @@ -623,23 +578,26 @@ interface AboutSectionProps { } const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( function AboutSectionImpl( - { - list, - descriptionRT, - creator, - isCurateList, - isOwner, - onPressAddUser, - onScroll, - headerHeight, - isScrolledDown, - scrollElRef, - }, + {list, onPressAddUser, onScroll, headerHeight, isScrolledDown, scrollElRef}, ref, ) { const pal = usePalette('default') const {_} = useLingui() const {isMobile} = useWebMediaQueries() + const {currentAccount} = useSession() + const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist' + const isOwner = list.creator.did === currentAccount?.did + + const descriptionRT = useMemo( + () => + list.description + ? new RichTextAPI({ + text: list.description, + facets: list.descriptionFacets, + }) + : undefined, + [list], + ) const onScrollToTop = useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -headerHeight}) @@ -650,9 +608,6 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( })) const renderHeader = React.useCallback(() => { - if (!list.data) { - return <View /> - } return ( <View> <View @@ -685,8 +640,8 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( 'you' ) : ( <TextLink - text={sanitizeHandle(creator?.handle || '', '@')} - href={creator ? makeProfileLink(creator) : ''} + text={sanitizeHandle(list.creator.handle || '', '@')} + href={makeProfileLink(list.creator)} style={pal.textLight} /> )} @@ -728,10 +683,9 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( ) }, [ pal, - list.data, + list, isMobile, descriptionRT, - creator, isCurateList, isOwner, onPressAddUser, @@ -750,12 +704,12 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( return ( <View> - <ListItems + <ListMembers testID="listItems" + list={list.uri} scrollElRef={scrollElRef} renderHeader={renderHeader} renderEmptyState={renderEmptyState} - list={list} headerOffset={headerHeight} onScroll={onScroll} scrollEventThrottle={1} |