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.
340 lines
12 KiB
340 lines
12 KiB
import { Button } from '@/components/ui/button' |
|
import { |
|
DropdownMenu, |
|
DropdownMenuContent, |
|
DropdownMenuItem, |
|
DropdownMenuSeparator, |
|
DropdownMenuTrigger |
|
} from '@/components/ui/dropdown-menu' |
|
import { usePrimaryPage } from '@/contexts/primary-page-context' |
|
import { buildHiveTalkJoinUrl, roomIdForPubkeys } from '@/lib/hivetalk' |
|
import { getNostrArchivesProfileUrl, openExternalUrl } from '@/lib/link' |
|
import { pubkeyToNpub } from '@/lib/pubkey' |
|
import { useMuteList } from '@/contexts/mute-list-context' |
|
import { muteSetHas } from '@/lib/mute-set' |
|
import { normalizeAnyRelayUrl } from '@/lib/url' |
|
import { useNostr } from '@/providers/NostrProvider' |
|
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' |
|
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' |
|
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' |
|
import { encodeProfileInteractionsSpellId } from '@/pages/primary/SpellsPage/fauxSpellConfig' |
|
import client, { replaceableEventService } from '@/services/client.service' |
|
import { nip66Service } from '@/services/nip66.service' |
|
import RawEventDialog from '@/components/NoteOptions/RawEventDialog' |
|
import { |
|
Bell, |
|
BellOff, |
|
Code, |
|
Copy, |
|
Ellipsis, |
|
ExternalLink, |
|
Flag, |
|
ThumbsUp, |
|
MessageCircle, |
|
Network, |
|
Send, |
|
SatelliteDish, |
|
Video |
|
} 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, kinds } from 'nostr-tools' |
|
|
|
export default function ProfileOptions({ |
|
pubkey, |
|
profileEvent, |
|
onSendPublicMessage, |
|
onSendCallInvite, |
|
onSeeReports |
|
}: { |
|
pubkey: string |
|
/** Optional profile event (kind 0): reply / like, republish to relays, view 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 |
|
/** Opens the profile reports modal. */ |
|
onSeeReports?: () => void |
|
}) { |
|
const { t } = useTranslation() |
|
const { navigate } = usePrimaryPage() |
|
const { pubkey: accountPubkey, 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, { |
|
allowWideRelayFallback: true |
|
}) |
|
if (event) { |
|
setLocalProfileEvent(event) |
|
} |
|
} catch { |
|
// Silently fail: reply/like stay hidden until the event loads |
|
} |
|
} |
|
|
|
fetchEvent() |
|
}, [pubkey, profileEvent]) |
|
|
|
const isMuted = useMemo(() => muteSetHas(mutePubkeySet, pubkey), [mutePubkeySet, pubkey]) |
|
const nostrArchivesProfileUrl = useMemo(() => getNostrArchivesProfileUrl(pubkey), [pubkey]) |
|
|
|
/** All available relays: current feed, favorites, relay sets, defaults (FAST_READ, FAST_WRITE). */ |
|
const allAvailableRelayUrls = useMemo(() => { |
|
const urls = [ |
|
...currentBrowsingRelayUrls.map((url) => normalizeAnyRelayUrl(url) || url), |
|
...favoriteRelays.map((url) => normalizeAnyRelayUrl(url) || url), |
|
...relaySets.flatMap((set) => set.relayUrls.map((url) => normalizeAnyRelayUrl(url) || url)), |
|
...FAST_READ_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url), |
|
...FAST_WRITE_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url) |
|
].filter(Boolean) as string[] |
|
return Array.from(new Set(urls)) |
|
}, [currentBrowsingRelayUrls, favoriteRelays, relaySets]) |
|
|
|
useEffect(() => { |
|
void nip66Service.getPublicLivelyRelayUrls().then((urls) => { |
|
setMonitoringListRelayCount(urls?.length ?? 0) |
|
}) |
|
}, []) |
|
|
|
const eventToUse = localProfileEvent || profileEvent |
|
/** Kind 0 only; coerce `kind` in case deserialization yields a string. */ |
|
const kind0ForRelay = |
|
eventToUse != null && Number(eventToUse.kind) === kinds.Metadata ? eventToUse : undefined |
|
|
|
const handleRepublishToAllAvailable = async () => { |
|
if (!kind0ForRelay) { |
|
toast.error(t('Profile event not available')) |
|
return |
|
} |
|
const promise = client.publishEvent(allAvailableRelayUrls, kind0ForRelay, { skipOutboxRetry: true }).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 () => { |
|
if (!kind0ForRelay) { |
|
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, kind0ForRelay, { skipOutboxRetry: true }) |
|
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 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) }) |
|
|
|
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> |
|
<DropdownMenuItem onClick={() => navigate('spells', { spell: encodeProfileInteractionsSpellId(pubkey) })}> |
|
<Network /> |
|
{t('Interactions map')} |
|
</DropdownMenuItem> |
|
{onSeeReports && ( |
|
<DropdownMenuItem onClick={onSeeReports}> |
|
<Flag /> |
|
{t('See reports')} |
|
</DropdownMenuItem> |
|
)} |
|
{nostrArchivesProfileUrl && ( |
|
<DropdownMenuItem onClick={() => openExternalUrl(nostrArchivesProfileUrl)}> |
|
<ExternalLink /> |
|
{t('View on Nostr.Archives')} |
|
</DropdownMenuItem> |
|
)} |
|
{kind0ForRelay && ( |
|
<> |
|
<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} |
|
/> |
|
)} |
|
{kind0ForRelay && ( |
|
<RawEventDialog |
|
event={kind0ForRelay} |
|
isOpen={isRawEventDialogOpen} |
|
onClose={() => setIsRawEventDialogOpen(false)} |
|
/> |
|
)} |
|
</DropdownMenu> |
|
) |
|
}
|
|
|