import { PlatformPressable } from '@react-navigation/elements'
import { StackActions, StaticScreenProps, useNavigation } from '@react-navigation/native'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { isNil, omitBy } from 'lodash'
import React, { useCallback } from 'react'
import { Alert, Platform, StyleSheet, View } from 'react-native'
import { Text } from '../components'
import { useReactionStyles } from '../components/conversation/message_reaction'
import FormSheet, { getFormSheetScreenOptions } from '../components/primitive/form_sheet'
import { useCreateAndroidRippleColor, useFontScale, useTheme } from '../hooks'
import { useApiClient } from '../hooks/use_api_client'
import { useConversation } from '../hooks/use_conversation'
import { useConversationMessages } from '../hooks/use_conversation_messages'
import { availableFeatures, useFeatures } from '../hooks/use_features'
import { useMessageReactionToggle } from '../hooks/use_message_reaction_toggle'
import { MessageResource } from '../types'
import { ReactionCountResource } from '../types/resources/reaction'
import { REACTION_EMOJIS } from '../utils'
import {
applyMessageDeletionToCaches,
getThreadedMessagesQueryKey,
} from '../utils/cache/messages_cache'
import { Clipboard, Haptic } from '../utils/native_adapters'
export const MessageActionsScreenOptions = getFormSheetScreenOptions({
sheetAllowedDetents: [0.5],
headerTitle: 'Message actions',
})
export type MessageActionsScreenProps = StaticScreenProps<{
message_id: string
reply_root_author_name?: string
conversation_id: number
canDeleteNonAuthoredMessages?: boolean
inReplyScreen?: boolean
isSystemMessage?: boolean
}>
export function MessageActionsScreen({ route }: MessageActionsScreenProps) {
const {
conversation_id,
message_id,
canDeleteNonAuthoredMessages,
inReplyScreen,
reply_root_author_name,
isSystemMessage,
} = route.params
const { messages, refetch } = useConversationMessages(
{ conversation_id },
{ refetchOnMount: false }
)
const { data: conversation } = useConversation({ conversation_id }, { refetchOnMount: false })
const message = messages.find(m => m.id === message_id)
if (!message) return null
return (
)
}
function MessageActionsScreenContent({
message,
conversation_id,
conversationDisabled,
canDeleteNonAuthoredMessages,
refetchMessages,
inReplyScreen,
replyRootAuthorName,
isSystemMessage,
}: {
message: MessageResource
conversation_id: number
conversationDisabled: boolean
canDeleteNonAuthoredMessages: boolean
refetchMessages: () => void
inReplyScreen?: boolean
replyRootAuthorName?: string
isSystemMessage?: boolean
}) {
const navigation = useNavigation()
const apiClient = useApiClient()
const queryClient = useQueryClient()
const styles = useStyles()
const { featureEnabled } = useFeatures()
const expandedLink = message.attachments.find(attachment => attachment.type === 'ExpandedLink')
const hasExpandedLink = !!expandedLink
const myReactions = message?.reactionCounts
.filter(reaction => reaction.mine)
.map(reaction => reaction.value)
const availableReactions = Object.entries(REACTION_EMOJIS).map(([value, emoji]) => {
return {
value: value as ReactionCountResource['value'],
emoji,
mine: myReactions?.includes(value as ReactionCountResource['value']),
}
})
const attachmentForCopy = (): string => {
const giphyTitleLink = message?.attachments.find(
attachment => attachment.type === 'giphy'
)?.titleLink
if (giphyTitleLink) return giphyTitleLink
return ''
}
const handleReplyPress = useCallback(() => {
navigation.goBack()
// Waits for the modal to be dismissed before pushing the reply screen
requestAnimationFrame(() => {
navigation.navigate('ConversationReply', {
conversation_id,
reply_root_id: message.replyRootId || message.id,
reply_root_author_name: replyRootAuthorName,
})
})
}, [navigation, conversation_id, message.id, message.replyRootId, replyRootAuthorName])
const handleCopyPress = async () => {
const textToCopy = message?.text || attachmentForCopy()
if (textToCopy) {
await Clipboard.setStringAsync(textToCopy)
Haptic.notificationSuccess()
}
navigation.goBack()
}
const handleReportPress = useCallback(() => {
Haptic.impactLight()
navigation.navigate('MessageReport', {
conversation_id,
message_id: message.id,
})
}, [navigation, conversation_id, message.id])
const { handleReactionToggle, isPending } = useMessageReactionToggle({
conversation_id,
message,
})
const deleteMessage = useCallback(() => {
const url = `/me/conversations/${conversation_id}/messages/${message.id}/`
return apiClient.chat.delete({ url })
}, [apiClient, conversation_id, message.id])
const { mutate: handleDeleteMessage } = useMutation({
mutationFn: deleteMessage,
mutationKey: ['deleteMessage', message.id],
onSuccess: () => {
applyMessageDeletionToCaches(queryClient, conversation_id, message)
if (message.replyRootId) {
queryClient.invalidateQueries({
queryKey: getThreadedMessagesQueryKey(conversation_id, message.replyRootId),
})
}
refetchMessages()
Haptic.notificationSuccess()
},
onError: () => {
Alert.alert('Oops', 'We were unable to delete this message. Please try again.')
},
})
const handleDeleteConfirm = useCallback(() => {
Alert.alert('Delete message', 'Are you sure you want to permanently delete this message?', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: () => {
handleDeleteMessage()
navigation.goBack()
},
},
])
}, [handleDeleteMessage, navigation])
const handleEditPress = useCallback(() => {
const state = navigation.getState?.()
const targetRouteName = inReplyScreen ? 'ConversationReply' : 'Conversation'
const targetRoute = state?.routes?.find(r => r.name === targetRouteName)
const params = omitBy(
{
...(targetRoute?.params || {}),
conversation_id,
editing_message_id: message.id,
},
isNil
)
navigation.dispatch(StackActions.popTo(targetRouteName, params))
}, [navigation, conversation_id, message.id, inReplyScreen])
const handleViewReadReceiptsPress = useCallback(() => {
Haptic.impactLight()
const params = omitBy(
{
conversation_id,
message_id: message.id,
},
isNil
)
navigation.dispatch(StackActions.popTo('MessageReadReceipts', params))
}, [navigation, conversation_id, message.id])
const removeLinkPreview = useCallback(() => {
const url = `/me/conversations/${conversation_id}/messages/${message.id}/remove_expanded_link`
const data = {
data: { type: 'ExpandedLink', attributes: { expanded_link_id: expandedLink?.id } },
}
return apiClient.chat.post({ url, data })
}, [apiClient, conversation_id, message.id, expandedLink])
const { mutate: handleRemoveLinkPreview } = useMutation({
mutationFn: removeLinkPreview,
onSuccess: () => {
refetchMessages()
Haptic.notificationSuccess()
navigation.goBack()
},
onError: () => {
Alert.alert('Oops', 'We were unable to remove the preview. Please try again.')
},
})
const handleRemoveLinkPreviewConfirm = useCallback(() => {
Alert.alert('Remove link preview?', 'This will remove the image preview but retain the link', [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Remove', style: 'destructive', onPress: () => handleRemoveLinkPreview() },
])
}, [handleRemoveLinkPreview])
const showReportMessageAction =
!message?.mine && featureEnabled(availableFeatures.message_reporting)
const showDeleteMessageAction =
!conversationDisabled && (message?.mine || canDeleteNonAuthoredMessages)
return (
{!conversationDisabled && (
{availableReactions.map((reaction, index) => (
{
Haptic.impactLight()
handleReactionToggle({
value: reaction.value,
mine: reaction.mine,
})
navigation.goBack()
}}
/>
))}
)}
{!isSystemMessage && (
{!inReplyScreen && !conversationDisabled && (
)}
{showReportMessageAction && (
)}
{message?.mine && !conversationDisabled && (
handleEditPress()}
title="Edit message"
iconName="accounts.editor"
accessibilityHint="Opens existing text in the message form input."
/>
)}
{message?.mine && (
handleViewReadReceiptsPress()}
title="View read receipts"
iconName="general.checkPerson"
accessibilityHint="Opens a modal with a list of people who read your message."
/>
)}
{message?.mine && hasExpandedLink && !conversationDisabled && (
handleRemoveLinkPreviewConfirm()}
title="Remove link preview"
iconName="general.brokenLink"
accessibilityHint="Removes an expanded link preview from the message."
/>
)}
{showDeleteMessageAction && (
handleDeleteConfirm()}
title="Delete message"
iconName="publishing.trash"
appearance="danger"
disabled={isPending}
accessibilityHint="Opens a confirmation alert to delete this message permanently."
/>
)}
)}
)
}
const Reaction = ({
reaction,
onPress,
}: {
reaction: { value: ReactionCountResource['value']; emoji: string; mine: boolean | undefined }
onPress: () => void
}) => {
const styles = useStyles()
const reactionStyles = useReactionStyles({ mine: reaction.mine ? 1 : 0 })
const { colors } = useTheme()
const baseRippleColor = reaction.mine ? colors.interaction : colors.fillColorNeutral060
const androidRippleColor = useCreateAndroidRippleColor({ color: baseRippleColor })
return (
{REACTION_EMOJIS[reaction.value]}
)
}
const useStyles = () => {
const { colors } = useTheme()
const fontScale = useFontScale({ maxFontSizeMultiplier: 1.3 })
const btnBorderWidth = 1
const baseSize = 46 * Math.max(1, fontScale)
const reactionBtnSize = Platform.select({
ios: baseSize,
android: baseSize + btnBorderWidth * 2,
})
return StyleSheet.create({
formSheetContent: {
paddingTop: 16,
},
reactionList: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: 16,
paddingTop: 8,
paddingBottom: 16,
borderBottomColor: colors.borderColorDefaultBase,
borderBottomWidth: 1,
},
reaction: {
height: reactionBtnSize,
width: reactionBtnSize,
borderWidth: btnBorderWidth,
borderRadius: 32,
justifyContent: 'center',
overflow: 'hidden',
},
reactionEmoji: {
fontSize: 24,
textAlign: 'center',
},
actions: {
paddingTop: 4,
},
})
}