From a3a0bf523f1a6481e1ccc2b20be449d8efbe3975 Mon Sep 17 00:00:00 2001 From: xmflsct Date: Sat, 3 Dec 2022 01:08:38 +0100 Subject: [PATCH] Moving to using zeego --- ios/Podfile.lock | 6 - package.json | 1 - src/components/ContextMenu/account.ts | 175 --------- src/components/ContextMenu/instance.ts | 116 +++--- src/components/ContextMenu/share.ts | 109 +++--- src/components/ContextMenu/status.ts | 335 +++++++++--------- src/components/Timeline/Default.tsx | 86 ++++- src/components/Timeline/Notifications.tsx | 101 ++++-- .../Timeline/Shared/ContextMenu.tsx | 84 ----- .../Timeline/Shared/HeaderAndroid.tsx | 103 ++++++ .../Timeline/Shared/HeaderDefault.android.tsx | 114 ------ .../Timeline/Shared/HeaderDefault.ios.tsx | 75 ---- .../Timeline/Shared/HeaderDefault.tsx | 144 ++++++++ .../Shared/HeaderNotification.android.tsx | 157 -------- .../Shared/HeaderNotification.ios.tsx | 105 ------ .../Timeline/Shared/HeaderNotification.tsx | 163 +++++++++ src/components/contextMenu/account.tsx | 220 ++++++++++++ src/components/contextMenu/index.d.ts | 6 + src/i18n/en/components/contextMenu.json | 6 +- src/screens/Tabs/Local.tsx | 69 ++-- src/screens/Tabs/Shared/Account.tsx | 72 +++- src/screens/Tabs/Shared/index.tsx | 42 +-- yarn.lock | 4 - 23 files changed, 1179 insertions(+), 1114 deletions(-) delete mode 100644 src/components/ContextMenu/account.ts delete mode 100644 src/components/Timeline/Shared/ContextMenu.tsx create mode 100644 src/components/Timeline/Shared/HeaderAndroid.tsx delete mode 100644 src/components/Timeline/Shared/HeaderDefault.android.tsx delete mode 100644 src/components/Timeline/Shared/HeaderDefault.ios.tsx create mode 100644 src/components/Timeline/Shared/HeaderDefault.tsx delete mode 100644 src/components/Timeline/Shared/HeaderNotification.android.tsx delete mode 100644 src/components/Timeline/Shared/HeaderNotification.ios.tsx create mode 100644 src/components/Timeline/Shared/HeaderNotification.tsx create mode 100644 src/components/contextMenu/account.tsx create mode 100644 src/components/contextMenu/index.d.ts diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 37023dbe..e676ffb5 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -297,8 +297,6 @@ PODS: - React-Core - react-native-cameraroll (5.1.0): - React-Core - - react-native-context-menu-view (1.5.4): - - React - react-native-image-picker (4.10.1): - React-Core - react-native-ios-context-menu (1.15.1): @@ -494,7 +492,6 @@ DEPENDENCIES: - "react-native-blur (from `../node_modules/@react-native-community/blur`)" - react-native-blurhash (from `../node_modules/react-native-blurhash`) - "react-native-cameraroll (from `../node_modules/@react-native-camera-roll/camera-roll`)" - - react-native-context-menu-view (from `../node_modules/react-native-context-menu-view`) - react-native-image-picker (from `../node_modules/react-native-image-picker`) - react-native-ios-context-menu (from `../node_modules/react-native-ios-context-menu`) - react-native-language-detection (from `../node_modules/react-native-language-detection`) @@ -627,8 +624,6 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-blurhash" react-native-cameraroll: :path: "../node_modules/@react-native-camera-roll/camera-roll" - react-native-context-menu-view: - :path: "../node_modules/react-native-context-menu-view" react-native-image-picker: :path: "../node_modules/react-native-image-picker" react-native-ios-context-menu: @@ -742,7 +737,6 @@ SPEC CHECKSUMS: react-native-blur: 50c9feabacbc5f49b61337ebc32192c6be7ec3c3 react-native-blurhash: add4df9a937b4e021a24bc67a0714f13e0bd40b7 react-native-cameraroll: a40b082318eb1ecd0336a2f29d9f74b7f2c8cae8 - react-native-context-menu-view: b0beca02aad4bd9f9d7d932bf437e0a03baa69ef react-native-image-picker: f2ab1215d17bcfe27b0eb6417cc236fd1f4775e7 react-native-ios-context-menu: b170594b4448c0cd10c79e13432216bac99de1ac react-native-language-detection: 0e43195ad014974f1b7a31b64820eff34a243f2d diff --git a/package.json b/package.json index 34ddf089..cdf9715f 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,6 @@ "react-native-animated-spinkit": "^1.5.2", "react-native-base64": "^0.2.1", "react-native-blurhash": "^1.1.10", - "react-native-context-menu-view": "xmflsct/react-native-context-menu-view", "react-native-fast-image": "^8.6.3", "react-native-feather": "^1.1.2", "react-native-flash-message": "^0.3.1", diff --git a/src/components/ContextMenu/account.ts b/src/components/ContextMenu/account.ts deleted file mode 100644 index 9bb4645b..00000000 --- a/src/components/ContextMenu/account.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { displayMessage } from '@components/Message' -import { useRelationshipQuery } from '@utils/queryHooks/relationship' -import { - MutationVarsTimelineUpdateAccountProperty, - QueryKeyTimeline, - useTimelineMutation -} from '@utils/queryHooks/timeline' -import { getInstanceAccount } from '@utils/slices/instancesSlice' -import { useTheme } from '@utils/styles/ThemeManager' -import { useTranslation } from 'react-i18next' -import { Platform } from 'react-native' -import { ContextMenuAction } from 'react-native-context-menu-view' -import { useQueryClient } from 'react-query' -import { useSelector } from 'react-redux' - -export interface Props { - actions: ContextMenuAction[] - type: 'status' | 'account' // Do not need to fetch relationship in timeline - queryKey?: QueryKeyTimeline - rootQueryKey?: QueryKeyTimeline - id: Mastodon.Account['id'] -} - -const contextMenuAccount = ({ actions, type, queryKey, rootQueryKey, id: accountId }: Props) => { - const { theme } = useTheme() - const { t } = useTranslation('componentContextMenu') - - const queryClient = useQueryClient() - const mutation = useTimelineMutation({ - onSuccess: (_, params) => { - queryClient.refetchQueries(['Relationship', { id: accountId }]) - const theParams = params as MutationVarsTimelineUpdateAccountProperty - displayMessage({ - theme, - type: 'success', - message: t('common:message.success.message', { - function: t(`account.${theParams.payload.property}.action`, { - ...(theParams.payload.property !== 'reports' && { - context: (theParams.payload.currentValue || false).toString() - }) - }) - }) - }) - }, - onError: (err: any, params) => { - const theParams = params as MutationVarsTimelineUpdateAccountProperty - displayMessage({ - theme, - type: 'error', - message: t('common:message.error.message', { - function: t(`account.${theParams.payload.property}.action`, { - ...(theParams.payload.property !== 'reports' && { - context: (theParams.payload.currentValue || false).toString() - }) - }) - }), - ...(err.status && - typeof err.status === 'number' && - err.data && - err.data.error && - typeof err.data.error === 'string' && { - description: err.data.error - }) - }) - }, - onSettled: () => { - queryKey && queryClient.invalidateQueries(queryKey) - rootQueryKey && queryClient.invalidateQueries(rootQueryKey) - } - }) - - const instanceAccount = useSelector(getInstanceAccount, (prev, next) => prev.id === next.id) - const ownAccount = instanceAccount?.id === accountId - - const { data: relationship } = useRelationshipQuery({ - id: accountId, - options: { enabled: type === 'account' } - }) - - if (!ownAccount) { - actions.push({ - id: 'account-mute', - title: t('account.mute.action', { - context: (relationship?.muting || false).toString() - }), - systemIcon: 'eye.slash' - }) - switch (Platform.OS) { - case 'ios': - actions.push({ - id: 'account', - title: t('account.title'), - actions: [ - { - id: 'account-block', - title: t('account.block.action', { - context: (relationship?.blocking || false).toString() - }), - systemIcon: 'xmark.circle', - destructive: true - }, - { - id: 'account-reports', - title: t('account.reports.action'), - systemIcon: 'flag', - destructive: true - } - ] - }) - break - default: - actions.push( - { - id: 'account-block', - title: t('account.block.action', { - context: (relationship?.blocking || false).toString() - }), - systemIcon: 'xmark.circle', - destructive: true - }, - { - id: 'account-reports', - title: t('account.reports.action'), - systemIcon: 'flag', - destructive: true - } - ) - break - } - } - - return (index: number) => { - if (typeof index !== 'number' || !actions[index]) { - return // For Android - } - if (actions[index].id === 'account-mute') { - mutation.mutate({ - type: 'updateAccountProperty', - queryKey, - id: accountId, - payload: { property: 'mute', currentValue: relationship?.muting } - }) - } - if ( - actions[index].id === 'account-block' || - (actions[index].id === 'account' && actions[index].actions?.[0].id === 'account-block') - ) { - mutation.mutate({ - type: 'updateAccountProperty', - queryKey, - id: accountId, - payload: { property: 'block', currentValue: relationship?.blocking } - }) - } - if ( - actions[index].id === 'account-reports' || - (actions[index].id === 'account' && actions[index].actions?.[0].id === 'account-reports') - ) { - mutation.mutate({ - type: 'updateAccountProperty', - queryKey, - id: accountId, - payload: { property: 'reports' } - }) - mutation.mutate({ - type: 'updateAccountProperty', - queryKey, - id: accountId, - payload: { property: 'block', currentValue: false } - }) - } - } -} - -export default contextMenuAccount diff --git a/src/components/ContextMenu/instance.ts b/src/components/ContextMenu/instance.ts index 3022c512..b152779a 100644 --- a/src/components/ContextMenu/instance.ts +++ b/src/components/ContextMenu/instance.ts @@ -3,24 +3,23 @@ import { QueryKeyTimeline, useTimelineMutation } from '@utils/queryHooks/timelin import { getInstanceUrl } from '@utils/slices/instancesSlice' import { useTheme } from '@utils/styles/ThemeManager' import { useTranslation } from 'react-i18next' -import { Alert, Platform } from 'react-native' -import { ContextMenuAction } from 'react-native-context-menu-view' +import { Alert } from 'react-native' import { useQueryClient } from 'react-query' import { useSelector } from 'react-redux' -export interface Props { - actions: ContextMenuAction[] - status: Mastodon.Status - queryKey: QueryKeyTimeline +const menuInstance = ({ + status, + queryKey, + rootQueryKey +}: { + status?: Mastodon.Status + queryKey?: QueryKeyTimeline rootQueryKey?: QueryKeyTimeline -} +}): ContextMenu[][] => { + if (!status || !queryKey) return [] -const contextMenuInstance = ({ actions, status, queryKey, rootQueryKey }: Props) => { - const { t } = useTranslation('componentContextMenu') const { theme } = useTheme() - - const currentInstance = useSelector(getInstanceUrl) - const instance = status?.uri && status.uri.split(new RegExp(/\/\/(.*?)\//))[1] + const { t } = useTranslation('componentContextMenu') const queryClient = useQueryClient() const mutation = useTimelineMutation({ @@ -37,61 +36,48 @@ const contextMenuInstance = ({ actions, status, queryKey, rootQueryKey }: Props) } }) + const menus: ContextMenu[][] = [] + + const currentInstance = useSelector(getInstanceUrl) + const instance = status.uri && status.uri.split(new RegExp(/\/\/(.*?)\//))[1] + if (currentInstance !== instance && instance) { - switch (Platform.OS) { - case 'ios': - actions.push({ - id: 'instance', - title: t('instance.title'), - actions: [ - { - id: 'instance-block', - title: t('instance.block.action', { instance }), - destructive: true - } - ] - }) - break - default: - actions.push({ - id: 'instance-block', - title: t('instance.block.action', { instance }), - destructive: true - }) - break - } + menus.push([ + { + key: 'instance-block', + item: { + onSelect: () => + Alert.alert( + t('instance.block.alert.title', { instance }), + t('instance.block.alert.message'), + [ + { + text: t('instance.block.alert.buttons.confirm'), + style: 'destructive', + onPress: () => { + mutation.mutate({ + type: 'domainBlock', + queryKey, + domain: instance + }) + } + }, + { + text: t('common:buttons.cancel') + } + ] + ), + disabled: false, + destructive: true, + hidden: false + }, + title: t('instance.block.action', { instance }), + icon: '' + } + ]) } - return (index: number) => { - if (typeof index !== 'number' || !actions[index]) { - return // For Android - } - if ( - actions[index].id === 'instance-block' || - (actions[index].id === 'instance' && actions[index].actions?.[0].id === 'instance-block') - ) { - Alert.alert( - t('instance.block.alert.title', { instance }), - t('instance.block.alert.message'), - [ - { - text: t('instance.block.alert.buttons.confirm'), - style: 'destructive', - onPress: () => { - mutation.mutate({ - type: 'domainBlock', - queryKey, - domain: instance - }) - } - }, - { - text: t('common:buttons.cancel') - } - ] - ) - } - } + return menus } -export default contextMenuInstance +export default menuInstance diff --git a/src/components/ContextMenu/share.ts b/src/components/ContextMenu/share.ts index 9bc5c465..961bc091 100644 --- a/src/components/ContextMenu/share.ts +++ b/src/components/ContextMenu/share.ts @@ -3,59 +3,74 @@ import Clipboard from '@react-native-clipboard/clipboard' import { useTheme } from '@utils/styles/ThemeManager' import { useTranslation } from 'react-i18next' import { Platform, Share } from 'react-native' -import { ContextMenuAction } from 'react-native-context-menu-view' -export interface Props { - copiableContent?: React.MutableRefObject<{ - content?: string | undefined - complete: boolean - }> - actions: ContextMenuAction[] - type: 'status' | 'account' - url: string -} +const menuShare = ( + params: + | { + visibility?: Mastodon.Status['visibility'] + copiableContent?: React.MutableRefObject<{ + content?: string | undefined + complete: boolean + }> + type: 'status' + url?: string + } + | { + type: 'account' + url: string + } +): ContextMenu[][] => { + if (params.type === 'status' && params.visibility === 'direct') return [] -const contextMenuShare = ({ copiableContent, actions, type, url }: Props) => { const { theme } = useTheme() const { t } = useTranslation('componentContextMenu') - actions.push({ - id: 'share', - title: t(`share.${type}.action`), - systemIcon: 'square.and.arrow.up' - }) - Platform.OS !== 'android' && - type === 'status' && - actions.push({ - id: 'copy', - title: t(`copy.action`), - systemIcon: 'doc.on.doc', - disabled: !copiableContent?.current.content?.length + const menus: ContextMenu[][] = [[]] + + if (params.url) { + const url = params.url + menus[0].push({ + key: 'share', + item: { + onSelect: () => { + switch (Platform.OS) { + case 'ios': + Share.share({ url }) + break + case 'android': + Share.share({ message: url }) + break + } + }, + disabled: false, + destructive: false, + hidden: false + }, + title: t(`share.${params.type}.action`), + icon: 'square.and.arrow.up' + }) + } + if (params.type === 'status' && Platform.OS === 'ios') + menus[0].push({ + key: 'copy', + item: { + onSelect: () => { + Clipboard.setString(params.copiableContent?.current.content || '') + displayMessage({ + theme, + type: 'success', + message: t(`copy.succeed`) + }) + }, + disabled: false, + destructive: false, + hidden: !params.copiableContent?.current.content?.length + }, + title: t('copy.action'), + icon: 'doc.on.doc' }) - return (index: number) => { - if (typeof index !== 'number' || !actions[index]) { - return // For Android - } - if (actions[index].id === 'copy') { - Clipboard.setString(copiableContent?.current.content || '') - displayMessage({ - theme, - type: 'success', - message: t(`copy.succeed`) - }) - } - if (actions[index].id === 'share') { - switch (Platform.OS) { - case 'ios': - Share.share({ url }) - break - case 'android': - Share.share({ message: url }) - break - } - } - } + return menus } -export default contextMenuShare +export default menuShare diff --git a/src/components/ContextMenu/status.ts b/src/components/ContextMenu/status.ts index 6f23326f..9223d640 100644 --- a/src/components/ContextMenu/status.ts +++ b/src/components/ContextMenu/status.ts @@ -12,18 +12,20 @@ import { checkInstanceFeature, getInstanceAccount } from '@utils/slices/instance import { useTheme } from '@utils/styles/ThemeManager' import { useTranslation } from 'react-i18next' import { Alert } from 'react-native' -import { ContextMenuAction } from 'react-native-context-menu-view' import { useQueryClient } from 'react-query' import { useSelector } from 'react-redux' -export interface Props { - actions: ContextMenuAction[] - status: Mastodon.Status - queryKey: QueryKeyTimeline +const menuStatus = ({ + status, + queryKey, + rootQueryKey +}: { + status?: Mastodon.Status + queryKey?: QueryKeyTimeline rootQueryKey?: QueryKeyTimeline -} +}): ContextMenu[][] => { + if (!status || !queryKey) return [] -const contextMenuStatus = ({ actions, status, queryKey, rootQueryKey }: Props) => { const navigation = useNavigation>() const { theme } = useTheme() const { t } = useTranslation('componentContextMenu') @@ -53,85 +55,19 @@ const contextMenuStatus = ({ actions, status, queryKey, rootQueryKey }: Props) = } }) + const menus: ContextMenu[][] = [] + const instanceAccount = useSelector(getInstanceAccount, (prev, next) => prev.id === next.id) - const ownAccount = instanceAccount?.id === status?.account?.id + const ownAccount = instanceAccount?.id === status.account?.id + + const canEditPost = useSelector(checkInstanceFeature('edit_post')) if (ownAccount) { - const accountMenuItems: ContextMenuAction[] = [ + menus.push([ { - id: 'status-delete', - title: t('status.delete.action'), - systemIcon: 'trash', - destructive: true - }, - { - id: 'status-delete-edit', - title: t('status.deleteEdit.action'), - systemIcon: 'pencil.and.outline', - destructive: true - }, - { - id: 'status-mute', - title: t('status.mute.action', { - context: (status.muted || false).toString() - }), - systemIcon: status.muted ? 'speaker' : 'speaker.slash' - } - ] - - const canEditPost = useSelector(checkInstanceFeature('edit_post')) - if (canEditPost) { - accountMenuItems.unshift({ - id: 'status-edit', - title: t('status.edit.action'), - systemIcon: 'square.and.pencil' - }) - } - - if (status.visibility === 'public' || status.visibility === 'unlisted') { - accountMenuItems.push({ - id: 'status-pin', - title: t('status.pin.action', { - context: (status.pinned || false).toString() - }), - systemIcon: status.pinned ? 'pin.slash' : 'pin' - }) - } - - actions.push(...accountMenuItems) - } - - return async (index: number) => { - if (typeof index !== 'number' || !actions[index]) { - return // For Android - } - if (actions[index].id === 'status-delete') { - Alert.alert(t('status.delete.alert.title'), t('status.delete.alert.message'), [ - { - text: t('status.delete.alert.buttons.confirm'), - style: 'destructive', - onPress: async () => { - mutation.mutate({ - type: 'deleteItem', - source: 'statuses', - queryKey, - rootQueryKey, - id: status.id - }) - } - }, - { - text: t('common:buttons.cancel'), - style: 'default' - } - ]) - } - if (actions[index].id === 'status-delete-edit') { - Alert.alert(t('status.deleteEdit.alert.title'), t('status.deleteEdit.alert.message'), [ - { - text: t('status.deleteEdit.alert.buttons.confirm'), - style: 'destructive', - onPress: async () => { + key: 'status-edit', + item: { + onSelect: async () => { let replyToStatus: Mastodon.Status | undefined = undefined if (status.in_reply_to_id) { replyToStatus = await apiInstance({ @@ -139,87 +75,166 @@ const contextMenuStatus = ({ actions, status, queryKey, rootQueryKey }: Props) = url: `statuses/${status.in_reply_to_id}` }).then(res => res.body) } - mutation - .mutateAsync({ - type: 'deleteItem', - source: 'statuses', + apiInstance<{ + id: Mastodon.Status['id'] + text: NonNullable + spoiler_text: Mastodon.Status['spoiler_text'] + }>({ + method: 'get', + url: `statuses/${status.id}/source` + }).then(res => { + navigation.navigate('Screen-Compose', { + type: 'edit', + incomingStatus: { + ...status, + text: res.body.text, + spoiler_text: res.body.spoiler_text + }, + ...(replyToStatus && { replyToStatus }), queryKey, - id: status.id + rootQueryKey }) - .then(res => { - navigation.navigate('Screen-Compose', { - type: 'deleteEdit', - incomingStatus: res.body as Mastodon.Status, - ...(replyToStatus && { replyToStatus }), - queryKey - }) - }) - } - }, - { - text: t('common:buttons.cancel') - } - ]) - } - if (actions[index].id === 'status-mute') { - mutation.mutate({ - type: 'updateStatusProperty', - queryKey, - rootQueryKey, - id: status.id, - payload: { - property: 'muted', - currentValue: status.muted, - propertyCount: undefined, - countValue: undefined - } - }) - } - if (actions[index].id === 'status-edit') { - let replyToStatus: Mastodon.Status | undefined = undefined - if (status.in_reply_to_id) { - replyToStatus = await apiInstance({ - method: 'get', - url: `statuses/${status.in_reply_to_id}` - }).then(res => res.body) - } - apiInstance<{ - id: Mastodon.Status['id'] - text: NonNullable - spoiler_text: Mastodon.Status['spoiler_text'] - }>({ - method: 'get', - url: `statuses/${status.id}/source` - }).then(res => { - navigation.navigate('Screen-Compose', { - type: 'edit', - incomingStatus: { - ...status, - text: res.body.text, - spoiler_text: res.body.spoiler_text + }) }, - ...(replyToStatus && { replyToStatus }), - queryKey, - rootQueryKey - }) - }) - } - if (actions[index].id === 'status-pin') { - // Also note that reblogs cannot be pinned. - mutation.mutate({ - type: 'updateStatusProperty', - queryKey, - rootQueryKey, - id: status.id, - payload: { - property: 'pinned', - currentValue: status.pinned, - propertyCount: undefined, - countValue: undefined - } - }) - } + disabled: false, + destructive: false, + hidden: !canEditPost + }, + title: t('status.edit.action'), + icon: 'square.and.pencil' + }, + { + key: 'status-delete-edit', + item: { + onSelect: () => + Alert.alert(t('status.deleteEdit.alert.title'), t('status.deleteEdit.alert.message'), [ + { + text: t('status.deleteEdit.alert.buttons.confirm'), + style: 'destructive', + onPress: async () => { + let replyToStatus: Mastodon.Status | undefined = undefined + if (status.in_reply_to_id) { + replyToStatus = await apiInstance({ + method: 'get', + url: `statuses/${status.in_reply_to_id}` + }).then(res => res.body) + } + mutation + .mutateAsync({ + type: 'deleteItem', + source: 'statuses', + queryKey, + id: status.id + }) + .then(res => { + navigation.navigate('Screen-Compose', { + type: 'deleteEdit', + incomingStatus: res.body as Mastodon.Status, + ...(replyToStatus && { replyToStatus }), + queryKey + }) + }) + } + }, + { + text: t('common:buttons.cancel') + } + ]), + disabled: false, + destructive: true, + hidden: false + }, + title: t('status.deleteEdit.action'), + icon: 'pencil.and.outline' + }, + { + key: 'status-delete', + item: { + onSelect: () => + Alert.alert(t('status.delete.alert.title'), t('status.delete.alert.message'), [ + { + text: t('status.delete.alert.buttons.confirm'), + style: 'destructive', + onPress: async () => { + mutation.mutate({ + type: 'deleteItem', + source: 'statuses', + queryKey, + rootQueryKey, + id: status.id + }) + } + }, + { + text: t('common:buttons.cancel'), + style: 'default' + } + ]), + disabled: false, + destructive: true, + hidden: false + }, + title: t('status.delete.action'), + icon: 'trash' + } + ]) + + menus.push([ + { + key: 'status-mute', + item: { + onSelect: () => + mutation.mutate({ + type: 'updateStatusProperty', + queryKey, + rootQueryKey, + id: status.id, + payload: { + property: 'muted', + currentValue: status.muted, + propertyCount: undefined, + countValue: undefined + } + }), + disabled: false, + destructive: false, + hidden: false + }, + title: t('status.mute.action', { + context: (status.muted || false).toString() + }), + icon: status.muted ? 'speaker' : 'speaker.slash' + }, + { + key: 'status-pin', + item: { + onSelect: () => + // Also note that reblogs cannot be pinned. + mutation.mutate({ + type: 'updateStatusProperty', + queryKey, + rootQueryKey, + id: status.id, + payload: { + property: 'pinned', + currentValue: status.pinned, + propertyCount: undefined, + countValue: undefined + } + }), + disabled: false, + destructive: false, + hidden: status.visibility !== 'public' && status.visibility !== 'unlisted' + }, + title: t('status.pin.action', { + context: (status.pinned || false).toString() + }), + icon: status.pinned ? 'pin.slash' : 'pin' + } + ]) } + + return menus } -export default contextMenuStatus +export default menuStatus diff --git a/src/components/Timeline/Default.tsx b/src/components/Timeline/Default.tsx index 6d9ab41d..7660f6a3 100644 --- a/src/components/Timeline/Default.tsx +++ b/src/components/Timeline/Default.tsx @@ -1,10 +1,12 @@ +import menuInstance from '@components/contextMenu/instance' +import menuShare from '@components/contextMenu/share' +import menuStatus from '@components/contextMenu/status' import TimelineActioned from '@components/Timeline/Shared/Actioned' import TimelineActions from '@components/Timeline/Shared/Actions' import TimelineAttachment from '@components/Timeline/Shared/Attachment' import TimelineAvatar from '@components/Timeline/Shared/Avatar' import TimelineCard from '@components/Timeline/Shared/Card' import TimelineContent from '@components/Timeline/Shared/Content' -// @ts-ignore import TimelineHeaderDefault from '@components/Timeline/Shared/HeaderDefault' import TimelinePoll from '@components/Timeline/Shared/Poll' import { useNavigation } from '@react-navigation/native' @@ -18,10 +20,11 @@ import { uniqBy } from 'lodash' import React, { useRef } from 'react' import { Pressable, StyleProp, View, ViewStyle } from 'react-native' import { useSelector } from 'react-redux' -import TimelineContextMenu from './Shared/ContextMenu' +import * as ContextMenu from 'zeego/context-menu' import TimelineFeedback from './Shared/Feedback' import TimelineFiltered, { shouldFilter } from './Shared/Filtered' import TimelineFullConversation from './Shared/FullConversation' +import TimelineHeaderAndroid from './Shared/HeaderAndroid' import TimelineTranslate from './Shared/Translate' export interface Props { @@ -89,8 +92,10 @@ const TimelineDefault: React.FC = ({ /> @@ -149,24 +154,71 @@ const TimelineDefault: React.FC = ({ ) + const mShare = menuShare({ + visibility: actualStatus.visibility, + type: 'status', + url: actualStatus.url || actualStatus.uri, + copiableContent + }) + const mStatus = menuStatus({ status: actualStatus, queryKey, rootQueryKey }) + const mInstance = menuInstance({ status: actualStatus, queryKey, rootQueryKey }) + return disableOnPress ? ( {main()} ) : ( - - {}} - > - {main()} - - + <> + + + {}} + children={main()} + /> + + + + {mShare.map((mGroup, index) => ( + + {mGroup.map(menu => ( + + + + + ))} + + ))} + + {mStatus.map((mGroup, index) => ( + + {mGroup.map(menu => ( + + + + + ))} + + ))} + + {mInstance.map((mGroup, index) => ( + + {mGroup.map(menu => ( + + + + + ))} + + ))} + + + + ) } diff --git a/src/components/Timeline/Notifications.tsx b/src/components/Timeline/Notifications.tsx index 81e2e24b..848896e2 100644 --- a/src/components/Timeline/Notifications.tsx +++ b/src/components/Timeline/Notifications.tsx @@ -1,10 +1,12 @@ +import menuInstance from '@components/contextMenu/instance' +import menuShare from '@components/contextMenu/share' +import menuStatus from '@components/contextMenu/status' import TimelineActioned from '@components/Timeline/Shared/Actioned' import TimelineActions from '@components/Timeline/Shared/Actions' import TimelineAttachment from '@components/Timeline/Shared/Attachment' import TimelineAvatar from '@components/Timeline/Shared/Avatar' import TimelineCard from '@components/Timeline/Shared/Card' import TimelineContent from '@components/Timeline/Shared/Content' -// @ts-ignore import TimelineHeaderNotification from '@components/Timeline/Shared/HeaderNotification' import TimelinePoll from '@components/Timeline/Shared/Poll' import { useNavigation } from '@react-navigation/native' @@ -16,11 +18,12 @@ import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' import { uniqBy } from 'lodash' import React, { useCallback, useRef } from 'react' -import { Platform, Pressable, View } from 'react-native' +import { Pressable, View } from 'react-native' import { useSelector } from 'react-redux' -import TimelineContextMenu from './Shared/ContextMenu' +import * as ContextMenu from 'zeego/context-menu' import TimelineFiltered, { shouldFilter } from './Shared/Filtered' import TimelineFullConversation from './Shared/FullConversation' +import TimelineHeaderAndroid from './Shared/HeaderAndroid' export interface Props { notification: Mastodon.Notification @@ -136,36 +139,68 @@ const TimelineNotifications: React.FC = ({ ) } - return Platform.OS === 'android' ? ( - {}} - > - {main()} - - ) : ( - - {}} - > - {main()} - - + const mShare = menuShare({ + visibility: notification.status?.visibility, + type: 'status', + url: notification.status?.url || notification.status?.uri, + copiableContent + }) + const mStatus = menuStatus({ status: notification.status, queryKey }) + const mInstance = menuInstance({ status: notification.status, queryKey }) + + return ( + <> + + + {}} + children={main()} + /> + + + + {mShare.map((mGroup, index) => ( + + {mGroup.map(menu => ( + + + + + ))} + + ))} + + {mStatus.map((mGroup, index) => ( + + {mGroup.map(menu => ( + + + + + ))} + + ))} + + {mInstance.map((mGroup, index) => ( + + {mGroup.map(menu => ( + + + + + ))} + + ))} + + + + ) } diff --git a/src/components/Timeline/Shared/ContextMenu.tsx b/src/components/Timeline/Shared/ContextMenu.tsx deleted file mode 100644 index f5935e9d..00000000 --- a/src/components/Timeline/Shared/ContextMenu.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import contextMenuAccount from '@components/ContextMenu/account' -import contextMenuInstance from '@components/ContextMenu/instance' -import contextMenuShare from '@components/ContextMenu/share' -import contextMenuStatus from '@components/ContextMenu/status' -import { QueryKeyTimeline } from '@utils/queryHooks/timeline' -import React from 'react' -import { createContext } from 'react' -import { Platform } from 'react-native' -import ContextMenu, { ContextMenuAction, ContextMenuProps } from 'react-native-context-menu-view' - -export interface Props { - copiableContent: React.MutableRefObject<{ - content: string - complete: boolean - }> - status?: Mastodon.Status - queryKey?: QueryKeyTimeline - rootQueryKey?: QueryKeyTimeline -} - -export const ContextMenuContext = createContext([]) - -const TimelineContextMenu: React.FC = ({ - children, - copiableContent, - status, - queryKey, - rootQueryKey, - ...props -}) => { - if (!status || !queryKey || Platform.OS === 'android') { - return <>{children} - } - - const actions: ContextMenuAction[] = [] - - const shareOnPress = - status.visibility !== 'direct' - ? contextMenuShare({ - copiableContent, - actions, - type: 'status', - url: status.url || status.uri - }) - : null - const statusOnPress = contextMenuStatus({ - actions, - status, - queryKey, - rootQueryKey - }) - const accountOnPress = status?.account?.id - ? contextMenuAccount({ - actions, - type: 'status', - queryKey, - rootQueryKey, - id: status.account.id - }) - : null - const instanceOnPress = contextMenuInstance({ - actions, - status, - queryKey, - rootQueryKey - }) - - return ( - - { - for (const on of [shareOnPress, statusOnPress, accountOnPress, instanceOnPress]) { - on && on(index) - } - }} - children={children} - {...props} - /> - - ) -} - -export default TimelineContextMenu diff --git a/src/components/Timeline/Shared/HeaderAndroid.tsx b/src/components/Timeline/Shared/HeaderAndroid.tsx new file mode 100644 index 00000000..eeed11e6 --- /dev/null +++ b/src/components/Timeline/Shared/HeaderAndroid.tsx @@ -0,0 +1,103 @@ +import menuAccount from '@components/contextMenu/account' +import menuInstance from '@components/contextMenu/instance' +import menuShare from '@components/contextMenu/share' +import menuStatus from '@components/contextMenu/status' +import Icon from '@components/Icon' +import { QueryKeyTimeline } from '@utils/queryHooks/timeline' +import { StyleConstants } from '@utils/styles/constants' +import { useTheme } from '@utils/styles/ThemeManager' +import React, { useState } from 'react' +import { Platform, View } from 'react-native' +import * as DropdownMenu from 'zeego/dropdown-menu' + +export interface Props { + queryKey?: QueryKeyTimeline + rootQueryKey?: QueryKeyTimeline + status?: Mastodon.Status +} + +const TimelineHeaderAndroid: React.FC = ({ queryKey, rootQueryKey, status }) => { + if (Platform.OS !== 'android' || !status) return null + + const { colors } = useTheme() + + const [openChange, setOpenChange] = useState(false) + const mShare = menuShare({ + visibility: status.visibility, + type: 'status', + url: status.url || status.uri + }) + const mAccount = menuAccount({ + openChange, + id: status.account.id, + queryKey + }) + const mStatus = menuStatus({ status, queryKey, rootQueryKey }) + const mInstance = menuInstance({ status, queryKey, rootQueryKey }) + + return ( + + {queryKey ? ( + + + + + + + + + {mShare.map((mGroup, index) => ( + + {mGroup.map(menu => ( + + + + + ))} + + ))} + + {mAccount.map((mGroup, index) => ( + + {mGroup.map(menu => ( + + + + + ))} + + ))} + + {mStatus.map((mGroup, index) => ( + + {mGroup.map(menu => ( + + + + + ))} + + ))} + + {mInstance.map((mGroup, index) => ( + + {mGroup.map(menu => ( + + + + + ))} + + ))} + + + ) : null} + + ) +} + +export default TimelineHeaderAndroid diff --git a/src/components/Timeline/Shared/HeaderDefault.android.tsx b/src/components/Timeline/Shared/HeaderDefault.android.tsx deleted file mode 100644 index 9869f450..00000000 --- a/src/components/Timeline/Shared/HeaderDefault.android.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import contextMenuAccount from '@components/ContextMenu/account' -import contextMenuInstance from '@components/ContextMenu/instance' -import contextMenuShare from '@components/ContextMenu/share' -import contextMenuStatus from '@components/ContextMenu/status' -import Icon from '@components/Icon' -import { useActionSheet } from '@expo/react-native-action-sheet' -import { QueryKeyTimeline } from '@utils/queryHooks/timeline' -import { StyleConstants } from '@utils/styles/constants' -import { useTheme } from '@utils/styles/ThemeManager' -import React from 'react' -import { useTranslation } from 'react-i18next' -import { Pressable, View } from 'react-native' -import { ContextMenuAction } from 'react-native-context-menu-view' -import HeaderSharedAccount from './HeaderShared/Account' -import HeaderSharedApplication from './HeaderShared/Application' -import HeaderSharedCreated from './HeaderShared/Created' -import HeaderSharedMuted from './HeaderShared/Muted' -import HeaderSharedVisibility from './HeaderShared/Visibility' - -export interface Props { - queryKey?: QueryKeyTimeline - status: Mastodon.Status - highlighted: boolean -} - -const TimelineHeaderDefault = ({ queryKey, status, highlighted }: Props) => { - if (!queryKey) return null - - const { t } = useTranslation('componentContextMenu') - const { colors } = useTheme() - - const actions: ContextMenuAction[] = [] - - const shareOnPress = - status.visibility !== 'direct' - ? contextMenuShare({ - actions, - type: 'status', - url: status.url || status.uri - }) - : null - const statusOnPress = contextMenuStatus({ - actions, - status, - queryKey - }) - const accountOnPress = contextMenuAccount({ - actions, - type: 'status', - queryKey, - id: status.account.id - }) - const instanceOnPress = contextMenuInstance({ - actions, - status, - queryKey - }) - - const { showActionSheetWithOptions } = useActionSheet() - - return ( - - - - - - - - - - - - {queryKey ? ( - - showActionSheetWithOptions( - { - options: actions.map(action => action.title), - cancelButtonIndex: 999, - destructiveButtonIndex: actions - .map((action, index) => (action.destructive ? index : 999)) - .filter(num => num !== 999) - }, - index => { - if (index !== undefined) { - for (const on of [shareOnPress, statusOnPress, accountOnPress, instanceOnPress]) { - on && on(index) - } - } - } - ) - } - > - - - ) : null} - - ) -} - -export default TimelineHeaderDefault diff --git a/src/components/Timeline/Shared/HeaderDefault.ios.tsx b/src/components/Timeline/Shared/HeaderDefault.ios.tsx deleted file mode 100644 index d2219c87..00000000 --- a/src/components/Timeline/Shared/HeaderDefault.ios.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import Icon from '@components/Icon' -import { QueryKeyTimeline } from '@utils/queryHooks/timeline' -import { StyleConstants } from '@utils/styles/constants' -import { useTheme } from '@utils/styles/ThemeManager' -import React, { useContext } from 'react' -import { useTranslation } from 'react-i18next' -import { Pressable, View } from 'react-native' -import ContextMenu from 'react-native-context-menu-view' -import { ContextMenuContext } from './ContextMenu' -import HeaderSharedAccount from './HeaderShared/Account' -import HeaderSharedApplication from './HeaderShared/Application' -import HeaderSharedCreated from './HeaderShared/Created' -import HeaderSharedMuted from './HeaderShared/Muted' -import HeaderSharedVisibility from './HeaderShared/Visibility' - -export interface Props { - queryKey?: QueryKeyTimeline - status: Mastodon.Status - highlighted: boolean -} - -const TimelineHeaderDefault = ({ queryKey, status, highlighted }: Props) => { - const { t } = useTranslation('componentContextMenu') - const { colors } = useTheme() - - const contextMenuContext = useContext(ContextMenuContext) - - return ( - - - - - - - - - - - - {queryKey ? ( - - {}} - children={ - - } - /> - - ) : null} - - ) -} - -export default TimelineHeaderDefault diff --git a/src/components/Timeline/Shared/HeaderDefault.tsx b/src/components/Timeline/Shared/HeaderDefault.tsx new file mode 100644 index 00000000..7a649026 --- /dev/null +++ b/src/components/Timeline/Shared/HeaderDefault.tsx @@ -0,0 +1,144 @@ +import menuAccount from '@components/contextMenu/account' +import menuInstance from '@components/contextMenu/instance' +import menuShare from '@components/contextMenu/share' +import menuStatus from '@components/contextMenu/status' +import Icon from '@components/Icon' +import { QueryKeyTimeline } from '@utils/queryHooks/timeline' +import { StyleConstants } from '@utils/styles/constants' +import { useTheme } from '@utils/styles/ThemeManager' +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Platform, Pressable, View } from 'react-native' +import * as DropdownMenu from 'zeego/dropdown-menu' +import HeaderSharedAccount from './HeaderShared/Account' +import HeaderSharedApplication from './HeaderShared/Application' +import HeaderSharedCreated from './HeaderShared/Created' +import HeaderSharedMuted from './HeaderShared/Muted' +import HeaderSharedVisibility from './HeaderShared/Visibility' + +export interface Props { + queryKey?: QueryKeyTimeline + rootQueryKey?: QueryKeyTimeline + status: Mastodon.Status + highlighted: boolean + copiableContent: React.MutableRefObject<{ + content: string + complete: boolean + }> +} + +const TimelineHeaderDefault: React.FC = ({ + queryKey, + rootQueryKey, + status, + highlighted, + copiableContent +}) => { + const { colors } = useTheme() + const { t } = useTranslation('componentContextMenu') + + const [openChange, setOpenChange] = useState(false) + const mShare = menuShare({ + visibility: status.visibility, + type: 'status', + url: status.url || status.uri, + copiableContent + }) + const mAccount = menuAccount({ + openChange, + id: status.account.id, + queryKey + }) + const mStatus = menuStatus({ status, queryKey, rootQueryKey }) + const mInstance = menuInstance({ status, queryKey, rootQueryKey }) + + return ( + + + + + + + + + + + + {Platform.OS !== 'android' && queryKey ? ( + + + + + + + + {mShare.map((mGroup, index) => ( + + {mGroup.map(menu => ( + + + + + ))} + + ))} + + {mAccount.map((mGroup, index) => ( + + {mGroup.map(menu => ( + + + + + ))} + + ))} + + {mStatus.map((mGroup, index) => ( + + {mGroup.map(menu => ( + + + + + ))} + + ))} + + {mInstance.map((mGroup, index) => ( + + {mGroup.map(menu => ( + + + + + ))} + + ))} + + + + ) : null} + + ) +} + +export default TimelineHeaderDefault diff --git a/src/components/Timeline/Shared/HeaderNotification.android.tsx b/src/components/Timeline/Shared/HeaderNotification.android.tsx deleted file mode 100644 index de62abd0..00000000 --- a/src/components/Timeline/Shared/HeaderNotification.android.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import contextMenuAccount from '@components/ContextMenu/account' -import contextMenuInstance from '@components/ContextMenu/instance' -import contextMenuShare from '@components/ContextMenu/share' -import contextMenuStatus from '@components/ContextMenu/status' -import Icon from '@components/Icon' -import { RelationshipIncoming, RelationshipOutgoing } from '@components/Relationship' -import { useActionSheet } from '@expo/react-native-action-sheet' -import { QueryKeyTimeline } from '@utils/queryHooks/timeline' -import { StyleConstants } from '@utils/styles/constants' -import { useTheme } from '@utils/styles/ThemeManager' -import React, { useMemo } from 'react' -import { Pressable, View } from 'react-native' -import { ContextMenuAction } from 'react-native-context-menu-view' -import HeaderSharedAccount from './HeaderShared/Account' -import HeaderSharedApplication from './HeaderShared/Application' -import HeaderSharedCreated from './HeaderShared/Created' -import HeaderSharedMuted from './HeaderShared/Muted' -import HeaderSharedVisibility from './HeaderShared/Visibility' - -export interface Props { - queryKey: QueryKeyTimeline - notification: Mastodon.Notification -} - -const TimelineHeaderNotification = ({ queryKey, notification }: Props) => { - const { colors } = useTheme() - - const contextMenuActions: ContextMenuAction[] = [] - const status = notification.status - const shareOnPress = - status && status?.visibility !== 'direct' - ? contextMenuShare({ - actions: contextMenuActions, - type: 'status', - url: status.url || status.uri - }) - : null - const statusOnPress = - status && - contextMenuStatus({ - actions: contextMenuActions, - status: status, - queryKey - }) - const accountOnPress = - status && - contextMenuAccount({ - actions: contextMenuActions, - type: 'status', - queryKey, - id: status.account.id - }) - const instanceOnPress = - status && - contextMenuInstance({ - actions: contextMenuActions, - status: status, - queryKey - }) - - const { showActionSheetWithOptions } = useActionSheet() - - const actions = useMemo(() => { - switch (notification.type) { - case 'follow': - return - case 'follow_request': - return - default: - if (notification.status) { - return ( - - showActionSheetWithOptions( - { - options: contextMenuActions.map(action => action.title), - cancelButtonIndex: 999, - destructiveButtonIndex: contextMenuActions - .map((action, index) => (action.destructive ? index : 999)) - .filter(num => num !== 999) - }, - index => { - if (index !== undefined) { - for (const on of [ - shareOnPress, - statusOnPress, - accountOnPress, - instanceOnPress - ]) { - on && on(index) - } - } - } - ) - } - children={ - - } - /> - ) - } - } - }, [notification.type]) - - return ( - - - - - - {notification.status?.visibility ? ( - - ) : null} - - - - - - - {actions} - - - ) -} - -export default TimelineHeaderNotification diff --git a/src/components/Timeline/Shared/HeaderNotification.ios.tsx b/src/components/Timeline/Shared/HeaderNotification.ios.tsx deleted file mode 100644 index 376029a9..00000000 --- a/src/components/Timeline/Shared/HeaderNotification.ios.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import Icon from '@components/Icon' -import { RelationshipIncoming, RelationshipOutgoing } from '@components/Relationship' -import { QueryKeyTimeline } from '@utils/queryHooks/timeline' -import { StyleConstants } from '@utils/styles/constants' -import { useTheme } from '@utils/styles/ThemeManager' -import React, { useContext, useMemo } from 'react' -import { Pressable, View } from 'react-native' -import ContextMenu from 'react-native-context-menu-view' -import { ContextMenuContext } from './ContextMenu' -import HeaderSharedAccount from './HeaderShared/Account' -import HeaderSharedApplication from './HeaderShared/Application' -import HeaderSharedCreated from './HeaderShared/Created' -import HeaderSharedMuted from './HeaderShared/Muted' -import HeaderSharedVisibility from './HeaderShared/Visibility' - -export interface Props { - queryKey: QueryKeyTimeline - notification: Mastodon.Notification -} - -const TimelineHeaderNotification = ({ notification }: Props) => { - const { colors } = useTheme() - - const contextMenuContext = useContext(ContextMenuContext) - - const actions = useMemo(() => { - switch (notification.type) { - case 'follow': - return - case 'follow_request': - return - default: - if (notification.status) { - return ( - {}} - children={ - - } - /> - } - /> - ) - } - } - }, [notification.type]) - - return ( - - - - - - {notification.status?.visibility ? ( - - ) : null} - - - - - - - {actions} - - - ) -} - -export default TimelineHeaderNotification diff --git a/src/components/Timeline/Shared/HeaderNotification.tsx b/src/components/Timeline/Shared/HeaderNotification.tsx new file mode 100644 index 00000000..4e1b7854 --- /dev/null +++ b/src/components/Timeline/Shared/HeaderNotification.tsx @@ -0,0 +1,163 @@ +import menuAccount from '@components/contextMenu/account' +import menuInstance from '@components/contextMenu/instance' +import menuShare from '@components/contextMenu/share' +import menuStatus from '@components/contextMenu/status' +import Icon from '@components/Icon' +import { RelationshipIncoming, RelationshipOutgoing } from '@components/Relationship' +import { QueryKeyTimeline } from '@utils/queryHooks/timeline' +import { StyleConstants } from '@utils/styles/constants' +import { useTheme } from '@utils/styles/ThemeManager' +import React, { useState } from 'react' +import { Platform, Pressable, View } from 'react-native' +import * as DropdownMenu from 'zeego/dropdown-menu' +import HeaderSharedAccount from './HeaderShared/Account' +import HeaderSharedApplication from './HeaderShared/Application' +import HeaderSharedCreated from './HeaderShared/Created' +import HeaderSharedMuted from './HeaderShared/Muted' +import HeaderSharedVisibility from './HeaderShared/Visibility' + +export interface Props { + queryKey: QueryKeyTimeline + notification: Mastodon.Notification +} + +const TimelineHeaderNotification = ({ queryKey, notification }: Props) => { + const { colors } = useTheme() + + const [openChange, setOpenChange] = useState(false) + const mShare = menuShare({ + visibility: notification.status?.visibility, + type: 'status', + url: notification.status?.url || notification.status?.uri + }) + const mAccount = menuAccount({ + openChange, + id: notification.status?.account.id, + queryKey + }) + const mStatus = menuStatus({ status: notification.status, queryKey }) + const mInstance = menuInstance({ status: notification.status, queryKey }) + + const actions = () => { + switch (notification.type) { + case 'follow': + return + case 'follow_request': + return + default: + if (notification.status) { + return ( + + + + + + + {mShare.map((mGroup, index) => ( + + {mGroup.map(menu => ( + + + + + ))} + + ))} + + {mAccount.map((mGroup, index) => ( + + {mGroup.map(menu => ( + + + + + ))} + + ))} + + {mStatus.map((mGroup, index) => ( + + {mGroup.map(menu => ( + + + + + ))} + + ))} + + {mInstance.map((mGroup, index) => ( + + {mGroup.map(menu => ( + + + + + ))} + + ))} + + + } + /> + ) + } + } + } + + return ( + + + + + + {notification.status?.visibility ? ( + + ) : null} + + + + + + {Platform.OS !== 'android' ? ( + + ) : null} + + ) +} + +export default TimelineHeaderNotification diff --git a/src/components/contextMenu/account.tsx b/src/components/contextMenu/account.tsx new file mode 100644 index 00000000..3e0dc319 --- /dev/null +++ b/src/components/contextMenu/account.tsx @@ -0,0 +1,220 @@ +import haptics from '@components/haptics' +import { displayMessage } from '@components/Message' +import { + QueryKeyRelationship, + useRelationshipMutation, + useRelationshipQuery +} from '@utils/queryHooks/relationship' +import { + MutationVarsTimelineUpdateAccountProperty, + QueryKeyTimeline, + useTimelineMutation +} from '@utils/queryHooks/timeline' +import { getInstanceAccount } from '@utils/slices/instancesSlice' +import { useTheme } from '@utils/styles/ThemeManager' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Platform } from 'react-native' +import { useQueryClient } from 'react-query' +import { useSelector } from 'react-redux' + +const menuAccount = ({ + openChange, + id, + queryKey, + rootQueryKey +}: { + openChange: boolean + id?: Mastodon.Account['id'] + queryKey?: QueryKeyTimeline + rootQueryKey?: QueryKeyTimeline +}): ContextMenu[][] => { + if (!id) return [] + + const { theme } = useTheme() + const { t } = useTranslation('componentContextMenu') + + const menus: ContextMenu[][] = [[]] + + const instanceAccount = useSelector(getInstanceAccount, (prev, next) => prev.id === next.id) + const ownAccount = instanceAccount?.id === id + + const [enabled, setEnabled] = useState(openChange) + useEffect(() => { + if (!ownAccount && enabled === false && openChange === true) { + setEnabled(true) + } + }, [openChange, enabled]) + const { data, isFetching } = useRelationshipQuery({ id, options: { enabled } }) + + const queryClient = useQueryClient() + const timelineMutation = useTimelineMutation({ + onSuccess: (_, params) => { + queryClient.refetchQueries(['Relationship', { id }]) + const theParams = params as MutationVarsTimelineUpdateAccountProperty + displayMessage({ + theme, + type: 'success', + message: t('common:message.success.message', { + function: t(`account.${theParams.payload.property}.action`, { + ...(theParams.payload.property !== 'reports' && { + context: (theParams.payload.currentValue || false).toString() + }) + }) + }) + }) + }, + onError: (err: any, params) => { + const theParams = params as MutationVarsTimelineUpdateAccountProperty + displayMessage({ + theme, + type: 'error', + message: t('common:message.error.message', { + function: t(`account.${theParams.payload.property}.action`, { + ...(theParams.payload.property !== 'reports' && { + context: (theParams.payload.currentValue || false).toString() + }) + }) + }), + ...(err.status && + typeof err.status === 'number' && + err.data && + err.data.error && + typeof err.data.error === 'string' && { + description: err.data.error + }) + }) + }, + onSettled: () => { + queryKey && queryClient.invalidateQueries(queryKey) + rootQueryKey && queryClient.invalidateQueries(rootQueryKey) + } + }) + const queryKeyRelationship: QueryKeyRelationship = ['Relationship', { id }] + const relationshipMutation = useRelationshipMutation({ + onSuccess: (res, { payload: { action } }) => { + haptics('Success') + queryClient.setQueryData(queryKeyRelationship, [res]) + if (action === 'block') { + const queryKey: QueryKeyTimeline = ['Timeline', { page: 'Following' }] + queryClient.invalidateQueries(queryKey) + } + }, + onError: (err: any, { payload: { action } }) => { + displayMessage({ + theme, + type: 'error', + message: t('common:message.error.message', { + function: t(`${action}.function`) + }), + ...(err.status && + typeof err.status === 'number' && + err.data && + err.data.error && + typeof err.data.error === 'string' && { + description: err.data.error + }) + }) + } + }) + + if (!ownAccount && Platform.OS !== 'android') { + menus[0].push({ + key: 'account-following', + item: { + onSelect: () => + data && + relationshipMutation.mutate({ + id, + type: 'outgoing', + payload: { action: 'follow', state: !data?.requested ? data.following : true } + }), + disabled: !data || isFetching, + destructive: false, + hidden: false + }, + title: !data?.requested + ? t('account.following.action', { + context: (data?.following || false).toString() + }) + : t('componentRelationship:button.requested'), + icon: !data?.requested + ? data?.following + ? 'person.badge.minus' + : 'person.badge.plus' + : 'person.badge.minus' + }) + } + if (!ownAccount) { + menus[0].push({ + key: 'account-mute', + item: { + onSelect: () => + timelineMutation.mutate({ + type: 'updateAccountProperty', + queryKey, + id, + payload: { property: 'mute', currentValue: data?.muting } + }), + disabled: Platform.OS !== 'android' ? !data || isFetching : false, + destructive: false, + hidden: false + }, + title: t('account.mute.action', { + context: (data?.muting || false).toString() + }), + icon: data?.muting ? 'eye' : 'eye.slash' + }) + } + + !ownAccount && + menus.push([ + { + key: 'account-block', + item: { + onSelect: () => + timelineMutation.mutate({ + type: 'updateAccountProperty', + queryKey, + id, + payload: { property: 'block', currentValue: data?.blocking } + }), + disabled: Platform.OS !== 'android' ? !data || isFetching : false, + destructive: !data?.blocking, + hidden: false + }, + title: t('account.block.action', { + context: (data?.blocking || false).toString() + }), + icon: data?.blocking ? 'checkmark.circle' : 'xmark.circle' + }, + { + key: 'account-reports', + item: { + onSelect: () => { + timelineMutation.mutate({ + type: 'updateAccountProperty', + queryKey, + id, + payload: { property: 'reports' } + }) + timelineMutation.mutate({ + type: 'updateAccountProperty', + queryKey, + id, + payload: { property: 'block', currentValue: false } + }) + }, + disabled: false, + destructive: true, + hidden: false + }, + title: t('account.reports.action'), + icon: 'flag' + } + ]) + + return menus +} + +export default menuAccount diff --git a/src/components/contextMenu/index.d.ts b/src/components/contextMenu/index.d.ts new file mode 100644 index 00000000..edbf9430 --- /dev/null +++ b/src/components/contextMenu/index.d.ts @@ -0,0 +1,6 @@ +type ContextMenu = { + key: string + item: { onSelect: () => void; disabled: boolean; destructive: boolean; hidden: boolean } + title: string + icon: string +} diff --git a/src/i18n/en/components/contextMenu.json b/src/i18n/en/components/contextMenu.json index 6dbd8ea1..4ce19a8b 100644 --- a/src/i18n/en/components/contextMenu.json +++ b/src/i18n/en/components/contextMenu.json @@ -2,6 +2,10 @@ "accessibilityHint": "Actions for this toot, such as its posted user, toot itself", "account": { "title": "User actions", + "following": { + "action_false": "Follow user", + "action_true": "Unfollow user" + }, "mute": { "action_false": "Mute user", "action_true": "Unmute user" @@ -11,7 +15,7 @@ "action_true": "Unblock user" }, "reports": { - "action": "Report and block" + "action": "Report and block user" } }, "copy": { diff --git a/src/screens/Tabs/Local.tsx b/src/screens/Tabs/Local.tsx index f456b084..7f885001 100644 --- a/src/screens/Tabs/Local.tsx +++ b/src/screens/Tabs/Local.tsx @@ -9,8 +9,7 @@ import { QueryKeyTimeline } from '@utils/queryHooks/timeline' import layoutAnimation from '@utils/styles/layoutAnimation' import React, { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Platform } from 'react-native' -import ContextMenu from 'react-native-context-menu-view' +import * as DropdownMenu from 'zeego/dropdown-menu' import TabShared from './Shared' const Stack = createNativeStackNavigator() @@ -34,31 +33,8 @@ const TabLocal = React.memo( name='Tab-Local-Root' options={{ headerTitle: () => ( - ({ - id: list.id, - title: list.title, - disabled: queryKey[1].page === 'List' && queryKey[1].list === list.id - })) - ] - : undefined - } - onPress={({ nativeEvent: { index } }) => { - lists && index - ? setQueryKey(['Timeline', { page: 'List', list: lists[index - 1].id }]) - : setQueryKey(['Timeline', { page: 'Following' }]) - }} - children={ + + 0} content={ @@ -67,8 +43,43 @@ const TabLocal = React.memo( : t('tabs.local.name') } /> - } - /> + + + + {lists?.length + ? [ + { + key: 'default', + item: { + onSelect: () => setQueryKey(['Timeline', { page: 'Following' }]), + disabled: queryKey[1].page === 'Following', + destructive: false, + hidden: false + }, + title: t('tabs.local.name'), + icon: '' + }, + ...lists?.map(list => ({ + key: list.id, + item: { + onSelect: () => + setQueryKey(['Timeline', { page: 'List', list: list.id }]), + disabled: queryKey[1].page === 'List' && queryKey[1].list === list.id, + destructive: false, + hidden: false + }, + title: list.title, + icon: '' + })) + ].map(menu => ( + + + + + )) + : undefined} + + ), headerRight: () => ( > = ({ + navigation, route: { params: { account } } @@ -24,6 +29,65 @@ const TabSharedAccount: React.FC const { t, i18n } = useTranslation('screenTabs') const { colors, mode } = useTheme() + const mShare = menuShare({ type: 'account', url: account.url }) + const mAccount = menuAccount({ openChange: true, id: account.id }) + useEffect(() => { + navigation.setOptions({ + headerRight: () => { + // const shareOnPress = contextMenuShare({ + // actions, + // type: 'account', + // url: account.url + // }) + // const accountOnPress = contextMenuAccount({ + // actions, + // type: 'account', + // id: account.id + // }) + + return ( + + + {}} + background + /> + + + + {mShare.map((mGroup, index) => ( + + {mGroup.map(menu => ( + + + + + ))} + + ))} + + {mAccount.map((mGroup, index) => ( + + {mGroup.map(menu => ( + + + + + ))} + + ))} + + + ) + } + }) + }, []) + const { data } = useAccountQuery({ id: account.id }) const scrollY = useSharedValue(0) @@ -77,7 +141,13 @@ const TabSharedAccount: React.FC paddingHorizontal: StyleConstants.Spacing.Global.PagePadding }} > - + {t('shared.account.suspended')} diff --git a/src/screens/Tabs/Shared/index.tsx b/src/screens/Tabs/Shared/index.tsx index a98e71c9..46a7e25b 100644 --- a/src/screens/Tabs/Shared/index.tsx +++ b/src/screens/Tabs/Shared/index.tsx @@ -1,6 +1,4 @@ -import contextMenuAccount from '@components/ContextMenu/account' -import contextMenuShare from '@components/ContextMenu/share' -import { HeaderCenter, HeaderLeft, HeaderRight } from '@components/Header' +import { HeaderCenter, HeaderLeft } from '@components/Header' import { ParseEmojis } from '@components/Parse' import CustomText from '@components/Text' import { createNativeStackNavigator } from '@react-navigation/native-stack' @@ -18,7 +16,6 @@ import { debounce } from 'lodash' import React from 'react' import { Trans, useTranslation } from 'react-i18next' import { Platform, TextInput, View } from 'react-native' -import ContextMenu, { ContextMenuAction } from 'react-native-context-menu-view' const TabShared = ({ Stack }: { Stack: ReturnType }) => { const { colors, mode } = useTheme() @@ -46,42 +43,7 @@ const TabShared = ({ Stack }: { Stack: ReturnType navigation.goBack()} background />, - headerRight: () => { - const actions: ContextMenuAction[] = [] - - const shareOnPress = contextMenuShare({ - actions, - type: 'account', - url: account.url - }) - const accountOnPress = contextMenuAccount({ - actions, - type: 'account', - id: account.id - }) - - return ( - { - shareOnPress(index) - accountOnPress(index) - }} - dropdownMenuMode - > - {}} - background - /> - - ) - } + headerLeft: () => navigation.goBack()} background /> } }} /> diff --git a/yarn.lock b/yarn.lock index 9bfa0ac7..b50bb865 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10793,10 +10793,6 @@ react-native-codegen@^0.70.6: jscodeshift "^0.13.1" nullthrows "^1.1.1" -react-native-context-menu-view@xmflsct/react-native-context-menu-view: - version "1.5.4" - resolved "https://codeload.github.com/xmflsct/react-native-context-menu-view/tar.gz/bff5773d318970cd67b5cf114d4bec1e200178eb" - react-native-fast-image@^8.6.3: version "8.6.3" resolved "https://registry.yarnpkg.com/react-native-fast-image/-/react-native-fast-image-8.6.3.tgz#6edc3f9190092a909d636d93eecbcc54a8822255"