import FollowButton from '@/components/FollowButton' import Nip05 from '@/components/Nip05' import Nip05List from '@/components/Nip05List' import NpubQrCode from '@/components/NpubQrCode' import ProfileAbout from '@/components/ProfileAbout' import ProfileBanner from '@/components/ProfileBanner' import { ProfileBotBadge } from '@/components/ProfileBotBadge' import ProfileOptions from '@/components/ProfileOptions' import ProfileZapButton from '@/components/ProfileZapButton' import PubkeyCopy from '@/components/PubkeyCopy' import { Avatar, AvatarFallback, AvatarIdenticon, AvatarImage } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { useFetchProfile } from '@/hooks' import { requestProfileWallRefresh } from '@/hooks/useProfileWall' import { kinds, type NostrEvent } from 'nostr-tools' import { createReactionDraftEvent } from '@/lib/draft-event' import { getPaymentInfoFromEvent } from '@/lib/event-metadata' import { showSimplePublishSuccess, toastPublishPromise } from '@/lib/publishing-feedback' import { getNostrArchivesProfileUrl, openExternalUrl, toProfileEditor } from '@/lib/link' import { encodeProfileInteractionsSpellId } from '@/pages/primary/SpellsPage/fauxSpellConfig' import { generateImageByPubkey } from '@/lib/pubkey' import { isVideo, normalizeAnyRelayUrl } from '@/lib/url' import { usePrimaryPage } from '@/contexts/primary-page-context' import { useSecondaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' import { replaceableEventService } from '@/services/client.service' import { ReplaceableEventService } from '@/services/client-replaceable-events.service' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Ellipsis, ExternalLink, Calendar, Flag, MapPin, Pencil, SatelliteDish, Code, Gift, Link, MessageCircle, Network, ThumbsUp } from 'lucide-react' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type MutableRefObject, type Ref } from 'react' import { useTranslation } from 'react-i18next' import logger from '@/lib/logger' import { AlexandriaEventsSearchEmptyCta } from '@/components/AlexandriaEventsSearchEmptyCta' import NotFound from '../NotFound' import ProfileBadges from './ProfileBadges' import ProfileFeed from './ProfileFeed' import ProfileReportsDialog from './ProfileReportsDialog' import SmartFollowings from './SmartFollowings' import SmartMuteLink from './SmartMuteLink' import SmartRelays from './SmartRelays' import PostEditor from '@/components/PostEditor' import { ScheduleVideoCallDialog, ScheduleInPersonMeetingDialog } from '@/components/ScheduleVideoCallDialog' import RawEventDialog from '@/components/NoteOptions/RawEventDialog' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' import { nip66Service } from '@/services/nip66.service' import PaymentMethodsSection from '@/components/PaymentMethodsSection' import { buildRecipientPaymentData } from '@/hooks/useRecipientAlternativePayments' import { loadAuthorReplaceablesFromLocalCache } from '@/lib/profile-author-replaceables-cache' import ZapDialog from '@/components/ZapDialog' import { groupPaymentMethodsForDisplay, mergePaymentMethods, recipientHasAnyPaymentOptions, sortMergedPaymentMethods } from '@/lib/merge-payment-methods' import { useSenderPaytoTypes } from '@/hooks/useSenderPaytoTypes' import { PRIMARY_LINK_HOVER_CLASS } from '@/lib/link-styles' import { cn } from '@/lib/utils' export default function Profile({ id, feedRef, alexandriaNotFoundHref = null }: { id?: string /** When set, exposes {@link ProfileFeed} `refresh` for titlebars / parent pages. */ feedRef?: Ref<{ refresh: () => void }> /** When profile lookup fails, link to Alexandria with the same identifier (search / deep link). */ alexandriaNotFoundHref?: string | null }) { const { t } = useTranslation() const { push } = useSecondaryPage() const { navigate: navigatePrimary } = usePrimaryPage() const internalFeedRef = useRef<{ refresh: () => void }>(null) const profileFeedRef = feedRef ?? internalFeedRef const profilePubkeyRef = useRef(null) const [openReportsDialog, setOpenReportsDialog] = useState(false) const { profile, isFetching } = useFetchProfile(id) profilePubkeyRef.current = profile?.pubkey ?? null const { pubkey: accountPubkey, profileEvent: accountProfileEvent, publish, checkLogin } = useNostr() const [paymentInfo, setPaymentInfo] = useState | null>(null) const [profileEvent, setProfileEvent] = useState(undefined) const [openPaymentDialog, setOpenPaymentDialog] = useState(false) const [openPublicMessageTo, setOpenPublicMessageTo] = useState(null) const [openCallInviteTo, setOpenCallInviteTo] = useState<{ pubkey: string; url: string } | null>(null) const [openScheduleOwnCall, setOpenScheduleOwnCall] = useState(false) const [openScheduleInPersonMeeting, setOpenScheduleInPersonMeeting] = useState(false) const [isRawEventDialogOpen, setIsRawEventDialogOpen] = useState(false) const [openSelfReply, setOpenSelfReply] = useState(false) const [selfReacting, setSelfReacting] = useState(false) const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays() const { relaySets, favoriteRelays } = useFavoriteRelays() const isSelf = accountPubkey === profile?.pubkey const effectiveProfileEvent = useMemo(() => { if (!isSelf || !accountProfileEvent) return profileEvent if (!profileEvent) return accountProfileEvent return accountProfileEvent.created_at >= profileEvent.created_at ? accountProfileEvent : profileEvent }, [isSelf, profileEvent, accountProfileEvent]) const senderPaytoTypes = useSenderPaytoTypes(!!accountPubkey && !isSelf) const mergedPaymentMethods = useMemo( () => sortMergedPaymentMethods( mergePaymentMethods(paymentInfo, profile ?? null, effectiveProfileEvent) ), [paymentInfo, profile, effectiveProfileEvent] ) const paymentMethodsByType = useMemo( () => groupPaymentMethodsForDisplay(mergedPaymentMethods, senderPaytoTypes), [mergedPaymentMethods, senderPaytoTypes] ) const hasPaymentMethods = useMemo( () => recipientHasAnyPaymentOptions(paymentInfo, profile ?? null, effectiveProfileEvent), [paymentInfo, profile, effectiveProfileEvent] ) const prefetchedPaymentData = useMemo( () => profile?.pubkey ? buildRecipientPaymentData(paymentInfo, profile ?? null, effectiveProfileEvent ?? null) : null, [paymentInfo, profile, effectiveProfileEvent] ) const syncAuthorReplaceablesFromCache = useCallback( async (pubkey: string, options?: { bustCache?: boolean }) => { try { if (options?.bustCache) { replaceableEventService.clearAuthorViewPaymentAndMetadataLoaders(pubkey) } const [paymentEvent, metaEvent] = await Promise.all([ client.fetchPaymentInfoEvent(pubkey), replaceableEventService.fetchReplaceableEvent(pubkey, kinds.Metadata) ]) setPaymentInfo(paymentEvent ? getPaymentInfoFromEvent(paymentEvent) : null) setProfileEvent(metaEvent ?? undefined) } catch (error) { logger.error('Failed to sync author replaceables from cache', { error, pubkey }) } }, [] ) useEffect(() => { if (!profile?.pubkey) { setPaymentInfo(null) setProfileEvent(undefined) return } let cancelled = false void loadAuthorReplaceablesFromLocalCache(profile.pubkey).then(({ paymentInfo: pi, profileEvent: pe }) => { if (cancelled) return setPaymentInfo(pi) setProfileEvent(pe) }) void syncAuthorReplaceablesFromCache(profile.pubkey) return () => { cancelled = true } }, [profile?.pubkey, syncAuthorReplaceablesFromCache]) const refreshAuthorReplaceables = useCallback(async (pubkey: string) => { requestProfileWallRefresh(pubkey) try { await client.forceRefreshProfileAndPaymentInfoCache(pubkey) await syncAuthorReplaceablesFromCache(pubkey, { bustCache: true }) } catch (error) { logger.error('Failed to refresh author replaceables', { error, pubkey }) } }, [syncAuthorReplaceablesFromCache]) const refreshAuthorExtrasForCurrentProfile = useCallback(() => { const pk = profilePubkeyRef.current if (pk) void refreshAuthorReplaceables(pk) }, [refreshAuthorReplaceables]) useEffect(() => { if (!profile?.pubkey || profile.batchPlaceholder) return const pk = profile.pubkey // Defer wide replaceable refresh so initial kind-0 / feed relay setup can finish first. const timer = window.setTimeout(() => { void client.refreshAuthorPublishedReplaceablesOnProfileView(pk) }, 2_000) return () => clearTimeout(timer) }, [profile?.pubkey, profile?.batchPlaceholder]) useEffect(() => { if (!isSelf || !profile?.pubkey || !accountProfileEvent) return setProfileEvent((prev) => !prev || accountProfileEvent.created_at >= prev.created_at ? accountProfileEvent : prev ) void syncAuthorReplaceablesFromCache(profile.pubkey, { bustCache: true }) }, [isSelf, accountProfileEvent, profile?.pubkey, syncAuthorReplaceablesFromCache]) useEffect(() => { if (!profile?.pubkey) return const pk = profile.pubkey.toLowerCase() const onAuthorReplaceablesRefreshed: EventListener = (domEvt) => { const detailPk = (domEvt as CustomEvent<{ pubkey?: string }>).detail?.pubkey?.toLowerCase() if (detailPk !== pk) return void syncAuthorReplaceablesFromCache(profile.pubkey, { bustCache: true }) } window.addEventListener( ReplaceableEventService.AUTHOR_REPLACEABLES_REFRESHED_EVENT, onAuthorReplaceablesRefreshed ) return () => window.removeEventListener( ReplaceableEventService.AUTHOR_REPLACEABLES_REFRESHED_EVENT, onAuthorReplaceablesRefreshed ) }, [profile?.pubkey, syncAuthorReplaceablesFromCache]) const defaultImage = useMemo( () => (profile?.pubkey ? generateImageByPubkey(profile?.pubkey) : ''), [profile] ) /** 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]) const handleRepublishToAllAvailable = async () => { if (!profileEvent) return const promise = client.publishEvent(allAvailableRelayUrls, profileEvent, { 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 (!profileEvent) 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, profileEvent, { 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 }) }) } useLayoutEffect(() => { const r = profileFeedRef if (typeof r === 'function') return const m = r as MutableRefObject<{ refresh: () => void } | null> m.current = { // ProfileFeed.refresh already runs onRefreshExtras (payment + badges). refresh: () => internalFeedRef.current?.refresh() } return () => { m.current = null } }, []) if (!profile && isFetching) { return ( <>
{t('Searching all available relays...')}
) } if (!profile && !isFetching) { return ( {alexandriaNotFoundHref ? : null} ) } if (!profile) return null // TypeScript guard - should never reach here but satisfies type checker const { banner, username, about, avatar, pubkey, website, websiteList, nip05List, isBot } = profile const nostrArchivesProfileUrl = getNostrArchivesProfileUrl(pubkey) return ( <>
{/* Banner first in paint order; avatar uses higher z-index so it always sits on top. fetchPriority still prefers the pic over the banner. */} {isVideo(avatar ?? '') ? (
{isBot ? ( ) : null}
) : (
{isBot ? ( ) : null}
)}
{/* Below banner only: room for avatar half that extends past the banner edge */}
setOpenPublicMessageTo(pubkey) : undefined} onSendCallInvite={ !isSelf ? (url) => setOpenCallInviteTo({ pubkey, url }) : undefined } onSeeReports={() => setOpenReportsDialog(true)} /> {isSelf ? ( {profileEvent && ( <> setOpenSelfReply(true)}> {t('Reply')} { if (!profileEvent) return checkLogin(async () => { if (selfReacting) return setSelfReacting(true) try { const reaction = createReactionDraftEvent(profileEvent, '+') const evt = await publish(reaction) if (evt) { showSimplePublishSuccess(t('Reaction published')) } } finally { setSelfReacting(false) } }) }} disabled={selfReacting} > {selfReacting ? t('Publishing...') : t('Like')} )} setOpenScheduleOwnCall(true)}> {t('Schedule a video call')} setOpenScheduleInPersonMeeting(true)}> {t('Schedule in-person meeting')} navigatePrimary('spells', { spell: 'followPacks' })}> {t('Follow Packs')} navigatePrimary('spells', { spell: encodeProfileInteractionsSpellId(pubkey) })} > {t('Interactions map')} setOpenReportsDialog(true)}> {t('See reports')} {nostrArchivesProfileUrl ? ( openExternalUrl(nostrArchivesProfileUrl)}> {t('View on Nostr.Archives')} ) : null} push(toProfileEditor())}> {t('Edit')} {profileEvent && ( <> {t('Republish to all available relays')} ({allAvailableRelayUrls.length}) {t('Republish to all active relays')} setIsRawEventDialogOpen(true)}> {t('View JSON')} )} ) : null} {profileEvent && isSelf && ( )} {!isSelf ? ( <> {hasPaymentMethods && ( )} ) : null}
{username}
{/* Display multiple NIP-05 values if available, with verification */} {nip05List && nip05List.length > 1 && ( )}
{/* Display websites - show first one prominently, others below */} {website && ( )} {websiteList && websiteList.length > 1 && (
{websiteList.slice(1).map((url: string, idx: number) => ( ))}
)} {paymentMethodsByType.length > 0 && ( )} {!isSelf && hasPaymentMethods && ( )}
{isSelf && }
{openPublicMessageTo && ( !open && setOpenPublicMessageTo(null)} initialPublicMessageTo={openPublicMessageTo} /> )} {openCallInviteTo && ( !open && setOpenCallInviteTo(null)} initialPublicMessageTo={openCallInviteTo.pubkey} defaultContent={`${t('Join the video call')}: ${openCallInviteTo.url}`} /> )} {profileEvent && ( setIsRawEventDialogOpen(false)} /> )} ) }