Moving to using zeego

This commit is contained in:
xmflsct 2022-12-03 01:08:38 +01:00
parent f619d1bb6a
commit a3a0bf523f
23 changed files with 1179 additions and 1114 deletions

View File

@ -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

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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<NativeStackNavigationProp<RootStackParamList, 'Screen-Tabs'>>()
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<Mastodon.Status>({
@ -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<Mastodon.Status['text']>
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<Mastodon.Status>({
method: 'get',
url: `statuses/${status.in_reply_to_id}`
}).then(res => res.body)
}
apiInstance<{
id: Mastodon.Status['id']
text: NonNullable<Mastodon.Status['text']>
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<Mastodon.Status>({
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

View File

@ -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<Props> = ({
/>
<TimelineHeaderDefault
queryKey={disableOnPress ? undefined : queryKey}
rootQueryKey={rootQueryKey}
status={actualStatus}
highlighted={highlighted}
copiableContent={copiableContent}
/>
</View>
@ -149,24 +154,71 @@ const TimelineDefault: React.FC<Props> = ({
</>
)
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 ? (
<View style={mainStyle}>{main()}</View>
) : (
<TimelineContextMenu
copiableContent={copiableContent}
status={actualStatus}
queryKey={queryKey}
rootQueryKey={rootQueryKey}
>
<Pressable
accessible={highlighted ? false : true}
style={mainStyle}
onPress={onPress}
onLongPress={() => {}}
>
{main()}
</Pressable>
</TimelineContextMenu>
<>
<ContextMenu.Root>
<ContextMenu.Trigger>
<Pressable
accessible={highlighted ? false : true}
style={mainStyle}
onPress={onPress}
onLongPress={() => {}}
children={main()}
/>
</ContextMenu.Trigger>
<ContextMenu.Content>
{mShare.map((mGroup, index) => (
<ContextMenu.Group key={index}>
{mGroup.map(menu => (
<ContextMenu.Item key={menu.key} {...menu.item}>
<ContextMenu.ItemTitle children={menu.title} />
<ContextMenu.ItemIcon iosIconName={menu.icon} />
</ContextMenu.Item>
))}
</ContextMenu.Group>
))}
{mStatus.map((mGroup, index) => (
<ContextMenu.Group key={index}>
{mGroup.map(menu => (
<ContextMenu.Item key={menu.key} {...menu.item}>
<ContextMenu.ItemTitle children={menu.title} />
<ContextMenu.ItemIcon iosIconName={menu.icon} />
</ContextMenu.Item>
))}
</ContextMenu.Group>
))}
{mInstance.map((mGroup, index) => (
<ContextMenu.Group key={index}>
{mGroup.map(menu => (
<ContextMenu.Item key={menu.key} {...menu.item}>
<ContextMenu.ItemTitle children={menu.title} />
<ContextMenu.ItemIcon iosIconName={menu.icon} />
</ContextMenu.Item>
))}
</ContextMenu.Group>
))}
</ContextMenu.Content>
</ContextMenu.Root>
<TimelineHeaderAndroid
queryKey={disableOnPress ? undefined : queryKey}
rootQueryKey={rootQueryKey}
status={actualStatus}
/>
</>
)
}

View File

@ -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<Props> = ({
)
}
return Platform.OS === 'android' ? (
<Pressable
style={{
padding: StyleConstants.Spacing.Global.PagePadding,
backgroundColor: colors.backgroundDefault,
paddingBottom: notification.status ? 0 : StyleConstants.Spacing.Global.PagePadding
}}
onPress={onPress}
onLongPress={() => {}}
>
{main()}
</Pressable>
) : (
<TimelineContextMenu
copiableContent={copiableContent}
status={notification.status}
queryKey={queryKey}
>
<Pressable
style={{
padding: StyleConstants.Spacing.Global.PagePadding,
backgroundColor: colors.backgroundDefault,
paddingBottom: notification.status ? 0 : StyleConstants.Spacing.Global.PagePadding
}}
onPress={onPress}
onLongPress={() => {}}
>
{main()}
</Pressable>
</TimelineContextMenu>
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 (
<>
<ContextMenu.Root>
<ContextMenu.Trigger>
<Pressable
style={{
padding: StyleConstants.Spacing.Global.PagePadding,
backgroundColor: colors.backgroundDefault,
paddingBottom: notification.status ? 0 : StyleConstants.Spacing.Global.PagePadding
}}
onPress={onPress}
onLongPress={() => {}}
children={main()}
/>
</ContextMenu.Trigger>
<ContextMenu.Content>
{mShare.map((mGroup, index) => (
<ContextMenu.Group key={index}>
{mGroup.map(menu => (
<ContextMenu.Item key={menu.key} {...menu.item}>
<ContextMenu.ItemTitle children={menu.title} />
<ContextMenu.ItemIcon iosIconName={menu.icon} />
</ContextMenu.Item>
))}
</ContextMenu.Group>
))}
{mStatus.map((mGroup, index) => (
<ContextMenu.Group key={index}>
{mGroup.map(menu => (
<ContextMenu.Item key={menu.key} {...menu.item}>
<ContextMenu.ItemTitle children={menu.title} />
<ContextMenu.ItemIcon iosIconName={menu.icon} />
</ContextMenu.Item>
))}
</ContextMenu.Group>
))}
{mInstance.map((mGroup, index) => (
<ContextMenu.Group key={index}>
{mGroup.map(menu => (
<ContextMenu.Item key={menu.key} {...menu.item}>
<ContextMenu.ItemTitle children={menu.title} />
<ContextMenu.ItemIcon iosIconName={menu.icon} />
</ContextMenu.Item>
))}
</ContextMenu.Group>
))}
</ContextMenu.Content>
</ContextMenu.Root>
<TimelineHeaderAndroid queryKey={queryKey} status={notification.status} />
</>
)
}

View File

@ -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<ContextMenuAction[]>([])
const TimelineContextMenu: React.FC<Props & ContextMenuProps> = ({
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 (
<ContextMenuContext.Provider value={actions}>
<ContextMenu
actions={actions}
onPress={({ nativeEvent: { index } }) => {
for (const on of [shareOnPress, statusOnPress, accountOnPress, instanceOnPress]) {
on && on(index)
}
}}
children={children}
{...props}
/>
</ContextMenuContext.Provider>
)
}
export default TimelineContextMenu

View File

@ -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<Props> = ({ 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 (
<View style={{ position: 'absolute', top: 0, right: 0 }}>
{queryKey ? (
<DropdownMenu.Root onOpenChange={setOpenChange}>
<DropdownMenu.Trigger>
<View style={{ padding: StyleConstants.Spacing.L }}>
<Icon
name='MoreHorizontal'
color={colors.secondary}
size={StyleConstants.Font.Size.L}
/>
</View>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{mShare.map((mGroup, index) => (
<DropdownMenu.Group key={index}>
{mGroup.map(menu => (
<DropdownMenu.Item key={menu.key} {...menu.item}>
<DropdownMenu.ItemTitle children={menu.title} />
<DropdownMenu.ItemIcon iosIconName={menu.icon} />
</DropdownMenu.Item>
))}
</DropdownMenu.Group>
))}
{mAccount.map((mGroup, index) => (
<DropdownMenu.Group key={index}>
{mGroup.map(menu => (
<DropdownMenu.Item key={menu.key} {...menu.item}>
<DropdownMenu.ItemTitle children={menu.title} />
<DropdownMenu.ItemIcon iosIconName={menu.icon} />
</DropdownMenu.Item>
))}
</DropdownMenu.Group>
))}
{mStatus.map((mGroup, index) => (
<DropdownMenu.Group key={index}>
{mGroup.map(menu => (
<DropdownMenu.Item key={menu.key} {...menu.item}>
<DropdownMenu.ItemTitle children={menu.title} />
<DropdownMenu.ItemIcon iosIconName={menu.icon} />
</DropdownMenu.Item>
))}
</DropdownMenu.Group>
))}
{mInstance.map((mGroup, index) => (
<DropdownMenu.Group key={index}>
{mGroup.map(menu => (
<DropdownMenu.Item key={menu.key} {...menu.item}>
<DropdownMenu.ItemTitle children={menu.title} />
<DropdownMenu.ItemIcon iosIconName={menu.icon} />
</DropdownMenu.Item>
))}
</DropdownMenu.Group>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
) : null}
</View>
)
}
export default TimelineHeaderAndroid

View File

@ -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 (
<View style={{ flex: 1, flexDirection: 'row' }}>
<View style={{ flex: 7 }}>
<HeaderSharedAccount account={status.account} />
<View
style={{
flexDirection: 'row',
alignItems: 'center',
marginTop: StyleConstants.Spacing.XS,
marginBottom: StyleConstants.Spacing.S
}}
>
<HeaderSharedCreated
created_at={status.created_at}
edited_at={status.edited_at}
highlighted={highlighted}
/>
<HeaderSharedVisibility visibility={status.visibility} />
<HeaderSharedMuted muted={status.muted} />
<HeaderSharedApplication application={status.application} />
</View>
</View>
{queryKey ? (
<Pressable
accessibilityHint={t('accessibilityHint')}
style={{ flex: 1, flexBasis: StyleConstants.Font.Size.L }}
onPress={() =>
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)
}
}
}
)
}
>
<Icon name='MoreHorizontal' color={colors.secondary} size={StyleConstants.Font.Size.L} />
</Pressable>
) : null}
</View>
)
}
export default TimelineHeaderDefault

View File

@ -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 (
<View style={{ flex: 1, flexDirection: 'row' }}>
<View style={{ flex: 7 }}>
<HeaderSharedAccount account={status.account} />
<View
style={{
flexDirection: 'row',
alignItems: 'center',
marginTop: StyleConstants.Spacing.XS,
marginBottom: StyleConstants.Spacing.S
}}
>
<HeaderSharedCreated
created_at={status.created_at}
edited_at={status.edited_at}
highlighted={highlighted}
/>
<HeaderSharedVisibility visibility={status.visibility} />
<HeaderSharedMuted muted={status.muted} />
<HeaderSharedApplication application={status.application} />
</View>
</View>
{queryKey ? (
<Pressable
accessibilityHint={t('accessibilityHint')}
style={{ flex: 1, flexBasis: StyleConstants.Font.Size.L }}
>
<ContextMenu
style={{ flex: 1, alignItems: 'center' }}
dropdownMenuMode
actions={contextMenuContext}
onPress={() => {}}
children={
<Icon
name='MoreHorizontal'
color={colors.secondary}
size={StyleConstants.Font.Size.L}
/>
}
/>
</Pressable>
) : null}
</View>
)
}
export default TimelineHeaderDefault

View File

@ -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<Props> = ({
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 (
<View style={{ flex: 1, flexDirection: 'row' }}>
<View style={{ flex: 7 }}>
<HeaderSharedAccount account={status.account} />
<View
style={{
flexDirection: 'row',
alignItems: 'center',
marginTop: StyleConstants.Spacing.XS,
marginBottom: StyleConstants.Spacing.S
}}
>
<HeaderSharedCreated
created_at={status.created_at}
edited_at={status.edited_at}
highlighted={highlighted}
/>
<HeaderSharedVisibility visibility={status.visibility} />
<HeaderSharedMuted muted={status.muted} />
<HeaderSharedApplication application={status.application} />
</View>
</View>
{Platform.OS !== 'android' && queryKey ? (
<Pressable
accessibilityHint={t('accessibilityHint')}
style={{ flex: 1, alignItems: 'center' }}
>
<DropdownMenu.Root onOpenChange={setOpenChange}>
<DropdownMenu.Trigger>
<Icon
name='MoreHorizontal'
color={colors.secondary}
size={StyleConstants.Font.Size.L}
/>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{mShare.map((mGroup, index) => (
<DropdownMenu.Group key={index}>
{mGroup.map(menu => (
<DropdownMenu.Item key={menu.key} {...menu.item}>
<DropdownMenu.ItemTitle children={menu.title} />
<DropdownMenu.ItemIcon iosIconName={menu.icon} />
</DropdownMenu.Item>
))}
</DropdownMenu.Group>
))}
{mAccount.map((mGroup, index) => (
<DropdownMenu.Group key={index}>
{mGroup.map(menu => (
<DropdownMenu.Item key={menu.key} {...menu.item}>
<DropdownMenu.ItemTitle children={menu.title} />
<DropdownMenu.ItemIcon iosIconName={menu.icon} />
</DropdownMenu.Item>
))}
</DropdownMenu.Group>
))}
{mStatus.map((mGroup, index) => (
<DropdownMenu.Group key={index}>
{mGroup.map(menu => (
<DropdownMenu.Item key={menu.key} {...menu.item}>
<DropdownMenu.ItemTitle children={menu.title} />
<DropdownMenu.ItemIcon iosIconName={menu.icon} />
</DropdownMenu.Item>
))}
</DropdownMenu.Group>
))}
{mInstance.map((mGroup, index) => (
<DropdownMenu.Group key={index}>
{mGroup.map(menu => (
<DropdownMenu.Item key={menu.key} {...menu.item}>
<DropdownMenu.ItemTitle children={menu.title} />
<DropdownMenu.ItemIcon iosIconName={menu.icon} />
</DropdownMenu.Item>
))}
</DropdownMenu.Group>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</Pressable>
) : null}
</View>
)
}
export default TimelineHeaderDefault

View File

@ -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 <RelationshipOutgoing id={notification.account.id} />
case 'follow_request':
return <RelationshipIncoming id={notification.account.id} />
default:
if (notification.status) {
return (
<Pressable
style={{ flex: 1, flexBasis: StyleConstants.Font.Size.L }}
onPress={() =>
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={
<Icon
name='MoreHorizontal'
color={colors.secondary}
size={StyleConstants.Font.Size.L}
/>
}
/>
)
}
}
}, [notification.type])
return (
<View style={{ flex: 1, flexDirection: 'row' }}>
<View
style={{
flex: notification.type === 'follow' || notification.type === 'follow_request' ? 1 : 4
}}
>
<HeaderSharedAccount
account={notification.status ? notification.status.account : notification.account}
{...((notification.type === 'follow' || notification.type === 'follow_request') && {
withoutName: true
})}
/>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
marginTop: StyleConstants.Spacing.XS,
marginBottom: StyleConstants.Spacing.S
}}
>
<HeaderSharedCreated
created_at={notification.status?.created_at || notification.created_at}
edited_at={notification.status?.edited_at}
/>
{notification.status?.visibility ? (
<HeaderSharedVisibility visibility={notification.status.visibility} />
) : null}
<HeaderSharedMuted muted={notification.status?.muted} />
<HeaderSharedApplication application={notification.status?.application} />
</View>
</View>
<View
style={[
{ marginLeft: StyleConstants.Spacing.M },
notification.type === 'follow' || notification.type === 'follow_request'
? { flexShrink: 1 }
: { flex: 1 }
]}
>
{actions}
</View>
</View>
)
}
export default TimelineHeaderNotification

View File

@ -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 <RelationshipOutgoing id={notification.account.id} />
case 'follow_request':
return <RelationshipIncoming id={notification.account.id} />
default:
if (notification.status) {
return (
<Pressable
style={{ flex: 1, flexBasis: StyleConstants.Font.Size.L }}
children={
<ContextMenu
style={{ flex: 1, alignItems: 'center' }}
dropdownMenuMode
actions={contextMenuContext}
onPress={() => {}}
children={
<Icon
name='MoreHorizontal'
color={colors.secondary}
size={StyleConstants.Font.Size.L}
/>
}
/>
}
/>
)
}
}
}, [notification.type])
return (
<View style={{ flex: 1, flexDirection: 'row' }}>
<View
style={{
flex: notification.type === 'follow' || notification.type === 'follow_request' ? 1 : 4
}}
>
<HeaderSharedAccount
account={notification.status ? notification.status.account : notification.account}
{...((notification.type === 'follow' || notification.type === 'follow_request') && {
withoutName: true
})}
/>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
marginTop: StyleConstants.Spacing.XS,
marginBottom: StyleConstants.Spacing.S
}}
>
<HeaderSharedCreated
created_at={notification.status?.created_at || notification.created_at}
edited_at={notification.status?.edited_at}
/>
{notification.status?.visibility ? (
<HeaderSharedVisibility visibility={notification.status.visibility} />
) : null}
<HeaderSharedMuted muted={notification.status?.muted} />
<HeaderSharedApplication application={notification.status?.application} />
</View>
</View>
<View
style={[
{ marginLeft: StyleConstants.Spacing.M },
notification.type === 'follow' || notification.type === 'follow_request'
? { flexShrink: 1 }
: { flex: 1 }
]}
>
{actions}
</View>
</View>
)
}
export default TimelineHeaderNotification

View File

@ -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 <RelationshipOutgoing id={notification.account.id} />
case 'follow_request':
return <RelationshipIncoming id={notification.account.id} />
default:
if (notification.status) {
return (
<Pressable
style={{ flex: 1, alignItems: 'center' }}
children={
<DropdownMenu.Root onOpenChange={setOpenChange}>
<DropdownMenu.Trigger>
<Icon
name='MoreHorizontal'
color={colors.secondary}
size={StyleConstants.Font.Size.L}
/>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{mShare.map((mGroup, index) => (
<DropdownMenu.Group key={index}>
{mGroup.map(menu => (
<DropdownMenu.Item key={menu.key} {...menu.item}>
<DropdownMenu.ItemTitle children={menu.title} />
<DropdownMenu.ItemIcon iosIconName={menu.icon} />
</DropdownMenu.Item>
))}
</DropdownMenu.Group>
))}
{mAccount.map((mGroup, index) => (
<DropdownMenu.Group key={index}>
{mGroup.map(menu => (
<DropdownMenu.Item key={menu.key} {...menu.item}>
<DropdownMenu.ItemTitle children={menu.title} />
<DropdownMenu.ItemIcon iosIconName={menu.icon} />
</DropdownMenu.Item>
))}
</DropdownMenu.Group>
))}
{mStatus.map((mGroup, index) => (
<DropdownMenu.Group key={index}>
{mGroup.map(menu => (
<DropdownMenu.Item key={menu.key} {...menu.item}>
<DropdownMenu.ItemTitle children={menu.title} />
<DropdownMenu.ItemIcon iosIconName={menu.icon} />
</DropdownMenu.Item>
))}
</DropdownMenu.Group>
))}
{mInstance.map((mGroup, index) => (
<DropdownMenu.Group key={index}>
{mGroup.map(menu => (
<DropdownMenu.Item key={menu.key} {...menu.item}>
<DropdownMenu.ItemTitle children={menu.title} />
<DropdownMenu.ItemIcon iosIconName={menu.icon} />
</DropdownMenu.Item>
))}
</DropdownMenu.Group>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
}
/>
)
}
}
}
return (
<View style={{ flex: 1, flexDirection: 'row' }}>
<View
style={{
flex: notification.type === 'follow' || notification.type === 'follow_request' ? 1 : 4
}}
>
<HeaderSharedAccount
account={notification.status ? notification.status.account : notification.account}
{...((notification.type === 'follow' || notification.type === 'follow_request') && {
withoutName: true
})}
/>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
marginTop: StyleConstants.Spacing.XS,
marginBottom: StyleConstants.Spacing.S
}}
>
<HeaderSharedCreated
created_at={notification.status?.created_at || notification.created_at}
edited_at={notification.status?.edited_at}
/>
{notification.status?.visibility ? (
<HeaderSharedVisibility visibility={notification.status.visibility} />
) : null}
<HeaderSharedMuted muted={notification.status?.muted} />
<HeaderSharedApplication application={notification.status?.application} />
</View>
</View>
{Platform.OS !== 'android' ? (
<View
style={[
{ marginLeft: StyleConstants.Spacing.M },
notification.type === 'follow' || notification.type === 'follow_request'
? { flexShrink: 1 }
: { flex: 1 }
]}
children={actions()}
/>
) : null}
</View>
)
}
export default TimelineHeaderNotification

View File

@ -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<Mastodon.Relationship[]>(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

6
src/components/contextMenu/index.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
type ContextMenu = {
key: string
item: { onSelect: () => void; disabled: boolean; destructive: boolean; hidden: boolean }
title: string
icon: string
}

View File

@ -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": {

View File

@ -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<TabLocalStackParamList>()
@ -34,31 +33,8 @@ const TabLocal = React.memo(
name='Tab-Local-Root'
options={{
headerTitle: () => (
<ContextMenu
dropdownMenuMode
style={{ maxWidth: '80%', flex: Platform.OS === 'android' ? 1 : undefined }}
actions={
lists?.length
? [
{
id: '',
title: t('tabs.local.name'),
disabled: queryKey[1].page === 'Following'
},
...lists?.map(list => ({
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={
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<HeaderCenter
dropdown={(lists?.length ?? 0) > 0}
content={
@ -67,8 +43,43 @@ const TabLocal = React.memo(
: t('tabs.local.name')
}
/>
}
/>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{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 => (
<DropdownMenu.Item key={menu.key} {...menu.item}>
<DropdownMenu.ItemTitle children={menu.title} />
<DropdownMenu.ItemIcon iosIconName={menu.icon} />
</DropdownMenu.Item>
))
: undefined}
</DropdownMenu.Content>
</DropdownMenu.Root>
),
headerRight: () => (
<HeaderRight

View File

@ -1,3 +1,6 @@
import menuAccount from '@components/contextMenu/account'
import menuShare from '@components/contextMenu/share'
import { HeaderRight } from '@components/Header'
import Timeline from '@components/Timeline'
import TimelineDefault from '@components/Timeline/Default'
import SegmentedControl from '@react-native-community/segmented-control'
@ -11,12 +14,14 @@ import { useTranslation } from 'react-i18next'
import { StyleSheet, Text, View } from 'react-native'
import { useSharedValue } from 'react-native-reanimated'
import { useIsFetching } from 'react-query'
import * as DropdownMenu from 'zeego/dropdown-menu'
import AccountAttachments from './Account/Attachments'
import AccountHeader from './Account/Header'
import AccountInformation from './Account/Information'
import AccountNav from './Account/Nav'
const TabSharedAccount: React.FC<TabSharedStackScreenProps<'Tab-Shared-Account'>> = ({
navigation,
route: {
params: { account }
}
@ -24,6 +29,65 @@ const TabSharedAccount: React.FC<TabSharedStackScreenProps<'Tab-Shared-Account'>
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 (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<HeaderRight
accessibilityLabel={t('shared.account.actions.accessibilityLabel', {
user: account.acct
})}
accessibilityHint={t('shared.account.actions.accessibilityHint')}
content='MoreHorizontal'
onPress={() => {}}
background
/>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{mShare.map((mGroup, index) => (
<DropdownMenu.Group key={index}>
{mGroup.map(menu => (
<DropdownMenu.Item key={menu.key} {...menu.item}>
<DropdownMenu.ItemTitle children={menu.title} />
<DropdownMenu.ItemIcon iosIconName={menu.icon} />
</DropdownMenu.Item>
))}
</DropdownMenu.Group>
))}
{mAccount.map((mGroup, index) => (
<DropdownMenu.Group key={index}>
{mGroup.map(menu => (
<DropdownMenu.Item key={menu.key} {...menu.item}>
<DropdownMenu.ItemTitle children={menu.title} />
<DropdownMenu.ItemIcon iosIconName={menu.icon} />
</DropdownMenu.Item>
))}
</DropdownMenu.Group>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
)
}
})
}, [])
const { data } = useAccountQuery({ id: account.id })
const scrollY = useSharedValue(0)
@ -77,7 +141,13 @@ const TabSharedAccount: React.FC<TabSharedStackScreenProps<'Tab-Shared-Account'>
paddingHorizontal: StyleConstants.Spacing.Global.PagePadding
}}
>
<Text style={{ ...StyleConstants.FontStyle.M, color: colors.secondary, textAlign: 'center' }}>
<Text
style={{
...StyleConstants.FontStyle.M,
color: colors.secondary,
textAlign: 'center'
}}
>
{t('shared.account.suspended')}
</Text>
</View>

View File

@ -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<typeof createNativeStackNavigator> }) => {
const { colors, mode } = useTheme()
@ -46,42 +43,7 @@ const TabShared = ({ Stack }: { Stack: ReturnType<typeof createNativeStackNaviga
backgroundColor: `rgba(255, 255, 255, 0)`
},
title: '',
headerLeft: () => <HeaderLeft onPress={() => 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 (
<ContextMenu
actions={actions}
onPress={({ nativeEvent: { index } }) => {
shareOnPress(index)
accountOnPress(index)
}}
dropdownMenuMode
>
<HeaderRight
accessibilityLabel={t('shared.account.actions.accessibilityLabel', {
user: account.acct
})}
accessibilityHint={t('shared.account.actions.accessibilityHint')}
content='MoreHorizontal'
onPress={() => {}}
background
/>
</ContextMenu>
)
}
headerLeft: () => <HeaderLeft onPress={() => navigation.goBack()} background />
}
}}
/>

View File

@ -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"