diff options
Diffstat (limited to 'src/view/com')
-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 |
8 files changed, 311 insertions, 342 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: { |