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, AvatarImage } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { useFetchProfile } from '@/hooks' 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, 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 FollowedBy from './FollowedBy' import ProfileFeedWithPins from './ProfileFeedWithPins' import ProfileLikedFeed from './ProfileLikedFeed' import ProfileMediaFeed from './ProfileMediaFeed' import ProfilePublicationsFeed from './ProfilePublicationsFeed' import ProfileReportsFeed from './ProfileReportsFeed' import ProfileWallFeed from './ProfileWallFeed' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import type { TNoteListRef } from '@/components/NoteList' import SmartFollowings from './SmartFollowings' import SmartMuteLink from './SmartMuteLink' import SmartRelays from './SmartRelays' import ZapDialog from '@/components/ZapDialog' 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 { buildRecipientZapPaymentData } from '@/hooks/useRecipientAlternativePayments' import { groupPaymentMethodsByDisplayType, mergePaymentMethods, recipientHasAnyPaymentOptions, sortMergedPaymentMethods } from '@/lib/merge-payment-methods' 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 ProfileFeedWithPins} `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 postsFeedRef = useRef<{ refresh: () => void }>(null) const mediaFeedRef = useRef(null) const publicationsFeedRef = useRef<{ refresh: () => void }>(null) const reportsFeedRef = useRef<{ refresh: () => void }>(null) const wallFeedRef = useRef<{ refresh: () => void }>(null) const likedFeedRef = useRef<{ refresh: () => void }>(null) const [profileFeedTab, setProfileFeedTab] = useState< 'posts' | 'media' | 'publications' | 'reports' | 'wall' | 'liked' >('posts') const profilePubkeyRef = useRef(null) const pendingReportsRefreshRef = useRef(false) const { profile, isFetching } = useFetchProfile(id) profilePubkeyRef.current = profile?.pubkey ?? null const { pubkey: accountPubkey, publish, checkLogin } = useNostr() const [paymentInfo, setPaymentInfo] = useState | null>(null) const [profileEvent, setProfileEvent] = useState(undefined) const [openZapDialog, setOpenZapDialog] = useState(false) const [zapLightningDefault, setZapLightningDefault] = useState(null) 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 mergedPaymentMethods = useMemo( () => sortMergedPaymentMethods(mergePaymentMethods(paymentInfo, profile ?? null, profileEvent)), [paymentInfo, profile, profileEvent] ) const paymentMethodsByType = useMemo( () => groupPaymentMethodsByDisplayType(mergedPaymentMethods), [mergedPaymentMethods] ) const hasTipDialog = useMemo( () => recipientHasAnyPaymentOptions(paymentInfo, profile ?? null, profileEvent), [paymentInfo, profile, profileEvent] ) const prefetchedZapPayment = useMemo( () => profile?.pubkey ? buildRecipientZapPaymentData(paymentInfo, profile ?? null, profileEvent ?? null) : null, [paymentInfo, profile, profileEvent] ) const syncAuthorReplaceablesFromCache = useCallback(async (pubkey: string) => { try { 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 } void syncAuthorReplaceablesFromCache(profile.pubkey) }, [profile?.pubkey, syncAuthorReplaceablesFromCache]) const refreshAuthorReplaceables = useCallback(async (pubkey: string) => { await client.forceRefreshProfileAndPaymentInfoCache(pubkey) await syncAuthorReplaceablesFromCache(pubkey) }, [syncAuthorReplaceablesFromCache]) useEffect(() => { if (!profile?.pubkey) return void client.refreshAuthorPublishedReplaceablesOnProfileView(profile.pubkey) }, [profile?.pubkey]) 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) } window.addEventListener( ReplaceableEventService.AUTHOR_REPLACEABLES_REFRESHED_EVENT, onAuthorReplaceablesRefreshed ) return () => window.removeEventListener( ReplaceableEventService.AUTHOR_REPLACEABLES_REFRESHED_EVENT, onAuthorReplaceablesRefreshed ) }, [profile?.pubkey, syncAuthorReplaceablesFromCache]) const isFollowingYou = useMemo(() => { // This will be handled by the FollowedBy component return false }, [profile, accountPubkey]) const defaultImage = useMemo( () => (profile?.pubkey ? generateImageByPubkey(profile?.pubkey) : ''), [profile] ) const isSelf = accountPubkey === profile?.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]) 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 = { refresh: () => { postsFeedRef.current?.refresh() mediaFeedRef.current?.refresh() publicationsFeedRef.current?.refresh() wallFeedRef.current?.refresh() likedFeedRef.current?.refresh() const pk = profilePubkeyRef.current if (reportsFeedRef.current) { reportsFeedRef.current.refresh() } else { pendingReportsRefreshRef.current = true } if (pk) { void refreshAuthorReplaceables(pk) } } } return () => { m.current = null } }, [refreshAuthorReplaceables]) useEffect(() => { if (!profile?.pubkey) return setProfileFeedTab('posts') }, [profile?.pubkey]) useEffect(() => { if (!isSelf && profileFeedTab === 'liked') { setProfileFeedTab('posts') } }, [isSelf, profileFeedTab]) /** * Radix {@link TabsContent} unmounts inactive panels, so media / publications / liked feeds can miss the same * warm-up window as Posts or show a frozen first paint. Re-run their refresh path when the tab becomes active * (after refs attach — {@link useLayoutEffect}). */ useLayoutEffect(() => { if (profileFeedTab === 'media') { mediaFeedRef.current?.refresh() } else if (profileFeedTab === 'publications') { publicationsFeedRef.current?.refresh() } else if (profileFeedTab === 'reports') { if (pendingReportsRefreshRef.current) { pendingReportsRefreshRef.current = false } reportsFeedRef.current?.refresh() } else if (profileFeedTab === 'wall') { wallFeedRef.current?.refresh() } else if (profileFeedTab === 'liked') { likedFeedRef.current?.refresh() } }, [profileFeedTab]) 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}
)}
setOpenPublicMessageTo(pubkey) : undefined} onSendCallInvite={ !isSelf ? (url) => setOpenCallInviteTo({ pubkey, url }) : undefined } /> {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')} {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 ? ( <> {hasTipDialog && ( { if (open) setZapLightningDefault(null) setOpenZapDialog(open) if (!open) setZapLightningDefault(null) }} /> )} ) : null}
{username}
{isFollowingYou && (
{t('Follows you')}
)}
{/* 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 && ( { setZapLightningDefault(lightningAuthority) setOpenZapDialog(true) }} className="mt-2 mb-4 p-3 pb-4 border rounded-lg bg-muted/50 min-w-0" /> )} { const willOpen = typeof next === 'function' ? next(openZapDialog) : next setOpenZapDialog(willOpen) if (!willOpen) setZapLightningDefault(null) }} pubkey={pubkey} defaultLightningAddress={zapLightningDefault} prefetchedPayment={prefetchedZapPayment} />
{isSelf && }
{!isSelf && }
{ if ( v === 'posts' || v === 'media' || v === 'publications' || v === 'reports' || v === 'wall' || (isSelf && v === 'liked') ) { setProfileFeedTab(v) } }} className="min-w-0 pt-4" > {t('Posts')} {t('Media')} {t('Articles and Publications')} {t('Reports')} {t('Wall')} {isSelf && ( {t('Liked')} )} {profileFeedTab === 'reports' ? ( ) : null} {profileFeedTab === 'wall' ? ( ) : null} {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)} /> )} ) }