import {memo, useCallback} from 'react' import {Platform} from 'react-native' import {type GestureType} from 'react-native-gesture-handler' import * as Clipboard from 'expo-clipboard' import { type ChatBskyConvoDefs, type ModerationOpts, RichText, } from '@atproto/api' import {plural} from '@lingui/core/macro' import {useLingui} from '@lingui/react/macro' import {EMOJI_REACTION_LIMIT} from '#/lib/constants' import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate' import {richTextToString} from '#/lib/strings/rich-text-helpers' import {useMaybeProfileShadow} from '#/state/cache/profile-shadow' import {useConvoActive} from '#/state/messages/convo' import {useLanguagePrefs} from '#/state/preferences' import {useSession} from '#/state/session' import {atoms as a} from '#/alf' import * as ContextMenu from '#/components/ContextMenu' import {type TriggerProps} from '#/components/ContextMenu/types' import {useMessageDialogs} from '#/components/dms/MessageOverlays' import {useMessageReplies} from '#/components/dms/MessageReplies' import {ArrowCornerDownRight_Stroke2_Corner2_Rounded as ReplyIcon} from '#/components/icons/ArrowCornerDownRight' import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag' import {Language_Stroke2_Corner2_Rounded as LanguageIcon} from '#/components/icons/Language' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' import * as Toast from '#/components/Toast' import {useAnalytics} from '#/analytics' import {IS_NATIVE} from '#/env' import type * as bsky from '#/types/bsky' import {EmojiReactionPicker} from './EmojiReactionPicker' import {canReact, hasReachedReactionLimit} from './util' export let MessageContextMenu = ({ message, senderProfile, moderationOpts, children, swipeGesture, }: { message: ChatBskyConvoDefs.MessageView senderProfile?: bsky.profile.AnyProfileView moderationOpts: ModerationOpts | undefined children: TriggerProps['children'] /** * Native only. A swipe gesture (swipe-to-reply) composed into the trigger's * gesture group so it's mutually exclusive with the tap and long-press. */ swipeGesture?: GestureType }): React.ReactNode => { const {t: l, i18n} = useLingui() const ax = useAnalytics() const {currentAccount} = useSession() const convo = useConvoActive() const {openDeleteMessage, openReportMessage} = useMessageDialogs() const {setReply} = useMessageReplies() const langPrefs = useLanguagePrefs() const translate = useGoogleTranslate() const onReply = useCallback(() => { setReply(message) }, [setReply, message]) // On web, the menu is a Radix dropdown that restores focus to the trigger on // close. When Reply moves focus to the composer, don't let Radix steal it // back. Checking activeElement (rather than tracking reply intent) also // handles re-replying to the same message, where the composer's focus effect // bails on an unchanged reply target and focus should stay on the trigger. const onCloseAutoFocus = useCallback((event: Event) => { if (document.activeElement && document.activeElement !== document.body) { event.preventDefault() } }, []) const isFromSelf = message.sender?.did === currentAccount?.did const isGroupChatEnabled = !ax.features.enabled(ax.features.GroupChatsDisable) const primaryMember = useMaybeProfileShadow(convo.convo.primaryMember) const reactionsAvailable = canReact({ convoState: convo, primaryMember, moderationOpts, }) const onCopyMessage = useCallback(() => { const str = richTextToString( new RichText({ text: message.text, facets: message.facets, }), true, ) void Clipboard.setStringAsync(str) Toast.show(l`Copied to clipboard`, { type: 'success', }) }, [l, message.text, message.facets]) const onPressTranslateMessage = useCallback(() => { void translate(message.text, langPrefs.primaryLanguage) ax.metric('translate', { os: Platform.OS, possibleSourceLanguages: [], // N/A for chats expectedTargetLanguage: langPrefs.primaryLanguage, textLength: message.text.length, googleTranslate: true, }) }, [ax, langPrefs.primaryLanguage, message.text, translate]) const onEmojiSelect = useCallback( (emoji: string) => { if ( message.reactions?.find( reaction => reaction.value === emoji && reaction.sender.did === currentAccount?.did, ) ) { convo .removeReaction(message.id, emoji) .catch(() => Toast.show(l`Failed to remove emoji reaction`)) } else { if (hasReachedReactionLimit(message, currentAccount?.did)) { Toast.show( l`You cannot add more than ${plural(EMOJI_REACTION_LIMIT, { one: '# emoji reaction', other: '# emoji reactions', })}`, { type: 'info', }, ) return } convo.addReaction(message.id, emoji).catch(() => Toast.show(l`Failed to add emoji reaction`, { type: 'error', }), ) } }, [l, convo, message, currentAccount?.did], ) const sender = senderProfile return ( {IS_NATIVE && reactionsAvailable && ( )} {children} {l`Reply`} {message.text.length > 0 && ( <> {l`Translate`} {l`Copy message text`} )} openDeleteMessage(message)}> {l`Delete for me`} {!isFromSelf && ( openReportMessage(message, senderProfile)}> {l`Report`} )} ) } MessageContextMenu = memo(MessageContextMenu)