diff --git a/src/components/Account.tsx b/src/components/Account.tsx index a59684b7..a65827dd 100644 --- a/src/components/Account.tsx +++ b/src/components/Account.tsx @@ -4,39 +4,47 @@ import { StackNavigationProp } from '@react-navigation/stack' import { TabLocalStackParamList } from '@utils/navigation/navigators' import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' -import React, { useCallback } from 'react' +import React, { PropsWithChildren } from 'react' import { Pressable, View } from 'react-native' import GracefullyImage from './GracefullyImage' import CustomText from './Text' export interface Props { account: Mastodon.Account - onPress?: () => void + Component?: typeof View | typeof Pressable + props?: {} } -const ComponentAccount: React.FC = ({ account, onPress: customOnPress }) => { +const ComponentAccount: React.FC = ({ + account, + Component, + props, + children +}) => { const { colors } = useTheme() const navigation = useNavigation>() - const onPress = useCallback(() => navigation.push('Tab-Shared-Account', { account }), []) + if (!props) { + props = { onPress: () => navigation.push('Tab-Shared-Account', { account }) } + } - return ( - + } + }, + = ({ account, onPress: customOnPress }) @{account.acct} - + , + children ) } diff --git a/src/components/Timeline/Empty.tsx b/src/components/Timeline/Empty.tsx index b2ef2cde..c533de87 100644 --- a/src/components/Timeline/Empty.tsx +++ b/src/components/Timeline/Empty.tsx @@ -57,7 +57,7 @@ const TimelineEmpty = React.memo( style={{ marginTop: StyleConstants.Spacing.S, marginBottom: StyleConstants.Spacing.L, - color: colors.primaryDefault + color: colors.secondary }} > {t('empty.success.message')} diff --git a/src/i18n/en/screens/tabs.json b/src/i18n/en/screens/tabs.json index c50884bb..828ff2e2 100644 --- a/src/i18n/en/screens/tabs.json +++ b/src/i18n/en/screens/tabs.json @@ -46,17 +46,20 @@ "language": { "name": "Language" }, - "lists": { - "name": "Lists" + "list": { + "name": "List: {{list}}" + }, + "listAccounts": { + "name": "Users in list: {{list}}" }, "listAdd": { "name": "Add a List" }, "listEdit": { - "name": "Edit List" + "name": "Edit List Details" }, - "list": { - "name": "List: {{list}}" + "lists": { + "name": "Lists" }, "push": { "name": "Push Notification" @@ -93,7 +96,13 @@ "XXL": "XXL" } }, + "listAccounts": { + "heading": "Manage users", + "error": "Delete user from list", + "empty": "No user added in this list" + }, "listEdit": { + "heading": "Edit list details", "title": "Title", "repliesPolicy": { "heading": "Show replies to:", diff --git a/src/screens/Compose/Root/Suggestion.tsx b/src/screens/Compose/Root/Suggestion.tsx index 2f5155e2..c56078b0 100644 --- a/src/screens/Compose/Root/Suggestion.tsx +++ b/src/screens/Compose/Root/Suggestion.tsx @@ -44,7 +44,7 @@ const ComposeRootSuggestion: React.FC = ({ item }) => { } return item.acct ? ( - + ) : ( ) diff --git a/src/screens/Tabs/Me.tsx b/src/screens/Tabs/Me.tsx index 7c244ac5..bf8c5a14 100644 --- a/src/screens/Tabs/Me.tsx +++ b/src/screens/Tabs/Me.tsx @@ -8,8 +8,9 @@ import TabMeBookmarks from './Me/Bookmarks' import TabMeConversations from './Me/Cconversations' import TabMeFavourites from './Me/Favourites' import TabMeList from './Me/List' -import TabMeListEdit from './Me/ListEdit' -import TabMeLists from './Me/Lists' +import TabMeListAccounts from './Me/List/Accounts' +import TabMeListEdit from './Me/List/Edit' +import TabMeListList from './Me/List/List' import TabMeProfile from './Me/Profile' import TabMePush from './Me/Push' import TabMeRoot from './Me/Root' @@ -86,21 +87,28 @@ const TabMe = React.memo( headerLeft: () => navigation.pop(1)} /> })} /> + ({ + title: t('me.stacks.listAccounts.name', { list: params.title }), + ...(Platform.OS === 'android' && { + headerCenter: () => + }), + headerLeft: () => navigation.pop(1)} /> + })} + /> - }) + presentation: 'modal' }} /> ({ title: t('me.stacks.lists.name'), ...(Platform.OS === 'android' && { diff --git a/src/screens/Tabs/Me/List/Accounts.tsx b/src/screens/Tabs/Me/List/Accounts.tsx new file mode 100644 index 00000000..ca526283 --- /dev/null +++ b/src/screens/Tabs/Me/List/Accounts.tsx @@ -0,0 +1,99 @@ +import ComponentAccount from '@components/Account' +import Button from '@components/Button' +import haptics from '@components/haptics' +import { displayMessage } from '@components/Message' +import CustomText from '@components/Text' +import { TabMeStackScreenProps } from '@utils/navigation/navigators' +import { + QueryKeyListAccounts, + useListAccountsMutation, + useListAccountsQuery +} from '@utils/queryHooks/lists' +import { StyleConstants } from '@utils/styles/constants' +import { useTheme } from '@utils/styles/ThemeManager' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { FlatList, View } from 'react-native' + +const TabMeListAccounts: React.FC> = ({ + route: { params } +}) => { + const { colors, theme } = useTheme() + const { t } = useTranslation('screenTabs') + + const queryKey: QueryKeyListAccounts = ['ListAccounts', { id: params.id }] + const { data, refetch, fetchNextPage, hasNextPage } = useListAccountsQuery({ + ...queryKey[1], + options: { + getNextPageParam: lastPage => + lastPage?.links?.next && { + max_id: lastPage.links.next + } + } + }) + + const flattenData = data?.pages ? data.pages?.flatMap(page => [...page.body]) : [] + + const mutation = useListAccountsMutation({ + onSuccess: () => { + haptics('Light') + refetch() + }, + onError: () => { + displayMessage({ + theme, + type: 'error', + message: t('common:message.error.message', { + function: t('me.listAccounts.error') + }) + }) + } + }) + + return ( + ( + + mutation.mutate({ type: 'delete', payload: { id: params.id, accounts: [item.id] } }) + } + /> + } + /> + )} + ListEmptyComponent={ + + + {t('me.listAccounts.empty')} + + + } + onEndReached={() => hasNextPage && fetchNextPage()} + /> + ) +} + +export default TabMeListAccounts diff --git a/src/screens/Tabs/Me/ListEdit.tsx b/src/screens/Tabs/Me/List/Edit.tsx similarity index 90% rename from src/screens/Tabs/Me/ListEdit.tsx rename to src/screens/Tabs/Me/List/Edit.tsx index a4eab6ee..dda4e3e4 100644 --- a/src/screens/Tabs/Me/ListEdit.tsx +++ b/src/screens/Tabs/Me/List/Edit.tsx @@ -1,6 +1,6 @@ import { EmojisState } from '@components/Emojis/helpers/EmojisContext' import haptics from '@components/haptics' -import { HeaderLeft, HeaderRight } from '@components/Header' +import { HeaderCenter, HeaderLeft, HeaderRight } from '@components/Header' import ComponentInput from '@components/Input' import { displayMessage, Message } from '@components/Message' import Selections from '@components/Selections' @@ -12,7 +12,7 @@ import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' import React, { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Alert, ScrollView, TextInput } from 'react-native' +import { Alert, Platform, ScrollView, TextInput } from 'react-native' import { useQueryClient } from 'react-query' const TabMeListEdit: React.FC> = ({ @@ -83,6 +83,16 @@ const TabMeListEdit: React.FC> = ({ useEffect(() => { navigation.setOptions({ + title: params.type === 'add' ? t('me.stacks.listAdd.name') : t('me.stacks.listEdit.name'), + ...(Platform.OS === 'android' && { + headerCenter: () => ( + + ) + }), headerLeft: () => ( > = ({ navigation }) => { +const TabMeListList: React.FC> = ({ navigation }) => { const { data } = useListsQuery({}) const { t } = useTranslation('screenTabs') @@ -23,17 +23,17 @@ const TabMeLists: React.FC> = ({ navigatio return ( - {data?.map((d: Mastodon.List, i: number) => ( + {data?.map((params, index) => ( navigation.navigate('Tab-Me-List', d)} + title={params.title} + onPress={() => navigation.navigate('Tab-Me-List', params)} /> ))} ) } -export default TabMeLists +export default TabMeListList diff --git a/src/screens/Tabs/Me/List.tsx b/src/screens/Tabs/Me/List/index.tsx similarity index 60% rename from src/screens/Tabs/Me/List.tsx rename to src/screens/Tabs/Me/List/index.tsx index e1d1625e..464a8d5c 100644 --- a/src/screens/Tabs/Me/List.tsx +++ b/src/screens/Tabs/Me/List/index.tsx @@ -9,9 +9,9 @@ import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' import React, { useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { Alert } from 'react-native' import { useQueryClient } from 'react-query' import * as DropdownMenu from 'zeego/dropdown-menu' +import { menuListAccounts, menuListDelete, menuListEdit } from './menus' const TabMeList: React.FC> = ({ navigation, @@ -40,6 +40,10 @@ const TabMeList: React.FC> = ({ }) useEffect(() => { + const listAccounts = menuListAccounts({ params }) + const listEdit = menuListEdit({ params, key }) + const listDelete = menuListDelete({ params, mutation }) + navigation.setOptions({ headerRight: () => ( @@ -52,41 +56,24 @@ const TabMeList: React.FC> = ({ - - navigation.navigate('Tab-Me-List-Edit', { - type: 'edit', - payload: params, - key - }) - } - > - - - + + + + + + - - Alert.alert( - t('me.listDelete.confirm.title', { list: params.title.slice(0, 6) }), - t('me.listDelete.confirm.message'), - [ - { - text: t('common:buttons.delete'), - style: 'destructive', - onPress: () => mutation.mutate({ type: 'delete', payload: params }) - }, - { text: t('common:buttons.cancel') } - ] - ) - } - > - - - + + + + + + + + + + + ) diff --git a/src/screens/Tabs/Me/List/menus.tsx b/src/screens/Tabs/Me/List/menus.tsx new file mode 100644 index 00000000..aaccaf6b --- /dev/null +++ b/src/screens/Tabs/Me/List/menus.tsx @@ -0,0 +1,48 @@ +import navigationRef from '@helpers/navigationRef' +import i18next from 'i18next' +import { Alert } from 'react-native' +import { UseMutationResult } from 'react-query' + +export const menuListAccounts = ({ params }: { params: Mastodon.List }) => ({ + key: 'list-accounts', + onSelect: () => navigationRef.navigate('Tab-Me-List-Accounts', params), + title: i18next.t('screenTabs:me.listAccounts.heading'), + icon: 'person.crop.circle.fill.badge.checkmark' +}) + +export const menuListEdit = ({ params, key }: { params: Mastodon.List; key: string }) => ({ + key: 'list-edit', + onSelect: () => + navigationRef.navigate('Tab-Me-List-Edit', { + type: 'edit', + payload: params, + key + }), + title: i18next.t('screenTabs:me.listEdit.heading'), + icon: 'square.and.pencil' +}) + +export const menuListDelete = ({ + params, + mutation +}: { + params: Mastodon.List + mutation: UseMutationResult +}) => ({ + key: 'list-delete', + onSelect: () => + Alert.alert( + i18next.t('screenTabs:me.listDelete.confirm.title', { list: params.title.slice(0, 6) }), + i18next.t('screenTabs:me.listDelete.confirm.message'), + [ + { + text: i18next.t('common:buttons.delete'), + style: 'destructive', + onPress: () => mutation.mutate({ type: 'delete', payload: params }) + }, + { text: i18next.t('common:buttons.cancel') } + ] + ), + title: i18next.t('screenTabs:me.listDelete.heading'), + icon: 'trash' +}) diff --git a/src/screens/Tabs/Me/Root/Collections.tsx b/src/screens/Tabs/Me/Root/Collections.tsx index daf2608e..f9897c57 100644 --- a/src/screens/Tabs/Me/Root/Collections.tsx +++ b/src/screens/Tabs/Me/Root/Collections.tsx @@ -85,7 +85,7 @@ const Collections: React.FC = () => { iconFront='List' iconBack='ChevronRight' title={t('me.stacks.lists.name')} - onPress={() => navigation.navigate('Tab-Me-Lists')} + onPress={() => navigation.navigate('Tab-Me-List-List')} /> ) : null} {mePage.announcements.shown ? ( diff --git a/src/utils/navigation/navigators.ts b/src/utils/navigation/navigators.ts index f9659575..8ae239d4 100644 --- a/src/utils/navigation/navigators.ts +++ b/src/utils/navigation/navigators.ts @@ -139,6 +139,7 @@ export type TabMeStackParamList = { 'Tab-Me-Conversations': undefined 'Tab-Me-Favourites': undefined 'Tab-Me-List': Mastodon.List + 'Tab-Me-List-Accounts': Omit 'Tab-Me-List-Edit': | { type: 'add' @@ -148,7 +149,7 @@ export type TabMeStackParamList = { payload: Mastodon.List key: string // To update title after successful mutation } - 'Tab-Me-Lists': undefined + 'Tab-Me-List-List': undefined 'Tab-Me-Profile': undefined 'Tab-Me-Push': undefined 'Tab-Me-Settings': undefined diff --git a/src/utils/queryHooks/lists.ts b/src/utils/queryHooks/lists.ts index 865928b2..0f7d7071 100644 --- a/src/utils/queryHooks/lists.ts +++ b/src/utils/queryHooks/lists.ts @@ -1,6 +1,14 @@ -import apiInstance from '@api/instance' +import apiInstance, { InstanceResponse } from '@api/instance' import { AxiosError } from 'axios' -import { useMutation, UseMutationOptions, useQuery, UseQueryOptions } from 'react-query' +import { + QueryFunctionContext, + useInfiniteQuery, + UseInfiniteQueryOptions, + useMutation, + UseMutationOptions, + useQuery, + UseQueryOptions +} from 'react-query' export type QueryKeyLists = ['Lists'] @@ -53,10 +61,10 @@ const mutationFunction = async (params: MutationVarsLists) => { body }).then(res => res.body) case 'delete': - return apiInstance({ + return apiInstance<{}>({ method: 'delete', url: `lists/${params.payload.id}` - }) + }).then(res => res.body) } } @@ -66,4 +74,54 @@ const useListsMutation = ( return useMutation(mutationFunction, options) } -export { useListsQuery, useListsMutation } +/* ----- */ + +export type QueryKeyListAccounts = ['ListAccounts', { id: Mastodon.List['id'] }] + +const accountsQueryFunction = async ({ + queryKey, + pageParam +}: QueryFunctionContext) => { + const { id } = queryKey[1] + + return await apiInstance({ + method: 'get', + url: `lists/${id}/accounts`, + params: { ...pageParam, limit: 40 } + }) +} + +const useListAccountsQuery = ({ + options, + ...queryKeyParams +}: QueryKeyListAccounts[1] & { + options?: UseInfiniteQueryOptions, AxiosError> +}) => { + const queryKey: QueryKeyListAccounts = ['ListAccounts', queryKeyParams] + return useInfiniteQuery(queryKey, accountsQueryFunction, options) +} + +type AccountsMutationVarsLists = { + type: 'add' | 'delete' + payload: Pick & { accounts: Mastodon.Account['id'][] } +} + +const accountsMutationFunction = async (params: AccountsMutationVarsLists) => { + const body = new FormData() + for (const account of params.payload.accounts) { + body.append('account_ids[]', account) + } + return apiInstance<{}>({ + method: params.type === 'add' ? 'post' : 'delete', + url: `lists/${params.payload.id}/accounts`, + body + }).then(res => res.body) +} + +const useListAccountsMutation = ( + options: UseMutationOptions +) => { + return useMutation(accountsMutationFunction, options) +} + +export { useListsQuery, useListsMutation, useListAccountsQuery, useListAccountsMutation }