useMenuActions.tsx raw
1 import { Pubkey } from '@/domain'
2 import { isProtectedEvent } from '@/lib/event'
3 import { getNoteBech32Id } from '@/lib/event-service'
4 import { toNjump } from '@/lib/link'
5 import { simplifyUrl } from '@/lib/url'
6 import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
7 import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
8 import { useMuteList } from '@/providers/MuteListProvider'
9 import { useNostr } from '@/providers/NostrProvider'
10 import { usePinList } from '@/providers/PinListProvider'
11 import client from '@/services/client.service'
12 import {
13 Bell,
14 BellOff,
15 Code,
16 Copy,
17 Link,
18 Pin,
19 PinOff,
20 SatelliteDish,
21 Trash2,
22 TriangleAlert
23 } from 'lucide-react'
24 import { Event, kinds } from 'nostr-tools'
25 import { useMemo } from 'react'
26 import { useTranslation } from 'react-i18next'
27 import { toast } from 'sonner'
28 import RelayIcon from '../RelayIcon'
29
30 export interface SubMenuAction {
31 label: React.ReactNode
32 onClick: () => void
33 className?: string
34 separator?: boolean
35 }
36
37 export interface MenuAction {
38 icon: React.ComponentType
39 label: string
40 onClick?: () => void
41 className?: string
42 separator?: boolean
43 subMenu?: SubMenuAction[]
44 }
45
46 interface UseMenuActionsProps {
47 event: Event
48 closeDrawer: () => void
49 showSubMenuActions: (subMenu: SubMenuAction[], title: string) => void
50 setIsRawEventDialogOpen: (open: boolean) => void
51 setIsReportDialogOpen: (open: boolean) => void
52 isSmallScreen: boolean
53 }
54
55 export function useMenuActions({
56 event,
57 closeDrawer,
58 showSubMenuActions,
59 setIsRawEventDialogOpen,
60 setIsReportDialogOpen,
61 isSmallScreen
62 }: UseMenuActionsProps) {
63 const { t } = useTranslation()
64 const { pubkey, attemptDelete } = useNostr()
65 const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays()
66 const { relaySets, favoriteRelays } = useFavoriteRelays()
67 const relayUrls = useMemo(() => {
68 return Array.from(new Set(currentBrowsingRelayUrls.concat(favoriteRelays)))
69 }, [currentBrowsingRelayUrls, favoriteRelays])
70 const { mutePubkeyPublicly, mutePubkeyPrivately, unmutePubkey, mutePubkeySet } = useMuteList()
71 const { pinnedEventHexIdSet, pin, unpin } = usePinList()
72 const isMuted = useMemo(() => mutePubkeySet.has(event.pubkey), [mutePubkeySet, event])
73
74 const broadcastSubMenu: SubMenuAction[] = useMemo(() => {
75 const items = []
76 if (pubkey && event.pubkey === pubkey) {
77 items.push({
78 label: <div className="text-left"> {t('Optimal relays')}</div>,
79 onClick: async () => {
80 closeDrawer()
81 const promise = async () => {
82 const relays = await client.determineTargetRelays(event)
83 if (relays?.length) {
84 await client.publishEvent(relays, event)
85 }
86 }
87 toast.promise(promise, {
88 loading: t('Republishing...'),
89 success: () => {
90 return t(
91 "Successfully republish to optimal relays (your write relays and mentioned users' read relays)"
92 )
93 },
94 error: (err) => {
95 return t('Failed to republish to optimal relays: {{error}}', {
96 error: err.message
97 })
98 }
99 })
100 }
101 })
102 }
103
104 if (relaySets.length) {
105 items.push(
106 ...relaySets
107 .filter((set) => set.relayUrls.length)
108 .map((set, index) => ({
109 label: <div className="text-left truncate">{set.name}</div>,
110 onClick: async () => {
111 closeDrawer()
112 const promise = client.publishEvent(set.relayUrls, event)
113 toast.promise(promise, {
114 loading: t('Republishing...'),
115 success: () => {
116 return t('Successfully republish to relay set: {{name}}', { name: set.name })
117 },
118 error: (err) => {
119 return t('Failed to republish to relay set: {{name}}. Error: {{error}}', {
120 name: set.name,
121 error: err.message
122 })
123 }
124 })
125 },
126 separator: index === 0
127 }))
128 )
129 }
130
131 if (relayUrls.length) {
132 items.push(
133 ...relayUrls.map((relay, index) => ({
134 label: (
135 <div className="flex items-center gap-2 w-full">
136 <RelayIcon url={relay} />
137 <div className="flex-1 truncate text-left">{simplifyUrl(relay)}</div>
138 </div>
139 ),
140 onClick: async () => {
141 closeDrawer()
142 const promise = client.publishEvent([relay], event)
143 toast.promise(promise, {
144 loading: t('Republishing...'),
145 success: () => {
146 return t('Successfully republish to relay: {{url}}', { url: simplifyUrl(relay) })
147 },
148 error: (err) => {
149 return t('Failed to republish to relay: {{url}}. Error: {{error}}', {
150 url: simplifyUrl(relay),
151 error: err.message
152 })
153 }
154 })
155 },
156 separator: index === 0
157 }))
158 )
159 }
160
161 return items
162 }, [pubkey, relayUrls, relaySets])
163
164 const menuActions: MenuAction[] = useMemo(() => {
165 const actions: MenuAction[] = [
166 {
167 icon: Copy,
168 label: t('Copy event ID'),
169 onClick: () => {
170 navigator.clipboard.writeText(getNoteBech32Id(event))
171 closeDrawer()
172 }
173 },
174 {
175 icon: Copy,
176 label: t('Copy user ID'),
177 onClick: () => {
178 navigator.clipboard.writeText(Pubkey.tryFromString(event.pubkey)?.npub ?? '')
179 closeDrawer()
180 }
181 },
182 {
183 icon: Link,
184 label: t('Copy share link'),
185 onClick: () => {
186 navigator.clipboard.writeText(toNjump(getNoteBech32Id(event)))
187 closeDrawer()
188 }
189 },
190 {
191 icon: Code,
192 label: t('View raw event'),
193 onClick: () => {
194 closeDrawer()
195 setIsRawEventDialogOpen(true)
196 },
197 separator: true
198 }
199 ]
200
201 const isProtected = isProtectedEvent(event)
202 if (!isProtected || event.pubkey === pubkey) {
203 actions.push({
204 icon: SatelliteDish,
205 label: t('Republish to ...'),
206 onClick: isSmallScreen
207 ? () => showSubMenuActions(broadcastSubMenu, t('Republish to ...'))
208 : undefined,
209 subMenu: isSmallScreen ? undefined : broadcastSubMenu,
210 separator: true
211 })
212 }
213
214 if (event.pubkey === pubkey && event.kind === kinds.ShortTextNote) {
215 const pinned = pinnedEventHexIdSet.has(event.id)
216 actions.push({
217 icon: pinned ? PinOff : Pin,
218 label: pinned ? t('Unpin from profile') : t('Pin to profile'),
219 onClick: async () => {
220 closeDrawer()
221 await (pinned ? unpin(event) : pin(event))
222 }
223 })
224 }
225
226 if (pubkey && event.pubkey !== pubkey) {
227 actions.push({
228 icon: TriangleAlert,
229 label: t('Report'),
230 className: 'text-destructive focus:text-destructive',
231 onClick: () => {
232 closeDrawer()
233 setIsReportDialogOpen(true)
234 },
235 separator: true
236 })
237 }
238
239 if (pubkey && event.pubkey !== pubkey) {
240 if (isMuted) {
241 actions.push({
242 icon: Bell,
243 label: t('Unmute user'),
244 onClick: () => {
245 closeDrawer()
246 unmutePubkey(event.pubkey)
247 },
248 className: 'text-destructive focus:text-destructive',
249 separator: true
250 })
251 } else {
252 actions.push(
253 {
254 icon: BellOff,
255 label: t('Mute user privately'),
256 onClick: () => {
257 closeDrawer()
258 mutePubkeyPrivately(event.pubkey)
259 },
260 className: 'text-destructive focus:text-destructive',
261 separator: true
262 },
263 {
264 icon: BellOff,
265 label: t('Mute user publicly'),
266 onClick: () => {
267 closeDrawer()
268 mutePubkeyPublicly(event.pubkey)
269 },
270 className: 'text-destructive focus:text-destructive'
271 }
272 )
273 }
274 }
275
276 if (pubkey && event.pubkey === pubkey) {
277 actions.push({
278 icon: Trash2,
279 label: t('Try deleting this note'),
280 onClick: () => {
281 closeDrawer()
282 attemptDelete(event)
283 },
284 className: 'text-destructive focus:text-destructive',
285 separator: true
286 })
287 }
288
289 return actions
290 }, [
291 t,
292 event,
293 pubkey,
294 isMuted,
295 isSmallScreen,
296 broadcastSubMenu,
297 pinnedEventHexIdSet,
298 closeDrawer,
299 showSubMenuActions,
300 setIsRawEventDialogOpen,
301 mutePubkeyPrivately,
302 mutePubkeyPublicly,
303 unmutePubkey
304 ])
305
306 return menuActions
307 }
308