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