import Collapsible from '@/components/Collapsible' 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 ProfileOptions from '@/components/ProfileOptions' import ProfileZapButton from '@/components/ProfileZapButton' import PubkeyCopy from '@/components/PubkeyCopy' import Tabs from '@/components/Tabs' import RetroRefreshButton from '@/components/ui/RetroRefreshButton' import ProfileSearchBar from '@/components/ui/ProfileSearchBar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { ExtendedKind } from '@/constants' import { useFetchProfile } from '@/hooks' import { Event, kinds } from 'nostr-tools' import { getPaymentInfoFromEvent } from '@/lib/event-metadata' import { toProfileEditor } from '@/lib/link' import { generateImageByPubkey } from '@/lib/pubkey' import { useSecondaryPage } from '@/PageManager' import { toNoteList } from '@/lib/link' import { parseAdvancedSearch } from '@/lib/search-parser' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' import { replaceableEventService } from '@/services/client.service' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { FileText, Link, Film, Copy, Ellipsis, Calendar, MapPin, Pencil } from 'lucide-react' import { useEffect, useMemo, useState, useRef } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import logger from '@/lib/logger' import NotFound from '../NotFound' import FollowedBy from './FollowedBy' import ProfileFeed from './ProfileFeed' import ProfileArticles from './ProfileArticles' import ProfileBookmarksAndHashtags from './ProfileBookmarksAndHashtags' import SmartFollowings from './SmartFollowings' import SmartMuteLink from './SmartMuteLink' import SmartRelays from './SmartRelays' import ProfileMedia from './ProfileMedia' import ProfileInteractions from './ProfileInteractions' import ProfileNotes from './ProfileNotes' import { toFollowPacks } from '@/lib/link' import ZapDialog from '@/components/ZapDialog' import PaytoLink from '@/components/PaytoLink' import PostEditor from '@/components/PostEditor' import { ScheduleVideoCallDialog, ScheduleInPersonMeetingDialog } from '@/components/ScheduleVideoCallDialog' import type { TProfile } from '@/types' type ProfileTabValue = 'posts' | 'pins' | 'bookmarks' | 'interests' | 'articles' | 'media' | 'you' | 'notes' /** * Normalize lightning/LUD-16 authority to a canonical form for deduplication. * Handles "user@domain" and "user.domain" (dot variant) as the same address. */ function normalizeLightningAuthority(authority: string): string { const s = authority.trim().toLowerCase() if (!s) return s if (s.includes('@')) return s const firstDot = s.indexOf('.') if (firstDot > 0) return s.slice(0, firstDot) + '@' + s.slice(firstDot + 1) return s } /** Normalize authority for deduplication (canonical key per type) */ function normalizePaymentAuthority(type: string, authority: string): string { const t = type.toLowerCase() if (t === 'lightning' && authority) return normalizeLightningAuthority(authority) return authority.trim().toLowerCase() } /** Prefer displaying lightning address in canonical "user@domain" form when we have both variants */ function preferCanonicalLightningAuthority(a: string, b: string): string { const hasAt = (s: string) => s.trim().includes('@') if (hasAt(a) && !hasAt(b)) return a if (hasAt(b) && !hasAt(a)) return b return a } type MergedPaymentMethod = { type: string authority: string payto?: string displayType: string currency?: string minAmount?: number maxAmount?: number } /** Merge payment methods from kind 10133 and profile (kind 0: JSON + tags), normalized and deduplicated */ function mergePaymentMethods( paymentInfo: ReturnType | null, profile: TProfile | null ): MergedPaymentMethod[] { const seen = new Map() const out: MergedPaymentMethod[] = [] const add = (type: string, authority: string, payto?: string, displayType?: string, extra?: { currency?: string; minAmount?: number; maxAmount?: number }) => { if (!authority?.trim()) return const normType = type.toLowerCase() const key = `${normType}:${normalizePaymentAuthority(normType, authority)}` const existing = seen.get(key) if (existing) { if (normType === 'lightning') { existing.authority = preferCanonicalLightningAuthority(existing.authority, authority.trim()) existing.payto = existing.payto || payto || (normType && authority ? `payto://${normType}/${existing.authority}` : undefined) } return } const entry: MergedPaymentMethod = { type: normType, authority: authority.trim(), payto: payto || (normType && authority ? `payto://${normType}/${authority.trim()}` : undefined), displayType: displayType || (normType === 'lightning' ? 'Lightning Network' : normType === 'bitcoin' ? 'Bitcoin' : type || 'Payment'), ...extra } seen.set(key, entry) out.push(entry) } // Aggregate: profile (kind 0) first – from lightningAddressList (tags + JSON) and single lightningAddress const fromProfile = profile?.lightningAddressList?.length ? profile.lightningAddressList : profile?.lightningAddress ? [profile.lightningAddress] : [] fromProfile.forEach((addr) => { if (addr) add('lightning', addr, `payto://lightning/${addr}`, 'Lightning Network') }) // Then kind 10133 (payto tags and JSON content) if (paymentInfo?.methods?.length) { paymentInfo.methods.forEach((m) => { const authority = m.authority || m.address || '' add( (m.type || 'lightning').toLowerCase(), authority, m.payto, m.displayType, { currency: m.currency, minAmount: m.minAmount, maxAmount: m.maxAmount } ) }) } else if (paymentInfo?.payto) { const type = (paymentInfo.type || 'lightning').toLowerCase() const authority = paymentInfo.authority || paymentInfo.payto.replace(/^payto:\/\/[^/]+\//, '') || '' add(type, authority, paymentInfo.payto, type === 'lightning' ? 'Lightning Network' : paymentInfo.type || 'Payment') } return out } export default function Profile({ id }: { id?: string }) { const { t } = useTranslation() const { push } = useSecondaryPage() const { profile, isFetching } = useFetchProfile(id) const { pubkey: accountPubkey } = useNostr() const [paymentInfo, setPaymentInfo] = useState | null>(null) const [openZapDialog, setOpenZapDialog] = 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 mergedPaymentMethods = useMemo(() => { const list = mergePaymentMethods(paymentInfo, profile ?? null) return [...list].sort((a, b) => { const rank = (type: string) => (type === 'lightning' ? 0 : type === 'bitcoin' ? 1 : 2) return rank(a.type) - rank(b.type) }) }, [paymentInfo, profile]) /** Group payment methods by displayType so same-type addresses render under one heading */ const paymentMethodsByType = useMemo(() => { const rank = (type: string) => (type === 'lightning' ? 0 : type === 'bitcoin' ? 1 : 2) const groups = new Map() for (const method of mergedPaymentMethods) { const key = method.displayType || method.type if (!groups.has(key)) groups.set(key, []) groups.get(key)!.push(method) } const order = Array.from(groups.keys()).sort((a, b) => { const arrA = groups.get(a) const arrB = groups.get(b) const typeA = arrA?.[0]?.type ?? '' const typeB = arrB?.[0]?.type ?? '' return rank(typeA) - rank(typeB) }) return order.map((key) => ({ displayType: key, methods: groups.get(key) ?? [] })) }, [mergedPaymentMethods]) // Fetch payment info (kind 10133) for this profile; uses cached replaceable events and IndexedDB useEffect(() => { if (!profile?.pubkey) { setPaymentInfo(null) return } const fetchPaymentInfo = async () => { try { const paymentEvent = await client.fetchPaymentInfoEvent(profile.pubkey) if (paymentEvent) { setPaymentInfo(getPaymentInfoFromEvent(paymentEvent)) } else { setPaymentInfo(null) } } catch (error) { logger.error('Failed to fetch payment info', { error, pubkey: profile.pubkey }) setPaymentInfo(null) } } fetchPaymentInfo() }, [profile?.pubkey]) const [activeTab, setActiveTab] = useState('posts') const [searchQuery, setSearchQuery] = useState('') const [articleKindFilter, setArticleKindFilter] = useState('all') const [postKindFilter, setPostKindFilter] = useState('all') const [mediaKindFilter, setMediaKindFilter] = useState('all') const [notesKindFilter, setNotesKindFilter] = useState('all') // Handle search in articles tab - parse advanced search parameters const handleArticleSearch = (query: string) => { if (activeTab === 'articles' && query.trim()) { const searchParams = parseAdvancedSearch(query) // Build kinds array from filter const kinds = articleKindFilter && articleKindFilter !== 'all' ? [parseInt(articleKindFilter)] : undefined // Note: Kind filter only available as URL parameter k=, not from search parser const allKinds = kinds // Build URL with search parameters // For now, if we have a d-tag, use that. Otherwise use advanced search if (searchParams.dtag) { // Use d-tag search if we have plain text const url = toNoteList({ domain: searchParams.dtag, kinds: allKinds }) push(url) return } else if (Object.keys(searchParams).length > 0) { // Advanced search - we'll need to pass these as URL params // For now, construct URL with all parameters const urlParams = new URLSearchParams() if (searchParams.title) { if (Array.isArray(searchParams.title)) { searchParams.title.forEach(t => urlParams.append('title', t)) } else { urlParams.set('title', searchParams.title) } } if (searchParams.subject) { if (Array.isArray(searchParams.subject)) { searchParams.subject.forEach(s => urlParams.append('subject', s)) } else { urlParams.set('subject', searchParams.subject) } } if (searchParams.description) { if (Array.isArray(searchParams.description)) { searchParams.description.forEach(d => urlParams.append('description', d)) } else { urlParams.set('description', searchParams.description) } } if (searchParams.author) { if (Array.isArray(searchParams.author)) { searchParams.author.forEach(a => urlParams.append('author', a)) } else { urlParams.set('author', searchParams.author) } } if (searchParams.type) { if (Array.isArray(searchParams.type)) { searchParams.type.forEach(t => urlParams.append('type', t)) } else { urlParams.set('type', searchParams.type) } } // Note: Date searches, pubkey filters, and event filters removed - not supported if (allKinds) { allKinds.forEach((k: number) => urlParams.append('k', k.toString())) } const url = `/notes?${urlParams.toString()}` push(url) return } } setSearchQuery(query) } // Refs for child components const profileFeedRef = useRef<{ refresh: () => void }>(null) const profileBookmarksRef = useRef<{ refresh: () => void }>(null) const profileArticlesRef = useRef<{ refresh: () => void; getEvents: () => Event[] }>(null) const profileMediaRef = useRef<{ refresh: () => void; getEvents: () => Event[] }>(null) const profileInteractionsRef = useRef<{ refresh: () => void; getEvents?: () => Event[] }>(null) const profileNotesRef = useRef<{ refresh: () => void; getEvents?: () => Event[] }>(null) const [articleEvents, setArticleEvents] = useState([]) const [postEvents, setPostEvents] = useState([]) const [mediaEvents, setMediaEvents] = useState([]) const [_interactionEvents, setInteractionEvents] = useState([]) const [notesEvents, setNotesEvents] = useState([]) 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 // Refresh functions for each tab const handleRefresh = () => { if (activeTab === 'posts') { profileFeedRef.current?.refresh() } else if (activeTab === 'articles') { profileArticlesRef.current?.refresh() } else if (activeTab === 'media') { profileMediaRef.current?.refresh() } else if (activeTab === 'you') { profileInteractionsRef.current?.refresh() } else if (activeTab === 'notes') { profileNotesRef.current?.refresh() } else { profileBookmarksRef.current?.refresh() } } // Define tabs with refresh buttons const tabs = useMemo(() => { const baseTabs = [ { value: 'posts', label: 'Posts' }, { value: 'articles', label: 'Articles' }, { value: 'media', label: 'Media' }, { value: 'pins', label: 'Pins' }, { value: 'bookmarks', label: 'Bookmarks' }, { value: 'interests', label: 'Interests' } ] // Add "My Notes" tab if viewing own profile if (isSelf) { baseTabs.push({ value: 'notes', label: 'My Notes' }) } // Add "You" tab if viewing another user's profile and logged in if (!isSelf && accountPubkey) { baseTabs.push({ value: 'you', label: 'You' }) } return baseTabs }, [isSelf, accountPubkey]) useEffect(() => { if (!profile?.pubkey) return const forceUpdateCache = async () => { await Promise.all([ client.forceUpdateRelayListEvent(profile.pubkey), replaceableEventService.fetchReplaceableEvent(profile.pubkey, kinds.Metadata) ]) } forceUpdateCache() }, [profile?.pubkey]) // Listen for tab restoration from PageManager useEffect(() => { const handleRestore = (e: CustomEvent<{ page: string, tab: string }>) => { if (e.detail.page === 'profile' && e.detail.tab) { setActiveTab(e.detail.tab as ProfileTabValue) } } window.addEventListener('restorePageTab', handleRestore as EventListener) return () => window.removeEventListener('restorePageTab', handleRestore as EventListener) }, []) if (!profile && isFetching) { return ( <>
) } if (!profile) return const { banner, username, about, avatar, pubkey, website, websiteList, nip05List } = profile logger.component('Profile', 'Profile data loaded', { pubkey, username, hasProfile: !!profile, isFetching, id }) return ( <>
setOpenPublicMessageTo(pubkey) : undefined} onSendCallInvite={ !isSelf ? (url) => setOpenCallInviteTo({ pubkey, url }) : undefined } /> {isSelf ? ( setOpenScheduleOwnCall(true)}> {t('Schedule a video call')} setOpenScheduleInPersonMeeting(true)}> {t('Schedule in-person meeting')} push(toFollowPacks())}> {t('Browse follow packs')} push(toProfileEditor())}> {t('Edit')} ) : ( <> {mergedPaymentMethods.some((m) => m.type === 'lightning') && ( )} )}
{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, idx) => ( ))}
)} {/* Payment methods: merged from kind 10133 + profile lightning, deduplicated – use PaytoLink for consistent behavior */} {paymentMethodsByType.length > 0 && (
Payment Methods
{paymentMethodsByType.map((group, groupIdx) => (
{group.displayType}
{group.methods.map((method, idx) => (
{method.authority && (
setOpenZapDialog(true) : undefined} className="hover:underline break-all min-w-0 text-primary flex-1" > {method.authority}
)} {(method.currency || (method.minAmount !== undefined && method.maxAmount !== undefined)) && (
{method.currency && ({method.currency})} {method.minAmount !== undefined && method.maxAmount !== undefined && ( {method.minAmount}-{method.maxAmount} )}
)}
))}
))}
)}
{isSelf && }
{!isSelf && }
{ setActiveTab(tab as ProfileTabValue) // Dispatch tab change event for PageManager window.dispatchEvent(new CustomEvent('pageTabChanged', { detail: { page: 'profile', tab: tab } })) }} threshold={800} />
{activeTab === 'posts' && (() => { const allCount = postEvents.length const noteCount = postEvents.filter((event) => event.kind === kinds.ShortTextNote).length const repostCount = postEvents.filter((event) => event.kind === kinds.Repost).length const commentCount = postEvents.filter((event) => event.kind === ExtendedKind.COMMENT).length const discussionCount = postEvents.filter((event) => event.kind === ExtendedKind.DISCUSSION).length const pollCount = postEvents.filter((event) => event.kind === ExtendedKind.POLL).length const superzapCount = postEvents.filter((event) => event.kind === ExtendedKind.ZAP_RECEIPT).length const calendarEventCount = postEvents.filter( (event) => event.kind === ExtendedKind.CALENDAR_EVENT_TIME || event.kind === ExtendedKind.CALENDAR_EVENT_DATE ).length return ( ) })()} {activeTab === 'articles' && (() => { const allCount = articleEvents.length const longFormCount = articleEvents.filter((e) => e.kind === kinds.LongFormArticle).length const wikiMarkdownCount = articleEvents.filter((e) => e.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN).length const wikiAsciiDocCount = articleEvents.filter((e) => e.kind === ExtendedKind.WIKI_ARTICLE).length const publicationCount = articleEvents.filter((e) => e.kind === ExtendedKind.PUBLICATION).length const highlightsCount = articleEvents.filter((e) => e.kind === kinds.Highlights).length return ( ) })()} {activeTab === 'media' && (() => { const allCount = mediaEvents.length const pictureCount = mediaEvents.filter((event) => event.kind === ExtendedKind.PICTURE).length const videoCount = mediaEvents.filter((event) => event.kind === ExtendedKind.VIDEO).length const shortVideoCount = mediaEvents.filter((event) => event.kind === ExtendedKind.SHORT_VIDEO).length const voiceCount = mediaEvents.filter((event) => event.kind === ExtendedKind.VOICE).length const voiceCommentCount = mediaEvents.filter((event) => event.kind === ExtendedKind.VOICE_COMMENT).length return ( ) })()} {activeTab === 'notes' && (() => { const allCount = notesEvents.length const publicationContentCount = notesEvents.filter((event) => event.kind === ExtendedKind.PUBLICATION_CONTENT).length const internalCitationCount = notesEvents.filter((event) => event.kind === ExtendedKind.CITATION_INTERNAL).length const externalCitationCount = notesEvents.filter((event) => event.kind === ExtendedKind.CITATION_EXTERNAL).length const hardcopyCitationCount = notesEvents.filter((event) => event.kind === ExtendedKind.CITATION_HARDCOPY).length const promptCitationCount = notesEvents.filter((event) => event.kind === ExtendedKind.CITATION_PROMPT).length return ( ) })()}
{activeTab === 'posts' && ( )} {activeTab === 'articles' && ( )} {activeTab === 'media' && ( )} {(activeTab === 'pins' || activeTab === 'bookmarks' || activeTab === 'interests') && ( )} {activeTab === 'notes' && ( )} {activeTab === 'you' && accountPubkey && ( )}
{openPublicMessageTo && ( !open && setOpenPublicMessageTo(null)} initialPublicMessageTo={openPublicMessageTo} /> )} {openCallInviteTo && ( !open && setOpenCallInviteTo(null)} initialPublicMessageTo={openCallInviteTo.pubkey} defaultContent={`${t('Join the video call')}: ${openCallInviteTo.url}`} /> )} ) }