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, }, }) }