diff --git a/src/components/EditProfileForm.tsx b/src/components/EditProfileForm.tsx index ea758b3a..ce093918 100644 --- a/src/components/EditProfileForm.tsx +++ b/src/components/EditProfileForm.tsx @@ -123,6 +123,7 @@ export const EditProfileForm: React.FC = ({ onSuccess }) = // Invalidate queries to refresh the data queryClient.invalidateQueries({ queryKey: ['logins'] }); queryClient.invalidateQueries({ queryKey: ['author', user.pubkey] }); + queryClient.invalidateQueries({ queryKey: ['funnelcake-profile', user.pubkey] }); queryClient.invalidateQueries({ queryKey: ['follow-list-safety-check'] }); toast({ diff --git a/src/components/LinkedAccounts.test.tsx b/src/components/LinkedAccounts.test.tsx new file mode 100644 index 00000000..c20c69a8 --- /dev/null +++ b/src/components/LinkedAccounts.test.tsx @@ -0,0 +1,95 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { LinkedAccounts } from './LinkedAccounts'; + +const mockUseExternalIdentities = vi.fn(); +const mockVerifyIdentityClaim = vi.fn(); + +vi.mock('@/hooks/useExternalIdentities', () => ({ + useExternalIdentities: (...args: unknown[]) => mockUseExternalIdentities(...args), + verifyIdentityClaim: (...args: unknown[]) => mockVerifyIdentityClaim(...args), + SUPPORTED_PLATFORMS: { + github: { + label: 'GitHub', + profileUrl: (id: string) => `https://github.com/${id}`, + proofUrl: (id: string, proof: string) => `https://gist.github.com/${id}/${proof}`, + verificationText: () => [], + canVerifyInBrowser: false, + }, + }, +})); + +vi.mock('@/lib/verificationCache', () => ({ + getCachedVerification: () => null, +})); + +function withQueryClient(children: ReactNode) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + return ( + + {children} + + ); +} + +describe('LinkedAccounts', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders verified identity badge on profile', async () => { + mockUseExternalIdentities.mockReturnValue({ + data: [ + { + platform: 'github', + identity: 'alice', + proof: 'abc123', + profileUrl: 'https://github.com/alice', + proofUrl: 'https://gist.github.com/alice/abc123', + }, + ], + isLoading: false, + }); + mockVerifyIdentityClaim.mockResolvedValue({ verified: true }); + + render(withQueryClient()); + + await waitFor(() => { + expect(screen.getByTestId('identity-badge-github')).toBeInTheDocument(); + }); + }); + + it('hides unverified identity badge on profile', async () => { + mockUseExternalIdentities.mockReturnValue({ + data: [ + { + platform: 'github', + identity: 'alice', + proof: 'abc123', + profileUrl: 'https://github.com/alice', + proofUrl: 'https://gist.github.com/alice/abc123', + }, + ], + isLoading: false, + }); + mockVerifyIdentityClaim.mockResolvedValue({ verified: false, error: 'manual' }); + + render(withQueryClient()); + + await waitFor(() => { + expect(mockVerifyIdentityClaim).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(screen.queryByTestId('identity-badge-github')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/LinkedAccounts.tsx b/src/components/LinkedAccounts.tsx index aa11b925..168a040f 100644 --- a/src/components/LinkedAccounts.tsx +++ b/src/components/LinkedAccounts.tsx @@ -56,6 +56,18 @@ function PlatformIcon({ platform, className }: { platform: string; className?: s ); + case 'youtube': + return ( + + + + ); + case 'tiktok': + return ( + + + + ); default: return ; } @@ -84,12 +96,9 @@ function IdentityBadge({ identity, pubkey }: { identity: ExternalIdentity; pubke initialData: cachedResult ?? undefined, }); - // Don't show badge on public profile if verification failed (prevents impersonation display) - // Show while loading, when verified, or when manual check is needed + // Only show externally-verifiable claims that are currently verified. const isVerified = verification.data?.verified; - const isManual = verification.data?.error === 'manual'; - const isStillLoading = verification.isLoading || verification.fetchStatus === 'idle'; - if (!isVerified && !isManual && !isStillLoading) return null; + if (!isVerified) return null; return ( diff --git a/src/components/ProfileHeader.test.tsx b/src/components/ProfileHeader.test.tsx index 97aa0aca..6ce809bf 100644 --- a/src/components/ProfileHeader.test.tsx +++ b/src/components/ProfileHeader.test.tsx @@ -1,6 +1,7 @@ import { render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { describe, expect, it, vi } from 'vitest'; +import { nip19 } from 'nostr-tools'; import { ProfileHeader } from './ProfileHeader'; import type { ProfileStats } from '@/lib/profileStats'; @@ -133,4 +134,45 @@ describe('ProfileHeader', () => { expect(screen.queryByRole('link', { name: /twitter \/ x/i })).not.toBeInTheDocument(); }); + + it('linkifies urls, hashtags, and nostr mentions in bio', () => { + const mentionPubkey = 'b'.repeat(64); + const npub = nip19.npubEncode(mentionPubkey); + const about = `Loops forever: https://divine.video #Divine nostr:${npub}`; + + render( + + + + ); + + expect(screen.getByRole('link', { name: 'https://divine.video' })).toHaveAttribute('href', 'https://divine.video/'); + expect(screen.getByRole('link', { name: '#Divine' })).toHaveAttribute('href', '/t/divine'); + expect(screen.getByRole('link', { name: `nostr:${npub}` })).toHaveAttribute('href', `/${npub}`); + }); + + it('normalizes website link and keeps row visible when non-empty', () => { + render( + + + + ); + + expect(screen.getByRole('link', { name: /divine.video\/profile\/alice/i })) + .toHaveAttribute('href', 'https://divine.video/profile/alice'); + }); }); diff --git a/src/components/ProfileHeader.tsx b/src/components/ProfileHeader.tsx index 49259ce0..5ce53659 100644 --- a/src/components/ProfileHeader.tsx +++ b/src/components/ProfileHeader.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import { Link } from 'react-router-dom'; import { getDivineNip05Info } from '@/lib/nip05Utils'; +import { linkifyProfileBioText, normalizeExternalUrl } from '@/lib/profileBioLinkify'; import { Button } from '@/components/ui/button'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; @@ -37,46 +38,6 @@ import { getDmConversationPath } from '@/lib/dm'; import type { LegacySocialLink } from '@/lib/legacySocials'; import type { ProfileStats } from '@/lib/profileStats'; -/** Linkify URLs and domain-like words in text */ -function linkifyText(text: string): React.ReactNode[] { - // Match URLs (https?://...) and bare domain names (word.tld patterns) - const urlRegex = /(https?:\/\/[^\s]+)|(\b[a-zA-Z0-9][-a-zA-Z0-9]*\.[a-zA-Z]{2,}(?:\.[a-zA-Z]{2,})?(?:\/[^\s]*)?)/g; - const parts: React.ReactNode[] = []; - let lastIndex = 0; - let match; - - while ((match = urlRegex.exec(text)) !== null) { - // Add text before the match - if (match.index > lastIndex) { - parts.push(text.slice(lastIndex, match.index)); - } - - const matchedText = match[0]; - const href = matchedText.startsWith('http') ? matchedText : `https://${matchedText}`; - - parts.push( - - {matchedText} - - ); - - lastIndex = match.index + matchedText.length; - } - - // Add remaining text - if (lastIndex < text.length) { - parts.push(text.slice(lastIndex)); - } - - return parts; -} - interface ProfileMetadata extends NostrMetadata { _stillLoadingName?: boolean; // Flag to indicate name is still being fetched } @@ -161,6 +122,8 @@ export function ProfileHeader({ const profileImage = getSafeProfileImage(metadata?.picture) || '/user-avatar.png'; const about = metadata?.about; const website = metadata?.website; + const websiteHref = normalizeExternalUrl(website); + const websiteLabel = website?.trim(); const handleFollowClick = () => { onFollowToggle(!isFollowing); @@ -275,12 +238,13 @@ export function ProfileHeader({ ) : null} - {/* Website - hide if it's just a divine.video profile URL */} - {website && !website.includes('divine.video/profile/') && ( + {/* Website */} + {websiteLabel && websiteHref && ( @@ -288,9 +252,9 @@ export function ProfileHeader({ {/* Bio */} {about && ( -

- {linkifyText(about)} -

+
+ {linkifyProfileBioText(about)} +
)} diff --git a/src/hooks/useExternalIdentities.test.ts b/src/hooks/useExternalIdentities.test.ts index 36c4b6fe..df95c9c2 100644 --- a/src/hooks/useExternalIdentities.test.ts +++ b/src/hooks/useExternalIdentities.test.ts @@ -177,6 +177,45 @@ describe('parseIdentityTag', () => { proofUrl: '', }); }); + + it('maps x platform to twitter config', () => { + const tag = ['i', 'x:alice', '12345']; + const result = parseIdentityTag(tag); + + expect(result).toEqual({ + platform: 'twitter', + identity: 'alice', + proof: '12345', + profileUrl: 'https://twitter.com/alice', + proofUrl: 'https://twitter.com/alice/status/12345', + }); + }); + + it('parses a YouTube identity tag', () => { + const tag = ['i', 'youtube:alice', 'dQw4w9WgXcQ']; + const result = parseIdentityTag(tag); + + expect(result).toEqual({ + platform: 'youtube', + identity: 'alice', + proof: 'dQw4w9WgXcQ', + profileUrl: 'https://www.youtube.com/@alice', + proofUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + }); + }); + + it('parses a TikTok identity tag', () => { + const tag = ['i', 'tiktok:alice', '7399477729245037867']; + const result = parseIdentityTag(tag); + + expect(result).toEqual({ + platform: 'tiktok', + identity: 'alice', + proof: '7399477729245037867', + profileUrl: 'https://www.tiktok.com/@alice', + proofUrl: 'https://www.tiktok.com/@alice/video/7399477729245037867', + }); + }); }); // -- SUPPORTED_PLATFORMS config -- @@ -189,6 +228,8 @@ describe('SUPPORTED_PLATFORMS', () => { expect(SUPPORTED_PLATFORMS.telegram.canVerifyInBrowser).toBe(false); expect(SUPPORTED_PLATFORMS.bluesky.canVerifyInBrowser).toBe(false); expect(SUPPORTED_PLATFORMS.discord.canVerifyInBrowser).toBe(false); + expect(SUPPORTED_PLATFORMS.youtube.canVerifyInBrowser).toBe(false); + expect(SUPPORTED_PLATFORMS.tiktok.canVerifyInBrowser).toBe(false); }); it('generates correct Mastodon URLs for NIP-39 format', () => { @@ -279,6 +320,20 @@ describe('SUPPORTED_PLATFORMS', () => { expect(SUPPORTED_PLATFORMS.discord.createProofUrl).toBeUndefined(); }); + it('generates correct YouTube URLs', () => { + const config = SUPPORTED_PLATFORMS.youtube; + expect(config.profileUrl('alice')).toBe('https://www.youtube.com/@alice'); + expect(config.proofUrl('alice', 'dQw4w9WgXcQ')) + .toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ'); + }); + + it('generates correct TikTok URLs', () => { + const config = SUPPORTED_PLATFORMS.tiktok; + expect(config.profileUrl('alice')).toBe('https://www.tiktok.com/@alice'); + expect(config.proofUrl('alice', '7399477729245037867')) + .toBe('https://www.tiktok.com/@alice/video/7399477729245037867'); + }); + it('generates correct Discord URLs', () => { const config = SUPPORTED_PLATFORMS.discord; expect(config.profileUrl('alice')) @@ -320,7 +375,7 @@ describe('verifyIdentityClaim', () => { }); it('returns manual for non-CORS platforms without fetching', async () => { - const platforms = ['twitter', 'mastodon', 'telegram', 'bluesky', 'discord'] as const; + const platforms = ['twitter', 'mastodon', 'telegram', 'bluesky', 'discord', 'youtube', 'tiktok'] as const; for (const platform of platforms) { const identity: ExternalIdentity = { diff --git a/src/hooks/useExternalIdentities.ts b/src/hooks/useExternalIdentities.ts index 6eb8283a..692f0526 100644 --- a/src/hooks/useExternalIdentities.ts +++ b/src/hooks/useExternalIdentities.ts @@ -26,6 +26,23 @@ export interface PlatformConfig { canVerifyInBrowser: boolean; } +function buildYouTubeProfileUrl(identity: string): string { + const normalized = identity.trim(); + if (normalized.startsWith('@')) return `https://www.youtube.com/${normalized}`; + if (normalized.startsWith('channel/') || normalized.startsWith('c/') || normalized.startsWith('user/')) { + return `https://www.youtube.com/${normalized}`; + } + return `https://www.youtube.com/@${normalized}`; +} + +function buildYouTubeProofUrl(proof: string): string { + const normalized = proof.trim(); + if (normalized.startsWith('shorts/') || normalized.startsWith('watch?') || normalized.startsWith('live/')) { + return `https://www.youtube.com/${normalized}`; + } + return `https://www.youtube.com/watch?v=${normalized}`; +} + /** Well-known platforms with their profile URL patterns per NIP-39 */ const PLATFORM_CONFIG: Record = { github: { @@ -104,6 +121,26 @@ const PLATFORM_CONFIG: Record = { ], canVerifyInBrowser: false, }, + youtube: { + label: 'YouTube', + profileUrl: (id) => buildYouTubeProfileUrl(id), + proofUrl: (_id, proof) => buildYouTubeProofUrl(proof), + verificationText: (npub) => [ + `verify connecting this account with my divine account: ${npub}`, + `Verifying that I control the following Nostr public key: "${npub}"`, + ], + canVerifyInBrowser: false, + }, + tiktok: { + label: 'TikTok', + profileUrl: (id) => `https://www.tiktok.com/@${id.replace(/^@/, '')}`, + proofUrl: (id, proof) => `https://www.tiktok.com/@${id.replace(/^@/, '')}/video/${proof}`, + verificationText: (npub) => [ + `verify connecting this account with my divine account: ${npub}`, + `Verifying that I control the following Nostr public key: "${npub}"`, + ], + canVerifyInBrowser: false, + }, }; export const SUPPORTED_PLATFORMS = PLATFORM_CONFIG; @@ -114,7 +151,8 @@ export function parseIdentityTag(tag: string[]): ExternalIdentity | null { const colonIndex = tag[1].indexOf(':'); if (colonIndex === -1) return null; - const platform = tag[1].slice(0, colonIndex).toLowerCase(); + const rawPlatform = tag[1].slice(0, colonIndex).toLowerCase(); + const platform = rawPlatform === 'x' ? 'twitter' : rawPlatform; const identity = tag[1].slice(colonIndex + 1); const proof = tag[2] || ''; @@ -177,6 +215,14 @@ function cleanProofId(platform: string, proof: string): string { case 'bluesky': return parts.pop() || proof; case 'telegram': return parts.slice(-2).join('/') || proof; case 'discord': return parts.pop() || proof; + case 'youtube': { + if (url.hostname.includes('youtu.be')) return parts[0] || proof; + if (parts[0] === 'watch') return url.searchParams.get('v') || proof; + if (parts[0] === 'shorts' || parts[0] === 'live') return parts[1] || proof; + return parts.pop() || proof; + } + case 'tiktok': + return parts.pop() || proof; default: return proof; } } catch { diff --git a/src/lib/profileBioLinkify.tsx b/src/lib/profileBioLinkify.tsx new file mode 100644 index 00000000..4b286444 --- /dev/null +++ b/src/lib/profileBioLinkify.tsx @@ -0,0 +1,112 @@ +import type { ReactNode } from 'react'; +import { SmartLink } from '@/components/SmartLink'; + +const PROFILE_BIO_TOKEN_REGEX = /(https?:\/\/[^\s]+)|(\b[a-zA-Z0-9][-a-zA-Z0-9]*\.[a-zA-Z]{2,}(?:\.[a-zA-Z]{2,})?(?:\/[^\s]*)?)|(nostr:(?:npub1|note1|nprofile1|nevent1)[023456789acdefghjklmnpqrstuvwxyz]+)|(#([A-Za-z_][A-Za-z0-9_]*))/g; + +function trimUrlPunctuation(input: string): { value: string; suffix: string } { + let value = input; + let suffix = ''; + + while (value.length > 0 && /[),.!?]$/.test(value)) { + suffix = value.slice(-1) + suffix; + value = value.slice(0, -1); + } + + return { value, suffix }; +} + +export function normalizeExternalUrl(input: string | undefined): string | null { + const trimmed = input?.trim(); + if (!trimmed) return null; + + const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + + try { + const parsed = new URL(withProtocol); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return null; + } + return parsed.toString(); + } catch { + return null; + } +} + +export function linkifyProfileBioText(text: string): ReactNode[] { + const parts: ReactNode[] = []; + let lastIndex = 0; + let keyCounter = 0; + let match: RegExpExecArray | null; + + while ((match = PROFILE_BIO_TOKEN_REGEX.exec(text)) !== null) { + const fullMatch = match[0]; + const urlWithProtocol = match[1]; + const bareDomain = match[2]; + const nostrRef = match[3]; + const hashtag = match[4]; + const index = match.index; + + if (index > lastIndex) { + parts.push(text.slice(lastIndex, index)); + } + + if (urlWithProtocol || bareDomain) { + const { value, suffix } = trimUrlPunctuation(fullMatch); + const href = normalizeExternalUrl(value); + + if (href) { + parts.push( + + {value} + , + ); + } else { + parts.push(value); + } + + if (suffix) { + parts.push(suffix); + } + } else if (nostrRef) { + const nostrId = nostrRef.slice('nostr:'.length); + parts.push( + + {nostrRef} + , + ); + } else if (hashtag) { + const normalizedTag = hashtag.slice(1).toLowerCase(); + parts.push( + + {hashtag} + , + ); + } + + lastIndex = index + fullMatch.length; + } + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + if (parts.length === 0) { + parts.push(text); + } + + return parts; +} diff --git a/src/pages/LinkedAccountsSettingsPage.tsx b/src/pages/LinkedAccountsSettingsPage.tsx index 3f95b6bc..64d575fc 100644 --- a/src/pages/LinkedAccountsSettingsPage.tsx +++ b/src/pages/LinkedAccountsSettingsPage.tsx @@ -1,5 +1,5 @@ // ABOUTME: Settings page for NIP-39 external identity verification (linked accounts) -// ABOUTME: Manage linked platform accounts (GitHub, Twitter, Mastodon, Telegram) with proof verification +// ABOUTME: Manage linked platform accounts with proof verification and publishing import { useState, useCallback, useRef, useEffect } from 'react'; import { Link } from 'react-router-dom'; @@ -45,6 +45,22 @@ function DiscordIcon({ className }: { className?: string }) { ); } +function YouTubeIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function TikTokIcon({ className }: { className?: string }) { + return ( + + + + ); +} + const PLATFORM_ICONS: Record = { github: , twitter: , @@ -52,6 +68,8 @@ const PLATFORM_ICONS: Record = { telegram: , bluesky: , discord: , + youtube: , + tiktok: , }; const PROOF_PLACEHOLDERS: Record = { @@ -61,6 +79,8 @@ const PROOF_PLACEHOLDERS: Record = { telegram: 'https://t.me/channelname/123', bluesky: 'https://bsky.app/profile/you/post/abc123', discord: 'https://discord.gg/AbCdEf', + youtube: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + tiktok: 'https://www.tiktok.com/@you/video/123456789', }; /** Extract identity and proof from a URL, or return input as proof only */ @@ -109,6 +129,35 @@ function extractFromUrl(platform: string, input: string): { identity?: string; p const code = parts.pop() || trimmed; return { identity: code, proof: code }; } + case 'youtube': { + // https://www.youtube.com/@channel or /channel/UC... + proof from watch/shorts URLs + const watchId = url.searchParams.get('v'); + if (watchId) { + return { proof: watchId }; + } + if (url.hostname.includes('youtu.be') && parts[0]) { + return { proof: parts[0] }; + } + if ((parts[0] === 'shorts' || parts[0] === 'live') && parts[1]) { + return { proof: parts[1] }; + } + if (parts[0]?.startsWith('@')) { + return { identity: parts[0].slice(1), proof: trimmed }; + } + if ((parts[0] === 'channel' || parts[0] === 'c' || parts[0] === 'user') && parts[1]) { + return { identity: `${parts[0]}/${parts[1]}`, proof: trimmed }; + } + return { proof: parts.pop() || trimmed }; + } + case 'tiktok': + // https://www.tiktok.com/@user/video/123... + if (parts.length >= 3 && parts[0].startsWith('@') && parts[1] === 'video') { + return { identity: parts[0].slice(1), proof: parts[2] }; + } + if (parts[0]?.startsWith('@')) { + return { identity: parts[0].slice(1), proof: trimmed }; + } + return { proof: parts.pop() || trimmed }; default: return { proof: trimmed }; } @@ -124,6 +173,8 @@ const PROOF_INSTRUCTIONS: Record = { telegram: 'Send a message in a public channel/group containing the text below, then paste the message path (e.g. channelname/123).', bluesky: 'Post on Bluesky containing the text below, then paste the record key (rkey) from the post URL.', discord: 'Create a Discord server with your npub in the server name or description, then create a permanent invite link and paste the invite code.', + youtube: 'Create a public YouTube proof post (video/community/description containing the text below), then paste the proof URL.', + tiktok: 'Create a TikTok post containing the text below, then paste the post URL.', }; function CopyButton({ text }: { text: string }) { diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index d455d804..ee20ce71 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -125,6 +125,12 @@ export function ProfilePage() { // 2. Nostr relays (WebSocket) - fallback for users not in Funnelcake const nostrMeta = authorData?.metadata; const fcMeta = funnelcakeProfile; + const isOwnProfile = currentUser?.pubkey === pubkey; + const pickMetadataField = (nostrValue: T | undefined, funnelcakeValue: T | undefined): T | undefined => ( + isOwnProfile + ? (nostrValue || funnelcakeValue) + : (funnelcakeValue || nostrValue) + ); // Check if we have a real name from either source const hasNameFromFunnelcake = !!(fcMeta?.display_name || fcMeta?.name); @@ -133,16 +139,18 @@ export function ProfilePage() { // Still loading name if: no name from either source AND either query is still in progress const stillLoadingName = !hasNameFromFunnelcake && !hasNameFromNostr && (funnelcakeLoading || authorLoading); - // Build metadata object - prefer Funnelcake (fast) then Nostr + // Build metadata object: + // - own profile: prefer Nostr immediately so edits show up without REST lag + // - other profiles: prefer Funnelcake first for fast cached reads const metadata = { - display_name: fcMeta?.display_name || nostrMeta?.display_name, - name: fcMeta?.name || nostrMeta?.name, - picture: fcMeta?.picture || nostrMeta?.picture || '/user-avatar.png', - about: fcMeta?.about || nostrMeta?.about, - banner: fcMeta?.banner || nostrMeta?.banner, - nip05: fcMeta?.nip05 || nostrMeta?.nip05, - website: fcMeta?.website || nostrMeta?.website, - lud16: fcMeta?.lud16 || nostrMeta?.lud16, + display_name: pickMetadataField(nostrMeta?.display_name, fcMeta?.display_name), + name: pickMetadataField(nostrMeta?.name, fcMeta?.name), + picture: pickMetadataField(nostrMeta?.picture, fcMeta?.picture) || '/user-avatar.png', + about: pickMetadataField(nostrMeta?.about, fcMeta?.about), + banner: pickMetadataField(nostrMeta?.banner, fcMeta?.banner), + nip05: pickMetadataField(nostrMeta?.nip05, fcMeta?.nip05), + website: pickMetadataField(nostrMeta?.website, fcMeta?.website), + lud16: pickMetadataField(nostrMeta?.lud16, fcMeta?.lud16), // Flag to indicate name is still loading (used by ProfileHeader) _stillLoadingName: stillLoadingName, }; @@ -225,9 +233,6 @@ export function ProfilePage() { !!currentUser?.pubkey // Only check if user is logged in ); - // Check if this is the current user's own profile - const isOwnProfile = currentUser?.pubkey === pubkey; - const displayName = metadata?.display_name || metadata?.name || (pubkey ? genUserName(pubkey) : 'User'); // RSS auto-discovery link for feed readers (only if feed endpoints exist)