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.
622 lines
21 KiB
622 lines
21 KiB
<script lang="ts"> |
|
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; |
|
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; |
|
import PaymentAddresses from './PaymentAddresses.svelte'; |
|
import FeedPost from '../feed/FeedPost.svelte'; |
|
import ThreadDrawer from '../feed/ThreadDrawer.svelte'; |
|
import { fetchProfile, fetchUserStatus, type ProfileData } from '../../services/user-data.js'; |
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
|
import { relayManager } from '../../services/nostr/relay-manager.js'; |
|
import { sessionManager } from '../../services/auth/session-manager.js'; |
|
import { onMount } from 'svelte'; |
|
import { page } from '$app/stores'; |
|
import { nip19 } from 'nostr-tools'; |
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
import { KIND } from '../../types/kind-lookup.js'; |
|
|
|
let profile = $state<ProfileData | null>(null); |
|
let userStatus = $state<string | null>(null); |
|
let posts = $state<NostrEvent[]>([]); |
|
let responses = $state<NostrEvent[]>([]); |
|
let interactionsWithMe = $state<NostrEvent[]>([]); |
|
let loading = $state(true); |
|
let activeTab = $state<'posts' | 'responses' | 'interactions'>('posts'); |
|
let nip05Validations = $state<Record<string, boolean | null>>({}); // null = checking, true = valid, false = invalid |
|
|
|
// Cache for NIP-05 validation results (nip05+pubkey -> result) |
|
// This prevents re-validating the same NIP-05 address repeatedly |
|
const nip05ValidationCache = new Map<string, boolean>(); |
|
|
|
// Get current logged-in user's pubkey |
|
let currentUserPubkey = $state<string | null>(sessionManager.getCurrentPubkey()); |
|
|
|
// Drawer state for viewing threads |
|
let drawerOpen = $state(false); |
|
let drawerEvent = $state<NostrEvent | null>(null); |
|
|
|
function openDrawer(event: NostrEvent) { |
|
drawerEvent = event; |
|
drawerOpen = true; |
|
} |
|
|
|
function closeDrawer() { |
|
drawerOpen = false; |
|
drawerEvent = null; |
|
} |
|
|
|
// Subscribe to session changes |
|
$effect(() => { |
|
const unsubscribe = sessionManager.session.subscribe((session) => { |
|
currentUserPubkey = session?.pubkey || null; |
|
// Reload interactions if session changes and we're viewing another user's profile |
|
if (profile) { |
|
const pubkey = decodePubkey($page.params.pubkey); |
|
if (pubkey && currentUserPubkey && currentUserPubkey !== pubkey) { |
|
// Reload interactions tab data |
|
loadInteractionsWithMe(pubkey, currentUserPubkey); |
|
} else { |
|
interactionsWithMe = []; |
|
} |
|
} |
|
}); |
|
return unsubscribe; |
|
}); |
|
|
|
async function loadInteractionsWithMe(profilePubkey: string, currentUserPubkey: string) { |
|
if (!currentUserPubkey || currentUserPubkey === profilePubkey) { |
|
interactionsWithMe = []; |
|
return; |
|
} |
|
|
|
try { |
|
const interactionRelays = relayManager.getFeedResponseReadRelays(); |
|
|
|
// Fetch current user's posts from cache first (fast) |
|
const currentUserPosts = await nostrClient.fetchEvents( |
|
[{ kinds: [KIND.SHORT_TEXT_NOTE], authors: [currentUserPubkey], limit: 50 }], |
|
interactionRelays, |
|
{ useCache: true, cacheResults: true, timeout: 2000 } // Short timeout for cache |
|
); |
|
const currentUserPostIds = new Set(currentUserPosts.map(p => p.id)); |
|
|
|
// Only fetch interactions if we have some posts to check against |
|
if (currentUserPostIds.size === 0) { |
|
interactionsWithMe = []; |
|
return; |
|
} |
|
|
|
// Fetch interactions with timeout to prevent blocking |
|
const interactionEvents = await Promise.race([ |
|
nostrClient.fetchEvents( |
|
[ |
|
{ kinds: [KIND.SHORT_TEXT_NOTE], authors: [profilePubkey], '#e': Array.from(currentUserPostIds).slice(0, 20), limit: 20 }, // Limit IDs to avoid huge queries |
|
{ kinds: [KIND.SHORT_TEXT_NOTE], authors: [profilePubkey], '#p': [currentUserPubkey], limit: 20 } |
|
], |
|
interactionRelays, |
|
{ useCache: true, cacheResults: true, timeout: 5000 } |
|
), |
|
new Promise<NostrEvent[]>((resolve) => setTimeout(() => resolve([]), 5000)) // 5s timeout |
|
]); |
|
|
|
// Deduplicate and filter to only include actual interactions |
|
const seenIds = new Set<string>(); |
|
interactionsWithMe = interactionEvents |
|
.filter(e => { |
|
if (seenIds.has(e.id)) return false; |
|
seenIds.add(e.id); |
|
|
|
// Check if it's a reply to current user's post |
|
const eTag = e.tags.find(t => t[0] === 'e'); |
|
const isReplyToCurrentUser = eTag && currentUserPostIds.has(eTag[1]); |
|
|
|
// Check if it mentions current user |
|
const pTag = e.tags.find(t => t[0] === 'p' && t[1] === currentUserPubkey); |
|
const mentionsCurrentUser = !!pTag; |
|
|
|
return isReplyToCurrentUser || mentionsCurrentUser; |
|
}) |
|
.sort((a, b) => b.created_at - a.created_at); |
|
} catch (error) { |
|
console.debug('Error loading interactions with me:', error); |
|
interactionsWithMe = []; |
|
} |
|
} |
|
|
|
onMount(async () => { |
|
await nostrClient.initialize(); |
|
// Load profile after initialization |
|
if ($page.params.pubkey) { |
|
loadProfile(); |
|
} else { |
|
// No pubkey provided - show error state |
|
loading = false; |
|
profile = null; |
|
} |
|
}); |
|
|
|
// React to route param changes |
|
let lastParam = $state<string | null>(null); |
|
$effect(() => { |
|
const param = $page.params.pubkey; |
|
// Only reload if parameter actually changed |
|
if (param && param !== lastParam) { |
|
lastParam = param; |
|
loadProfile(); |
|
} |
|
}); |
|
|
|
/** |
|
* Decode route parameter to hex pubkey |
|
* Supports: hex pubkey, npub, or nprofile |
|
*/ |
|
function decodePubkey(param: string): string | null { |
|
if (!param) return null; |
|
|
|
// Check if it's already a hex pubkey (64 hex characters) |
|
if (/^[0-9a-f]{64}$/i.test(param)) { |
|
return param.toLowerCase(); |
|
} |
|
|
|
// Check if it's a bech32 encoded format (npub or nprofile) |
|
if (/^(npub|nprofile)1[a-z0-9]+$/i.test(param)) { |
|
try { |
|
const decoded = nip19.decode(param); |
|
if (decoded.type === 'npub') { |
|
return String(decoded.data); |
|
} else if (decoded.type === 'nprofile') { |
|
// nprofile contains pubkey and optional relays |
|
if (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) { |
|
return String(decoded.data.pubkey); |
|
} |
|
} |
|
} catch (error) { |
|
console.error('Error decoding bech32:', error); |
|
return null; |
|
} |
|
} |
|
|
|
return null; |
|
} |
|
|
|
/** |
|
* Get well-known.json URL for a NIP-05 address |
|
* Uses URL constructor like jumble for proper URL building |
|
*/ |
|
function getNIP05WellKnownUrl(nip05: string): string | null { |
|
const [localPart, domain] = nip05.split('@'); |
|
if (!localPart || !domain) { |
|
return null; |
|
} |
|
const url = new URL('/.well-known/nostr.json', `https://${domain}`); |
|
url.searchParams.set('name', localPart); |
|
return url.toString(); |
|
} |
|
|
|
/** |
|
* Validate NIP-05 address against well-known.json |
|
* Uses caching like jumble to avoid repeated lookups |
|
*/ |
|
async function validateNIP05(nip05: string, expectedPubkey: string) { |
|
console.log(`[NIP-05] Starting validation for ${nip05} with pubkey ${expectedPubkey.substring(0, 8)}...`); |
|
|
|
// Check cache first (like jumble does) |
|
const cacheKey = `${nip05}:${expectedPubkey}`; |
|
if (nip05ValidationCache.has(cacheKey)) { |
|
const cachedResult = nip05ValidationCache.get(cacheKey)!; |
|
console.log(`[NIP-05] Cache hit for ${nip05}: ${cachedResult}`); |
|
nip05Validations[nip05] = cachedResult; |
|
return; |
|
} |
|
|
|
// Mark as checking |
|
console.log(`[NIP-05] Cache miss, fetching for ${nip05}`); |
|
nip05Validations[nip05] = null; |
|
|
|
try { |
|
// Parse NIP-05: format is "local@domain.com" |
|
const [localPart, domain] = nip05.split('@'); |
|
if (!localPart || !domain) { |
|
console.log(`[NIP-05] Invalid format for ${nip05}`); |
|
nip05Validations[nip05] = false; |
|
nip05ValidationCache.set(cacheKey, false); |
|
return; |
|
} |
|
|
|
// Build URL using URL constructor (like jumble) |
|
const url = getNIP05WellKnownUrl(nip05); |
|
if (!url) { |
|
console.log(`[NIP-05] Failed to build URL for ${nip05}`); |
|
nip05Validations[nip05] = false; |
|
nip05ValidationCache.set(cacheKey, false); |
|
return; |
|
} |
|
|
|
console.log(`[NIP-05] Fetching ${url}`); |
|
|
|
// Add timeout to prevent hanging |
|
const controller = new AbortController(); |
|
const timeoutId = setTimeout(() => { |
|
console.log(`[NIP-05] Timeout reached for ${nip05}`); |
|
controller.abort(); |
|
}, 5000); // 5 second timeout |
|
|
|
try { |
|
const response = await fetch(url, { |
|
method: 'GET', |
|
headers: { |
|
'Accept': 'application/json' |
|
}, |
|
signal: controller.signal |
|
}); |
|
|
|
clearTimeout(timeoutId); |
|
console.log(`[NIP-05] Response status for ${nip05}: ${response.status}`); |
|
|
|
if (!response.ok) { |
|
console.log(`[NIP-05] Response not OK for ${nip05}: ${response.status}`); |
|
nip05Validations[nip05] = false; |
|
nip05ValidationCache.set(cacheKey, false); |
|
return; |
|
} |
|
|
|
let data; |
|
try { |
|
data = await response.json(); |
|
console.log(`[NIP-05] Parsed JSON for ${nip05}:`, data); |
|
} catch (jsonError) { |
|
console.error(`[NIP-05] Failed to parse JSON for ${nip05}:`, jsonError); |
|
nip05Validations[nip05] = false; |
|
nip05ValidationCache.set(cacheKey, false); |
|
return; |
|
} |
|
|
|
// Check if the response contains the expected pubkey |
|
// NIP-05 format: { "names": { "local": "hex-pubkey" } } |
|
const verifiedPubkey = data.names?.[localPart]; |
|
const isValid = verifiedPubkey && typeof verifiedPubkey === 'string' |
|
? verifiedPubkey.toLowerCase() === expectedPubkey.toLowerCase() |
|
: false; |
|
|
|
console.log(`[NIP-05] Validation result for ${nip05}: ${isValid} (verified: ${verifiedPubkey}, expected: ${expectedPubkey.substring(0, 8)}...)`); |
|
nip05Validations[nip05] = isValid; |
|
nip05ValidationCache.set(cacheKey, isValid); |
|
} catch (fetchError) { |
|
clearTimeout(timeoutId); |
|
// Check if it was aborted due to timeout |
|
if (fetchError instanceof Error && fetchError.name === 'AbortError') { |
|
console.log(`[NIP-05] Timeout/abort for ${nip05}`); |
|
nip05Validations[nip05] = false; |
|
nip05ValidationCache.set(cacheKey, false); |
|
} else { |
|
console.error(`[NIP-05] Fetch error for ${nip05}:`, fetchError); |
|
throw fetchError; // Re-throw other errors |
|
} |
|
} |
|
} catch (error) { |
|
console.error(`[NIP-05] Error validating ${nip05}:`, error); |
|
nip05Validations[nip05] = false; |
|
nip05ValidationCache.set(cacheKey, false); |
|
} |
|
|
|
console.log(`[NIP-05] Validation complete for ${nip05}`); |
|
} |
|
|
|
async function loadProfile() { |
|
const param = $page.params.pubkey; |
|
if (!param) { |
|
console.warn('No pubkey parameter provided to ProfilePage'); |
|
loading = false; |
|
profile = null; |
|
return; |
|
} |
|
|
|
// Decode the parameter to hex pubkey |
|
const pubkey = decodePubkey(param); |
|
if (!pubkey) { |
|
console.warn('Invalid pubkey format:', param); |
|
loading = false; |
|
profile = null; |
|
return; |
|
} |
|
|
|
loading = true; |
|
try { |
|
// Step 1: Load profile and status first (fast from cache) - display immediately |
|
const [profileData, status] = await Promise.all([ |
|
fetchProfile(pubkey), |
|
fetchUserStatus(pubkey) |
|
]); |
|
|
|
profile = profileData; |
|
userStatus = status; |
|
loading = false; // Show profile immediately, even if posts are still loading |
|
|
|
// Validate NIP-05 addresses in background (non-blocking) |
|
if (profileData?.nip05 && profileData.nip05.length > 0) { |
|
for (const nip05 of profileData.nip05) { |
|
validateNIP05(nip05, pubkey).catch(err => { |
|
console.error('[NIP-05] Unhandled validation error:', nip05, err); |
|
// Ensure state is set even on unhandled errors |
|
nip05Validations[nip05] = false; |
|
const cacheKey = `${nip05}:${pubkey}`; |
|
nip05ValidationCache.set(cacheKey, false); |
|
}); |
|
} |
|
} |
|
|
|
// Step 2: Load posts and responses in parallel (non-blocking, update when ready) |
|
const profileRelays = relayManager.getProfileReadRelays(); |
|
const responseRelays = relayManager.getFeedResponseReadRelays(); |
|
|
|
// Load posts first (needed for response filtering) |
|
const feedEvents = await nostrClient.fetchEvents( |
|
[{ kinds: [KIND.SHORT_TEXT_NOTE], authors: [pubkey], limit: 20 }], |
|
profileRelays, |
|
{ useCache: true, cacheResults: true, timeout: 5000 } |
|
); |
|
posts = feedEvents.sort((a, b) => b.created_at - a.created_at); |
|
|
|
// Load responses in parallel with posts (but filter after posts are loaded) |
|
const userPostIds = new Set(posts.map(p => p.id)); |
|
const responseEvents = await nostrClient.fetchEvents( |
|
[{ kinds: [KIND.SHORT_TEXT_NOTE], '#p': [pubkey], limit: 50 }], // Fetch more to account for filtering |
|
responseRelays, |
|
{ useCache: true, cacheResults: true, timeout: 5000 } |
|
); |
|
|
|
// Filter responses (exclude self-replies, only include replies to user's posts) |
|
responses = responseEvents |
|
.filter(e => { |
|
if (e.pubkey === pubkey) return false; // Exclude self-replies |
|
const eTag = e.tags.find(t => t[0] === 'e'); |
|
return eTag && userPostIds.has(eTag[1]); |
|
}) |
|
.sort((a, b) => b.created_at - a.created_at) |
|
.slice(0, 20); // Limit to 20 after filtering |
|
|
|
// Step 3: Load interactions in background (non-blocking) |
|
if (currentUserPubkey && currentUserPubkey !== pubkey) { |
|
loadInteractionsWithMe(pubkey, currentUserPubkey).catch(err => { |
|
console.debug('Error loading interactions:', err); |
|
}); |
|
} else { |
|
interactionsWithMe = []; |
|
} |
|
} catch (error) { |
|
console.error('Error loading profile:', error); |
|
loading = false; |
|
profile = null; |
|
} |
|
} |
|
</script> |
|
|
|
<div class="profile-page"> |
|
{#if loading} |
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading profile...</p> |
|
{:else if profile} |
|
<div class="profile-header mb-6"> |
|
{#if profile.picture} |
|
<img |
|
src={profile.picture} |
|
alt={profile.name || 'Profile picture'} |
|
class="profile-picture w-24 h-24 rounded-full mb-4" |
|
/> |
|
{/if} |
|
<h1 class="text-3xl font-bold mb-2">{profile.name || 'Anonymous'}</h1> |
|
{#if profile.about} |
|
<p class="text-fog-text dark:text-fog-dark-text mb-2">{profile.about}</p> |
|
{/if} |
|
{#if userStatus} |
|
<p class="text-sm text-fog-text-light dark:text-fog-dark-text-light italic mb-2"> |
|
{userStatus} |
|
</p> |
|
{/if} |
|
{#if profile.website && profile.website.length > 0} |
|
<div class="websites mb-2"> |
|
{#each profile.website as website} |
|
<a |
|
href={website} |
|
target="_blank" |
|
rel="noopener noreferrer" |
|
class="text-fog-accent dark:text-fog-dark-accent hover:underline mr-2" |
|
> |
|
{website} |
|
</a> |
|
{/each} |
|
</div> |
|
{/if} |
|
{#if profile.nip05 && profile.nip05.length > 0} |
|
<div class="nip05 mb-2"> |
|
{#each profile.nip05 as nip05} |
|
{@const isValid = nip05Validations[nip05]} |
|
{@const wellKnownUrl = getNIP05WellKnownUrl(nip05)} |
|
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light mr-2"> |
|
{nip05} |
|
{#if isValid === true} |
|
<span class="nip05-valid" title="NIP-05 verified">✓</span> |
|
{:else if isValid === false} |
|
{#if wellKnownUrl} |
|
<button |
|
onclick={() => window.open(wellKnownUrl, '_blank', 'noopener,noreferrer')} |
|
class="nip05-invalid-button" |
|
title="NIP-05 verification failed - Click to view well-known.json" |
|
aria-label="Open well-known.json for verification" |
|
> |
|
✗ |
|
</button> |
|
{:else} |
|
<span class="nip05-invalid" title="NIP-05 verification failed">✗</span> |
|
{/if} |
|
{:else} |
|
<span class="nip05-checking" title="Verifying NIP-05...">⟳</span> |
|
{/if} |
|
</span> |
|
{/each} |
|
</div> |
|
{/if} |
|
<PaymentAddresses pubkey={decodePubkey($page.params.pubkey) || ''} /> |
|
</div> |
|
|
|
<div class="profile-posts"> |
|
<div class="tabs mb-4 flex gap-4 border-b border-fog-border dark:border-fog-dark-border"> |
|
<button |
|
onclick={() => activeTab = 'posts'} |
|
class="px-4 py-2 font-semibold {activeTab === 'posts' ? 'border-b-2 border-fog-accent dark:border-fog-dark-accent' : ''}" |
|
> |
|
Posts ({posts.length}) |
|
</button> |
|
<button |
|
onclick={() => activeTab = 'responses'} |
|
class="px-4 py-2 font-semibold {activeTab === 'responses' ? 'border-b-2 border-fog-accent dark:border-fog-dark-accent' : ''}" |
|
> |
|
Responses ({responses.length}) |
|
</button> |
|
{#if currentUserPubkey && currentUserPubkey !== decodePubkey($page.params.pubkey)} |
|
<button |
|
onclick={() => activeTab = 'interactions'} |
|
class="px-4 py-2 font-semibold {activeTab === 'interactions' ? 'border-b-2 border-fog-accent dark:border-fog-dark-accent' : ''}" |
|
> |
|
Interactions with me ({interactionsWithMe.length}) |
|
</button> |
|
{/if} |
|
</div> |
|
|
|
{#if activeTab === 'posts'} |
|
{#if posts.length === 0} |
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">No posts yet.</p> |
|
{:else} |
|
<div class="posts-list"> |
|
{#each posts as post (post.id)} |
|
<FeedPost post={post} onOpenEvent={openDrawer} /> |
|
{/each} |
|
</div> |
|
{/if} |
|
{:else if activeTab === 'responses'} |
|
{#if responses.length === 0} |
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">No responses yet.</p> |
|
{:else} |
|
<div class="responses-list"> |
|
{#each responses as response (response.id)} |
|
<FeedPost post={response} onOpenEvent={openDrawer} /> |
|
{/each} |
|
</div> |
|
{/if} |
|
{:else if activeTab === 'interactions'} |
|
{#if interactionsWithMe.length === 0} |
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">No interactions with you yet.</p> |
|
{:else} |
|
<div class="interactions-list"> |
|
{#each interactionsWithMe as interaction (interaction.id)} |
|
<FeedPost post={interaction} onOpenEvent={openDrawer} /> |
|
{/each} |
|
</div> |
|
{/if} |
|
{/if} |
|
</div> |
|
{:else} |
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">Profile not found</p> |
|
{/if} |
|
|
|
<ThreadDrawer opEvent={drawerEvent} isOpen={drawerOpen} onClose={closeDrawer} /> |
|
</div> |
|
|
|
<style> |
|
.profile-page { |
|
max-width: var(--content-width); |
|
margin: 0 auto; |
|
padding: 1rem; |
|
} |
|
|
|
.profile-picture { |
|
object-fit: cover; |
|
} |
|
|
|
.profile-header { |
|
overflow-wrap: break-word; |
|
word-break: break-word; |
|
} |
|
|
|
.profile-header h1 { |
|
overflow-wrap: break-word; |
|
word-break: break-word; |
|
} |
|
|
|
.profile-header p { |
|
overflow-wrap: break-word; |
|
word-break: break-word; |
|
} |
|
|
|
.profile-header .websites { |
|
overflow-wrap: break-word; |
|
word-break: break-word; |
|
} |
|
|
|
.profile-header .websites a { |
|
display: inline-block; |
|
max-width: 100%; |
|
overflow-wrap: break-word; |
|
word-break: break-word; |
|
} |
|
|
|
.profile-header .nip05 { |
|
overflow-wrap: break-word; |
|
word-break: break-word; |
|
} |
|
|
|
.profile-header .nip05 span { |
|
display: inline-block; |
|
max-width: 100%; |
|
overflow-wrap: break-word; |
|
word-break: break-word; |
|
} |
|
|
|
.nip05-valid { |
|
color: #60a5fa; |
|
margin-left: 0.25rem; |
|
font-weight: bold; |
|
} |
|
|
|
.nip05-invalid { |
|
color: #ef4444; |
|
margin-left: 0.25rem; |
|
font-weight: bold; |
|
} |
|
|
|
.nip05-invalid-button { |
|
color: #ef4444; |
|
margin-left: 0.25rem; |
|
font-weight: bold; |
|
background: none; |
|
border: none; |
|
padding: 0; |
|
cursor: pointer; |
|
font-size: inherit; |
|
line-height: inherit; |
|
transition: opacity 0.2s; |
|
} |
|
|
|
.nip05-invalid-button:hover { |
|
opacity: 0.7; |
|
text-decoration: underline; |
|
} |
|
|
|
.nip05-invalid-button:active { |
|
opacity: 0.5; |
|
} |
|
|
|
.nip05-checking { |
|
color: #9ca3af; |
|
margin-left: 0.25rem; |
|
display: inline-block; |
|
animation: spin 1s linear infinite; |
|
} |
|
|
|
@keyframes spin { |
|
from { |
|
transform: rotate(0deg); |
|
} |
|
to { |
|
transform: rotate(360deg); |
|
} |
|
} |
|
</style>
|
|
|