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.
 
 
 
 

641 lines
26 KiB

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 { 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 { getPaymentInfoFromEvent } from '@/lib/event-metadata'
import { toProfileEditor } from '@/lib/link'
import { generateImageByPubkey } from '@/lib/pubkey'
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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Copy, Ellipsis, Calendar, MapPin, Pencil, SatelliteDish, Code, Gift, Link } from 'lucide-react'
import {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type MutableRefObject,
type Ref
} 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 ProfileFeedWithPins from './ProfileFeedWithPins'
import ProfileMediaFeed from './ProfileMediaFeed'
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 PaytoLink from '@/components/PaytoLink'
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 { normalizeUrl } from '@/lib/url'
import type { TProfile } from '@/types'
/**
* 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<typeof getPaymentInfoFromEvent> | null,
profile: TProfile | null
): MergedPaymentMethod[] {
const seen = new Map<string, MergedPaymentMethod>()
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,
feedRef
}: {
id?: string
/** When set, exposes {@link ProfileFeedWithPins} `refresh` for titlebars / parent pages. */
feedRef?: Ref<{ refresh: () => void }>
}) {
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<TNoteListRef>(null)
const { profile, isFetching } = useFetchProfile(id)
const { pubkey: accountPubkey } = useNostr()
const [paymentInfo, setPaymentInfo] = useState<ReturnType<typeof getPaymentInfoFromEvent> | null>(null)
const [profileEvent, setProfileEvent] = useState<NostrEvent | undefined>(undefined)
const [openZapDialog, setOpenZapDialog] = useState(false)
const [openPublicMessageTo, setOpenPublicMessageTo] = useState<string | null>(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 { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays()
const { relaySets, favoriteRelays } = useFavoriteRelays()
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<string, MergedPaymentMethod[]>()
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])
// Fetch profile event (kind 0) for republishing and viewing JSON
// Use fetchProfileEvent which does comprehensive search, not fetchReplaceableEvent
useEffect(() => {
if (!profile?.pubkey) {
setProfileEvent(undefined)
return
}
const fetchProfileEventData = async () => {
try {
// Use fetchProfileEvent which includes comprehensive relay search
const event = await replaceableEventService.fetchProfileEvent(profile.pubkey, false)
if (event) {
setProfileEvent(event)
} else {
setProfileEvent(undefined)
}
} catch (error) {
logger.error('Failed to fetch profile event', { error, pubkey: profile.pubkey })
setProfileEvent(undefined)
}
}
fetchProfileEventData()
}, [profile?.pubkey])
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 => 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])
const handleRepublishToAllAvailable = async () => {
if (!profileEvent) return
const promise = client.publishEvent(allAvailableRelayUrls, profileEvent).then((result) => {
if (result.successCount < 1) {
throw new Error(t('No relay accepted the event'))
}
return result
})
toast.promise(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)
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
})()
toast.promise(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()
}
}
return () => {
m.current = null
}
}, [])
useEffect(() => {
if (!profile?.pubkey) return
const forceUpdateCache = async () => {
await Promise.all([
client.forceUpdateRelayListEvent(profile.pubkey),
replaceableEventService.fetchReplaceableEvent(profile.pubkey, kinds.Metadata)
])
}
forceUpdateCache()
}, [profile?.pubkey])
if (!profile && isFetching) {
return (
<>
<div>
<div className="relative bg-cover bg-center mb-2">
<Skeleton className="w-full aspect-[3/1] rounded-none" />
<Skeleton className="w-24 h-24 md:w-48 md:h-48 absolute bottom-0 left-3 translate-y-1/2 border-4 border-background rounded-full" />
</div>
</div>
<div className="px-4">
<Skeleton className="h-5 w-28 mt-14 md:mt-28 mb-1 md:ml-56" />
<Skeleton className="h-5 w-56 mt-2 my-1 rounded-full md:ml-56" />
</div>
<div className="px-4 pt-4 flex items-center justify-center">
<div className="text-sm text-muted-foreground">
{t('Searching all available relays...')}
</div>
</div>
</>
)
}
if (!profile && !isFetching) return <NotFound />
if (!profile) return null // TypeScript guard - should never reach here but satisfies type checker
const { banner, username, about, avatar, pubkey, website, websiteList, nip05List } = profile
return (
<>
<div>
<div className="relative bg-cover bg-center mb-2">
<ProfileBanner banner={banner} pubkey={pubkey} className="w-full aspect-[3/1]" />
<Avatar className="w-24 h-24 md:w-48 md:h-48 absolute left-3 bottom-0 translate-y-1/2 border-4 border-background">
<AvatarImage src={avatar} className="object-cover object-center" />
<AvatarFallback>
<img src={defaultImage} />
</AvatarFallback>
</Avatar>
</div>
<div className="px-4">
<div className="flex justify-end h-8 gap-2 items-center">
<ProfileOptions
pubkey={pubkey}
profileEvent={profileEvent}
onSendPublicMessage={!isSelf ? () => setOpenPublicMessageTo(pubkey) : undefined}
onSendCallInvite={
!isSelf
? (url) => setOpenCallInviteTo({ pubkey, url })
: undefined
}
/>
{isSelf ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" className="rounded-full">
<Ellipsis />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setOpenScheduleOwnCall(true)}>
<Calendar />
{t('Schedule a video call')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setOpenScheduleInPersonMeeting(true)}>
<MapPin />
{t('Schedule in-person meeting')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigatePrimary('spells', { spell: 'followPacks' })}>
<Gift />
{t('Follow Packs')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => push(toProfileEditor())}>
<Pencil />
{t('Edit')}
</DropdownMenuItem>
{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')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setIsRawEventDialogOpen(true)}>
<Code />
{t('View JSON')}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
) : (
<>
{mergedPaymentMethods.some((m) => m.type === 'lightning') && (
<ProfileZapButton pubkey={pubkey} openZapDialog={openZapDialog} setOpenZapDialog={setOpenZapDialog} />
)}
<FollowButton pubkey={pubkey} />
</>
)}
</div>
<div className="pt-2 md:pl-56">
<div className="flex gap-2 items-center">
<div className="text-xl font-semibold truncate select-text">{username}</div>
{isFollowingYou && (
<div className="text-muted-foreground rounded-full bg-muted text-xs h-fit px-2 shrink-0">
{t('Follows you')}
</div>
)}
</div>
<Nip05 pubkey={pubkey} />
{/* Display multiple NIP-05 values if available, with verification */}
{nip05List && nip05List.length > 1 && (
<Nip05List nip05List={nip05List.slice(1)} pubkey={pubkey} />
)}
<div className="flex gap-1 mt-1">
<PubkeyCopy pubkey={pubkey} />
<NpubQrCode pubkey={pubkey} />
</div>
<Collapsible>
<ProfileAbout
about={about}
className="text-wrap break-words whitespace-pre-wrap mt-2 select-text"
/>
</Collapsible>
{/* Display websites - show first one prominently, others below */}
{website && (
<div className="flex gap-1 items-center text-primary mt-2 truncate select-text">
<Link size={14} className="shrink-0" />
<a
href={website}
target="_blank"
rel="noopener noreferrer"
className="hover:underline truncate flex-1 max-w-fit w-0"
>
{website}
</a>
</div>
)}
{websiteList && websiteList.length > 1 && (
<div className="flex flex-col gap-1 mt-1">
{websiteList.slice(1).map((url: string, idx: number) => (
<div key={idx} className="flex gap-1 items-center text-primary truncate select-text">
<Link size={12} className="shrink-0" />
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="hover:underline truncate text-sm"
>
{url}
</a>
</div>
))}
</div>
)}
{/* Payment methods: merged from kind 10133 + profile lightning, deduplicated – use PaytoLink for consistent behavior */}
{paymentMethodsByType.length > 0 && (
<div className="mt-2 p-2 border rounded-lg bg-muted/50 min-w-0 overflow-hidden">
<div className="text-xs font-semibold text-muted-foreground mb-2">Payment Methods</div>
<div className="space-y-3 min-w-0">
{paymentMethodsByType.map((group, groupIdx) => (
<div key={groupIdx} className="text-sm min-w-0">
<div className="font-medium">{group.displayType}</div>
<div className="space-y-1.5 mt-1">
{group.methods.map((method, idx) => (
<div key={idx} className="min-w-0">
{method.authority && (
<div className="text-muted-foreground flex items-center gap-1 min-w-0">
<PaytoLink
type={method.type}
authority={method.authority}
paytoUri={method.payto}
pubkey={method.type === 'lightning' ? pubkey : undefined}
onOpenZap={method.type === 'lightning' ? () => setOpenZapDialog(true) : undefined}
className="hover:underline break-all min-w-0 text-primary flex-1"
>
{method.authority}
</PaytoLink>
<button
type="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
navigator.clipboard.writeText(method.authority)
toast.success(t('Copied to clipboard'))
}}
className="shrink-0 p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted"
title={t('Copy address')}
>
<Copy className="size-3.5" />
</button>
</div>
)}
{(method.currency || (method.minAmount !== undefined && method.maxAmount !== undefined)) && (
<div className="text-muted-foreground text-xs mt-0.5">
{method.currency && <span>({method.currency})</span>}
{method.minAmount !== undefined && method.maxAmount !== undefined && (
<span className="ml-2">
{method.minAmount}-{method.maxAmount}
</span>
)}
</div>
)}
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
<ZapDialog
open={openZapDialog}
setOpen={setOpenZapDialog}
pubkey={pubkey}
/>
<div className="flex justify-between items-center mt-2 text-sm">
<div className="flex gap-4 items-center">
<SmartFollowings pubkey={pubkey} />
<SmartRelays pubkey={pubkey} />
{isSelf && <SmartMuteLink />}
</div>
{!isSelf && <FollowedBy pubkey={pubkey} />}
</div>
</div>
</div>
</div>
<Tabs defaultValue="posts" className="min-w-0">
<TabsList className="mb-2 ml-1 w-auto justify-start md:ml-4">
<TabsTrigger value="posts">{t('Posts')}</TabsTrigger>
<TabsTrigger value="media">{t('Media')}</TabsTrigger>
</TabsList>
<TabsContent value="posts" className="min-w-0 focus-visible:outline-none">
<ProfileFeedWithPins ref={postsFeedRef} pubkey={pubkey} />
</TabsContent>
<TabsContent value="media" className="min-w-0 focus-visible:outline-none">
<ProfileMediaFeed ref={mediaFeedRef} pubkey={pubkey} />
</TabsContent>
</Tabs>
{openPublicMessageTo && (
<PostEditor
open={!!openPublicMessageTo}
setOpen={(open) => !open && setOpenPublicMessageTo(null)}
initialPublicMessageTo={openPublicMessageTo}
/>
)}
{openCallInviteTo && (
<PostEditor
open={!!openCallInviteTo}
setOpen={(open) => !open && setOpenCallInviteTo(null)}
initialPublicMessageTo={openCallInviteTo.pubkey}
defaultContent={`${t('Join the video call')}: ${openCallInviteTo.url}`}
/>
)}
<ScheduleVideoCallDialog
open={openScheduleOwnCall}
onOpenChange={setOpenScheduleOwnCall}
/>
<ScheduleInPersonMeetingDialog
open={openScheduleInPersonMeeting}
onOpenChange={setOpenScheduleInPersonMeeting}
/>
{profileEvent && (
<RawEventDialog
event={profileEvent}
isOpen={isRawEventDialogOpen}
onClose={() => setIsRawEventDialogOpen(false)}
/>
)}
</>
)
}