Something went wrong. Try again.
Bluesky app fork with some witchin' additions 💫
Something went wrong. Try again.
8.3 kB · 227 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228import {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 ( <ContextMenu.Root> {IS_NATIVE && reactionsAvailable && ( <ContextMenu.AuxiliaryView align={isFromSelf ? 'right' : 'left'} style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}> <EmojiReactionPicker message={message} onEmojiSelect={onEmojiSelect} /> </ContextMenu.AuxiliaryView> )}
<ContextMenu.Trigger label={l`Message options`} contentLabel={l`Message from @${ sender?.handle ?? 'unknown' // should always be defined }: ${message.text}`} swipeGesture={swipeGesture}> {children} </ContextMenu.Trigger>
<ContextMenu.Outer align={isFromSelf ? 'right' : 'left'} label={l`Sent at ${i18n.date(new Date(message.sentAt), { timeStyle: 'short', })}`} style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]} onCloseAutoFocus={onCloseAutoFocus}> <ContextMenu.Item testID="messageDropdownReplyBtn" label={l`Reply`} onPress={onReply}> <ContextMenu.ItemIcon icon={ReplyIcon} position="left" /> <ContextMenu.ItemText>{l`Reply`}</ContextMenu.ItemText> </ContextMenu.Item> {message.text.length > 0 && ( <> <ContextMenu.Item testID="messageDropdownTranslateBtn" label={l`Translate`} onPress={onPressTranslateMessage}> <ContextMenu.ItemIcon icon={LanguageIcon} position="left" /> <ContextMenu.ItemText>{l`Translate`}</ContextMenu.ItemText> </ContextMenu.Item> <ContextMenu.Item testID="messageDropdownCopyBtn" label={l`Copy message text`} onPress={onCopyMessage}> <ContextMenu.ItemIcon icon={ClipboardIcon} position="left" /> <ContextMenu.ItemText> {l`Copy message text`} </ContextMenu.ItemText> </ContextMenu.Item> </> )} <ContextMenu.Item testID="messageDropdownDeleteBtn" label={l`Delete message for me`} onPress={() => openDeleteMessage(message)}> <ContextMenu.ItemIcon icon={TrashIcon} position="left" /> <ContextMenu.ItemText>{l`Delete for me`}</ContextMenu.ItemText> </ContextMenu.Item> {!isFromSelf && ( <ContextMenu.Item testID="messageDropdownReportBtn" label={l`Report message`} onPress={() => openReportMessage(message, senderProfile)}> <ContextMenu.ItemIcon icon={FlagIcon} position="left" /> <ContextMenu.ItemText>{l`Report`}</ContextMenu.ItemText> </ContextMenu.Item> )} </ContextMenu.Outer> </ContextMenu.Root> )}MessageContextMenu = memo(MessageContextMenu)