You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

301 lines
11 KiB

import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { buildHiveTalkJoinUrl, roomIdForPubkeys } from '@/lib/hivetalk'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { normalizeUrl } from '@/lib/url'
import { useMuteList } from '@/contexts/mute-list-context'
import { muteSetHas } from '@/lib/mute-set'
import { useNostr } from '@/providers/NostrProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants'
import client from '@/services/client.service'
import { replaceableEventService } from '@/services/client.service'
import { nip66Service } from '@/services/nip66.service'
import RawEventDialog from '@/components/NoteOptions/RawEventDialog'
import { Bell, BellOff, Copy, Ellipsis, ThumbsUp, MessageCircle, Send, Video, SatelliteDish, Code } from 'lucide-react'
import { useMemo, useState, useEffect } from 'react'
import { createReactionDraftEvent } from '@/lib/draft-event'
import PostEditor from '@/components/PostEditor'
import { showSimplePublishSuccess, toastPublishPromise } from '@/lib/publishing-feedback'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Event } from 'nostr-tools'
export default function ProfileOptions({
pubkey,
profileEvent,
onSendPublicMessage,
onSendCallInvite
}: {
pubkey: string
/** Optional profile event (kind 0) for republishing and viewing JSON */
profileEvent?: Event
/** Opens the post editor in public message mode with this profile's pubkey in the mention list. */
onSendPublicMessage?: () => void
/** Opens the post editor to send the call invite URL as a public message to this profile. */
onSendCallInvite?: (url: string) => void
}) {
const { t } = useTranslation()
const { pubkey: accountPubkey, profile, publish, checkLogin } = useNostr()
const { mutePubkeySet, mutePubkeyPrivately, mutePubkeyPublicly, unmutePubkey } = useMuteList()
const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays()
const { relaySets, favoriteRelays } = useFavoriteRelays()
const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false)
const [openReply, setOpenReply] = useState(false)
const [reacting, setReacting] = useState(false)
const [monitoringListRelayCount, setMonitoringListRelayCount] = useState<number | null>(null)
const [localProfileEvent, setLocalProfileEvent] = useState<Event | undefined>(profileEvent)
// Fetch profile event if not provided
useEffect(() => {
if (profileEvent) {
setLocalProfileEvent(profileEvent)
return
}
// If profileEvent is not provided, try to fetch it using comprehensive search
const fetchEvent = async () => {
try {
// Use fetchProfileEvent which includes comprehensive relay search
const event = await replaceableEventService.fetchProfileEvent(pubkey, false)
if (event) {
setLocalProfileEvent(event)
}
} catch (error) {
// Silently fail - menu items just won't show
}
}
fetchEvent()
}, [pubkey, profileEvent])
const isMuted = useMemo(() => muteSetHas(mutePubkeySet, pubkey), [mutePubkeySet, pubkey])
const displayName = profile?.username ?? (accountPubkey ? formatPubkey(accountPubkey) : 'jumble')
/** All available relays: current feed, favorites, relay sets, defaults (FAST_READ, FAST_WRITE). */
const allAvailableRelayUrls = useMemo(() => {
const urls = [
...currentBrowsingRelayUrls.map(url => normalizeUrl(url) || url),
...favoriteRelays.map(url => normalizeUrl(url) || url),
...relaySets.flatMap(set => set.relayUrls.map(url => normalizeUrl(url) || url)),
...FAST_READ_RELAY_URLS.map(url => normalizeUrl(url) || url),
...FAST_WRITE_RELAY_URLS.map(url => normalizeUrl(url) || url)
].filter(Boolean) as string[]
return Array.from(new Set(urls))
}, [currentBrowsingRelayUrls, favoriteRelays, relaySets])
useEffect(() => {
nip66Service.getPublicLivelyRelayUrls().then((urls) => {
setMonitoringListRelayCount(urls?.length ?? 0)
})
}, [])
const handleRepublishToAllAvailable = async () => {
const eventToPublish = localProfileEvent || profileEvent
if (!eventToPublish) {
toast.error(t('Profile event not available'))
return
}
const promise = client.publishEvent(allAvailableRelayUrls, eventToPublish).then((result) => {
if (result.successCount < 1) {
throw new Error(t('No relay accepted the event'))
}
return result
})
toastPublishPromise(promise, {
loading: t('Republishing...'),
success: () => t('Successfully republish to all available relays'),
error: (err) => t('Failed to republish to all available relays: {{error}}', { error: err.message })
})
}
const handleRepublishToAllActive = async () => {
const eventToPublish = localProfileEvent || profileEvent
if (!eventToPublish) {
toast.error(t('Profile event not available'))
return
}
const promise = (async () => {
let relays = await nip66Service.getPublicLivelyRelayUrls()
const usedMonitoringList = !!relays?.length
if (!relays?.length) {
relays = allAvailableRelayUrls
}
if (!relays?.length) {
throw new Error(t('No relays available'))
}
const result = await client.publishEvent(relays, eventToPublish)
const minRequired = usedMonitoringList ? 5 : 1
if (result.successCount < minRequired) {
throw new Error(
usedMonitoringList
? t('Only {{count}} relay(s) accepted the event; at least 5 required for "all active relays".', { count: result.successCount })
: t('No relay accepted the event')
)
}
return result
})()
toastPublishPromise(promise, {
loading: t('Republishing...'),
success: () => t('Successfully republish to all active relays'),
error: (err) => t('Failed to republish to all active relays: {{error}}', { error: err.message })
})
}
const eventToUse = localProfileEvent || profileEvent
const handleLike = () => {
if (!eventToUse) return
checkLogin(async () => {
if (reacting) return
setReacting(true)
try {
const reaction = createReactionDraftEvent(eventToUse, '+')
const evt = await publish(reaction)
if (evt) {
showSimplePublishSuccess(t('Reaction published'))
}
} finally {
setReacting(false)
}
})
}
if (pubkey === accountPubkey) return null
const callInviteUrl =
accountPubkey &&
buildHiveTalkJoinUrl({
room: roomIdForPubkeys(accountPubkey, pubkey),
name: displayName
})
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" className="rounded-full">
<Ellipsis />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{eventToUse && (
<>
<DropdownMenuItem onClick={() => setOpenReply(true)}>
<MessageCircle />
{t('Reply')}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleLike} disabled={reacting}>
<ThumbsUp />
{reacting ? t('Publishing...') : t('Like')}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{onSendPublicMessage && (
<DropdownMenuItem onClick={onSendPublicMessage}>
<MessageCircle />
{t('Send public message')}
</DropdownMenuItem>
)}
{callInviteUrl && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => window.open(callInviteUrl, '_blank', 'noopener,noreferrer')}
>
<Video />
{t('Start video call')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(callInviteUrl)
toast.success(t('Copied to clipboard'))
}}
>
<Copy />
{t('Copy call invite link')}
</DropdownMenuItem>
{onSendCallInvite && (
<DropdownMenuItem onClick={() => onSendCallInvite(callInviteUrl)}>
<Send />
{t('Send call invite')}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText('nostr:' + pubkeyToNpub(pubkey))}
>
<Copy />
{t('Copy user ID')}
</DropdownMenuItem>
{(localProfileEvent || profileEvent) && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleRepublishToAllAvailable}>
<SatelliteDish />
{t('Republish to all available relays')} ({allAvailableRelayUrls.length})
</DropdownMenuItem>
<DropdownMenuItem onClick={handleRepublishToAllActive}>
<SatelliteDish />
{t('Republish to all active relays')}
{monitoringListRelayCount !== null && ` (${monitoringListRelayCount > 0 ? monitoringListRelayCount : allAvailableRelayUrls.length})`}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setIsRawEventDialogOpen(true)}>
<Code />
{t('View JSON')}
</DropdownMenuItem>
</>
)}
{isMuted ? (
<DropdownMenuItem
onClick={() => unmutePubkey(pubkey)}
className="text-destructive focus:text-destructive"
>
<Bell />
{t('Unmute user')}
</DropdownMenuItem>
) : (
<>
<DropdownMenuItem
onClick={() => mutePubkeyPrivately(pubkey)}
className="text-destructive focus:text-destructive"
>
<BellOff />
{t('Mute user privately')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => mutePubkeyPublicly(pubkey)}
className="text-destructive focus:text-destructive"
>
<BellOff />
{t('Mute user publicly')}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
{eventToUse && (
<PostEditor
parentEvent={eventToUse}
open={openReply}
setOpen={setOpenReply}
/>
)}
{(localProfileEvent || profileEvent) && (
<RawEventDialog
event={(localProfileEvent || profileEvent)!}
isOpen={isRawEventDialogOpen}
onClose={() => setIsRawEventDialogOpen(false)}
/>
)}
</DropdownMenu>
)
}