forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {memo, useCallback} from 'react'
2import {LayoutAnimation, Platform} from 'react-native'
3import * as Clipboard from 'expo-clipboard'
4import {type ChatBskyConvoDefs, RichText} from '@atproto/api'
5import {useLingui} from '@lingui/react/macro'
6import {useQueryClient} from '@tanstack/react-query'
7
8import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
9import {richTextToString} from '#/lib/strings/rich-text-helpers'
10import {useConvoActive} from '#/state/messages/convo'
11import {useLanguagePrefs} from '#/state/preferences'
12import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
13import {useSession} from '#/state/session'
14import {atoms as a} from '#/alf'
15import * as ContextMenu from '#/components/ContextMenu'
16import {type TriggerProps} from '#/components/ContextMenu/types'
17import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
18import {BubbleQuestion_Stroke2_Corner0_Rounded as TranslateIcon} from '#/components/icons/Bubble'
19import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
20import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
21import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
22import {ReportDialog} from '#/components/moderation/ReportDialog'
23import * as Prompt from '#/components/Prompt'
24import {usePromptControl} from '#/components/Prompt'
25import * as Toast from '#/components/Toast'
26import {useAnalytics} from '#/analytics'
27import {IS_NATIVE} from '#/env'
28import {EmojiReactionPicker} from './EmojiReactionPicker'
29import {hasReachedReactionLimit} from './util'
30
31export let MessageContextMenu = ({
32 message,
33 children,
34 onTap,
35}: {
36 message: ChatBskyConvoDefs.MessageView
37 children: TriggerProps['children']
38 onTap?: () => void
39}): React.ReactNode => {
40 const {t: l} = useLingui()
41 const ax = useAnalytics()
42 const {currentAccount} = useSession()
43 const queryClient = useQueryClient()
44 const convo = useConvoActive()
45 const deleteControl = usePromptControl()
46 const reportControl = usePromptControl()
47 const blockOrDeleteControl = usePromptControl()
48 const langPrefs = useLanguagePrefs()
49 const translate = useGoogleTranslate()
50
51 const isFromSelf = message.sender?.did === currentAccount?.did
52 const isGroupChatEnabled = ax.features.enabled(ax.features.GroupChatsEnable)
53
54 const onCopyMessage = useCallback(() => {
55 const str = richTextToString(
56 new RichText({
57 text: message.text,
58 facets: message.facets,
59 }),
60 true,
61 )
62
63 void Clipboard.setStringAsync(str)
64 Toast.show(l`Copied to clipboard`, {
65 type: 'success',
66 })
67 }, [l, message.text, message.facets])
68
69 const onPressTranslateMessage = useCallback(() => {
70 void translate(message.text, langPrefs.primaryLanguage)
71
72 ax.metric('translate', {
73 os: Platform.OS,
74 possibleSourceLanguages: [], // N/A for chats
75 expectedTargetLanguage: langPrefs.primaryLanguage,
76 textLength: message.text.length,
77 googleTranslate: true,
78 })
79 }, [ax, langPrefs.primaryLanguage, message.text, translate])
80
81 const onDelete = useCallback(() => {
82 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
83 convo
84 .deleteMessage(message.id)
85 .then(() => Toast.show(l({message: 'Message deleted', context: 'toast'})))
86 .catch(() => Toast.show(l`Failed to delete message`))
87 }, [l, convo, message.id])
88
89 const onEmojiSelect = useCallback(
90 (emoji: string) => {
91 if (
92 message.reactions?.find(
93 reaction =>
94 reaction.value === emoji &&
95 reaction.sender.did === currentAccount?.did,
96 )
97 ) {
98 convo
99 .removeReaction(message.id, emoji)
100 .catch(() => Toast.show(l`Failed to remove emoji reaction`))
101 } else {
102 if (hasReachedReactionLimit(message, currentAccount?.did)) return
103 convo.addReaction(message.id, emoji).catch(() =>
104 Toast.show(l`Failed to add emoji reaction`, {
105 type: 'error',
106 }),
107 )
108 }
109 },
110 [l, convo, message, currentAccount?.did],
111 )
112
113 const sender = convo.convo.members.find(
114 member => member.did === message.sender.did,
115 )
116
117 return (
118 <>
119 <ContextMenu.Root>
120 {IS_NATIVE && (
121 <ContextMenu.AuxiliaryView
122 align={isFromSelf ? 'right' : 'left'}
123 style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
124 <EmojiReactionPicker
125 message={message}
126 onEmojiSelect={onEmojiSelect}
127 />
128 </ContextMenu.AuxiliaryView>
129 )}
130
131 <ContextMenu.Trigger
132 label={l`Message options`}
133 contentLabel={l`Message from @${
134 sender?.handle ?? 'unknown' // should always be defined
135 }: ${message.text}`}
136 onTap={onTap}>
137 {children}
138 </ContextMenu.Trigger>
139
140 <ContextMenu.Outer
141 align={isFromSelf ? 'right' : 'left'}
142 style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
143 {message.text.length > 0 && (
144 <>
145 <ContextMenu.Item
146 testID="messageDropdownTranslateBtn"
147 label={l`Translate`}
148 onPress={onPressTranslateMessage}>
149 <ContextMenu.ItemText>{l`Translate`}</ContextMenu.ItemText>
150 <ContextMenu.ItemIcon icon={TranslateIcon} position="right" />
151 </ContextMenu.Item>
152 <ContextMenu.Item
153 testID="messageDropdownCopyBtn"
154 label={l`Copy message text`}
155 onPress={onCopyMessage}>
156 <ContextMenu.ItemText>
157 {l`Copy message text`}
158 </ContextMenu.ItemText>
159 <ContextMenu.ItemIcon icon={ClipboardIcon} position="right" />
160 </ContextMenu.Item>
161 <ContextMenu.Divider />
162 </>
163 )}
164 <ContextMenu.Item
165 testID="messageDropdownDeleteBtn"
166 label={l`Delete message for me`}
167 onPress={() => deleteControl.open()}>
168 <ContextMenu.ItemText>{l`Delete for me`}</ContextMenu.ItemText>
169 <ContextMenu.ItemIcon icon={TrashIcon} position="right" />
170 </ContextMenu.Item>
171 {!isFromSelf && (
172 <ContextMenu.Item
173 testID="messageDropdownReportBtn"
174 label={l`Report message`}
175 onPress={() => reportControl.open()}>
176 <ContextMenu.ItemText>{l`Report`}</ContextMenu.ItemText>
177 <ContextMenu.ItemIcon icon={WarningIcon} position="right" />
178 </ContextMenu.Item>
179 )}
180 </ContextMenu.Outer>
181 </ContextMenu.Root>
182 <ReportDialog
183 control={reportControl}
184 subject={{
185 view: 'message',
186 convoId: convo.convo.id,
187 message,
188 }}
189 onAfterSubmit={() => {
190 if (sender) {
191 unstableCacheProfileView(queryClient, sender)
192 }
193 blockOrDeleteControl.open()
194 }}
195 />
196 <AfterReportDialog
197 control={blockOrDeleteControl}
198 currentScreen="conversation"
199 params={{
200 convoId: convo.convo.id,
201 message,
202 }}
203 />
204 <Prompt.Basic
205 control={deleteControl}
206 title={l`Delete message`}
207 description={l`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participants.`}
208 confirmButtonCta={l`Delete`}
209 confirmButtonColor="negative"
210 onConfirm={onDelete}
211 />
212 </>
213 )
214}
215MessageContextMenu = memo(MessageContextMenu)