Bpistle/src/components/NoteOptions/useMenuActions.tsx
codytseng 244dda807b feat: add copy note content option and migrate AGENTS.md to CLAUDE.md
Add a "Copy note content" option to the note options menu that copies the
event's content field to clipboard. Merge AGENTS.md into CLAUDE.md with
enhanced i18n rules emphasizing append-only locale key ordering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 21:56:32 +08:00

315 lines
8.9 KiB
TypeScript

import { formatError } from '@/lib/error'
import { getNoteBech32Id, isProtectedEvent } from '@/lib/event'
import { toJumbleNote } from '@/lib/link'
import { pubkeyToNpub } from '@/lib/pubkey'
import { simplifyUrl } from '@/lib/url'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { usePinList } from '@/providers/PinListProvider'
import client from '@/services/client.service'
import {
Bell,
BellOff,
Code,
Copy,
Link,
Pin,
PinOff,
SatelliteDish,
Trash2,
TriangleAlert
} from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import RelayIcon from '../RelayIcon'
export interface SubMenuAction {
label: React.ReactNode
onClick: () => void
className?: string
separator?: boolean
}
export interface MenuAction {
icon: React.ComponentType
label: string
onClick?: () => void
className?: string
separator?: boolean
subMenu?: SubMenuAction[]
}
interface UseMenuActionsProps {
event: Event
closeDrawer: () => void
showSubMenuActions: (subMenu: SubMenuAction[], title: string) => void
setIsRawEventDialogOpen: (open: boolean) => void
setIsReportDialogOpen: (open: boolean) => void
isSmallScreen: boolean
}
export function useMenuActions({
event,
closeDrawer,
showSubMenuActions,
setIsRawEventDialogOpen,
setIsReportDialogOpen,
isSmallScreen
}: UseMenuActionsProps) {
const { t } = useTranslation()
const { pubkey, attemptDelete } = useNostr()
const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays()
const { relaySets, favoriteRelays } = useFavoriteRelays()
const relayUrls = useMemo(() => {
return Array.from(new Set(currentBrowsingRelayUrls.concat(favoriteRelays)))
}, [currentBrowsingRelayUrls, favoriteRelays])
const { mutePubkeyPublicly, mutePubkeyPrivately, unmutePubkey, mutePubkeySet } = useMuteList()
const { pinnedEventHexIdSet, pin, unpin } = usePinList()
const isMuted = useMemo(() => mutePubkeySet.has(event.pubkey), [mutePubkeySet, event])
const broadcastSubMenu: SubMenuAction[] = useMemo(() => {
const items = []
if (pubkey && event.pubkey === pubkey) {
items.push({
label: <div className="text-left"> {t('Optimal relays')}</div>,
onClick: async () => {
closeDrawer()
const promise = async () => {
const relays = await client.determineTargetRelays(event)
if (relays?.length) {
await client.publishEvent(relays, event)
}
}
toast.promise(promise, {
loading: t('Republishing...'),
success: () => {
return t(
"Successfully republish to optimal relays (your write relays and mentioned users' read relays)"
)
},
error: (err) => {
return t('Failed to republish to optimal relays: {{error}}', {
error: err.message
})
}
})
}
})
}
if (relaySets.length) {
items.push(
...relaySets
.filter((set) => set.relayUrls.length)
.map((set, index) => ({
label: <div className="truncate text-left">{set.name}</div>,
onClick: async () => {
closeDrawer()
const promise = client.publishEvent(set.relayUrls, event)
toast.promise(promise, {
loading: t('Republishing...'),
success: () => {
return t('Successfully republish to relay set: {{name}}', { name: set.name })
},
error: (err) => {
return t('Failed to republish to relay set: {{name}}. Error: {{error}}', {
name: set.name,
error: formatError(err).join('; ')
})
}
})
},
separator: index === 0
}))
)
}
if (relayUrls.length) {
items.push(
...relayUrls.map((relay, index) => ({
label: (
<div className="flex w-full items-center gap-2">
<RelayIcon url={relay} />
<div className="flex-1 truncate text-left">{simplifyUrl(relay)}</div>
</div>
),
onClick: async () => {
closeDrawer()
const promise = client.publishEvent([relay], event)
toast.promise(promise, {
loading: t('Republishing...'),
success: () => {
return t('Successfully republish to relay: {{url}}', { url: simplifyUrl(relay) })
},
error: (err) => {
return t('Failed to republish to relay: {{url}}. Error: {{error}}', {
url: simplifyUrl(relay),
error: formatError(err).join('; ')
})
}
})
},
separator: index === 0
}))
)
}
return items
}, [pubkey, relayUrls, relaySets])
const menuActions: MenuAction[] = useMemo(() => {
const actions: MenuAction[] = [
{
icon: Copy,
label: t('Copy event ID'),
onClick: () => {
navigator.clipboard.writeText(getNoteBech32Id(event))
closeDrawer()
}
},
{
icon: Copy,
label: t('Copy user ID'),
onClick: () => {
navigator.clipboard.writeText(pubkeyToNpub(event.pubkey) ?? '')
closeDrawer()
}
},
{
icon: Link,
label: t('Copy share link'),
onClick: () => {
navigator.clipboard.writeText(toJumbleNote(event))
closeDrawer()
}
},
{
icon: Copy,
label: t('Copy note content'),
onClick: () => {
navigator.clipboard.writeText(event.content)
closeDrawer()
}
},
{
icon: Code,
label: t('View raw event'),
onClick: () => {
closeDrawer()
setIsRawEventDialogOpen(true)
},
separator: true
}
]
const isProtected = isProtectedEvent(event)
if (!isProtected || event.pubkey === pubkey) {
actions.push({
icon: SatelliteDish,
label: t('Republish to ...'),
onClick: isSmallScreen
? () => showSubMenuActions(broadcastSubMenu, t('Republish to ...'))
: undefined,
subMenu: isSmallScreen ? undefined : broadcastSubMenu,
separator: true
})
}
if (event.pubkey === pubkey && event.kind === kinds.ShortTextNote) {
const pinned = pinnedEventHexIdSet.has(event.id)
actions.push({
icon: pinned ? PinOff : Pin,
label: pinned ? t('Unpin from profile') : t('Pin to profile'),
onClick: async () => {
closeDrawer()
await (pinned ? unpin(event) : pin(event))
}
})
}
if (pubkey && event.pubkey !== pubkey) {
actions.push({
icon: TriangleAlert,
label: t('Report'),
className: 'text-destructive focus:text-destructive',
onClick: () => {
closeDrawer()
setIsReportDialogOpen(true)
},
separator: true
})
}
if (pubkey && event.pubkey !== pubkey) {
if (isMuted) {
actions.push({
icon: Bell,
label: t('Unmute user'),
onClick: () => {
closeDrawer()
unmutePubkey(event.pubkey)
},
className: 'text-destructive focus:text-destructive',
separator: true
})
} else {
actions.push(
{
icon: BellOff,
label: t('Mute user privately'),
onClick: () => {
closeDrawer()
mutePubkeyPrivately(event.pubkey)
},
className: 'text-destructive focus:text-destructive',
separator: true
},
{
icon: BellOff,
label: t('Mute user publicly'),
onClick: () => {
closeDrawer()
mutePubkeyPublicly(event.pubkey)
},
className: 'text-destructive focus:text-destructive'
}
)
}
}
if (pubkey && event.pubkey === pubkey) {
actions.push({
icon: Trash2,
label: t('Try deleting this note'),
onClick: () => {
closeDrawer()
attemptDelete(event)
},
className: 'text-destructive focus:text-destructive',
separator: true
})
}
return actions
}, [
t,
event,
pubkey,
isMuted,
isSmallScreen,
broadcastSubMenu,
pinnedEventHexIdSet,
closeDrawer,
showSubMenuActions,
setIsRawEventDialogOpen,
mutePubkeyPrivately,
mutePubkeyPublicly,
unmutePubkey
])
return menuActions
}