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.
1212 lines
40 KiB
1212 lines
40 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 CommentComponent from '../comments/Comment.svelte'; |
|
import ProfileMenu from '../../components/profile/ProfileMenu.svelte'; |
|
import { fetchProfile, fetchUserStatus, fetchUserStatusEvent, fetchRelayLists, type ProfileData } from '../../services/user-data.js'; |
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
|
import { relayManager } from '../../services/nostr/relay-manager.js'; |
|
import { config } from '../../services/nostr/config.js'; |
|
import { sessionManager } from '../../services/auth/session-manager.js'; |
|
import { onMount } from 'svelte'; |
|
import { page } from '$app/stores'; |
|
import { goto } from '$app/navigation'; |
|
import { nip19 } from 'nostr-tools'; |
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
import { KIND } from '../../types/kind-lookup.js'; |
|
import CommentForm from '../comments/CommentForm.svelte'; |
|
import { getProfile } from '../../services/cache/profile-cache.js'; |
|
|
|
|
|
let profile = $state<ProfileData | null>(null); |
|
let profileEvent = $state<NostrEvent | null>(null); // The kind 0 event |
|
let userStatus = $state<string | null>(null); |
|
let userStatusEvent = $state<NostrEvent | null>(null); // The kind 30315 event |
|
let notifications = $state<NostrEvent[]>([]); |
|
let interactionsWithMe = $state<NostrEvent[]>([]); |
|
let wallComments = $state<NostrEvent[]>([]); // Kind 1111 comments on the wall |
|
let loading = $state(true); |
|
let loadingWall = $state(false); |
|
let activeTab = $state<'pins' | 'notifications' | 'interactions' | 'wall'>('pins'); |
|
let nip05Validations = $state<Record<string, boolean | null>>({}); // null = checking, true = valid, false = invalid |
|
// Compute pubkey from route params |
|
let profilePubkey = $derived.by(() => decodePubkey($page.params.pubkey)); |
|
|
|
// Initialize activeTab from URL parameter |
|
function getTabFromUrl(): 'pins' | 'notifications' | 'interactions' | 'wall' { |
|
const tabParam = $page.url.searchParams.get('tab'); |
|
const validTabs: Array<'pins' | 'notifications' | 'interactions' | 'wall'> = ['pins', 'notifications', 'interactions', 'wall']; |
|
if (tabParam && validTabs.includes(tabParam as any)) { |
|
return tabParam as 'pins' | 'notifications' | 'interactions' | 'wall'; |
|
} |
|
return 'pins'; // Default |
|
} |
|
|
|
// Update activeTab when URL changes |
|
$effect(() => { |
|
const urlTab = getTabFromUrl(); |
|
if (urlTab !== activeTab) { |
|
activeTab = urlTab; |
|
// Load data for the tab if needed |
|
if (activeTab === 'wall' && profileEvent && wallComments.length === 0 && !loadingWall) { |
|
loadWallComments(profileEvent.id); |
|
} |
|
} |
|
}); |
|
|
|
// Function to change tab and update URL |
|
async function setActiveTab(tab: 'pins' | 'notifications' | 'interactions' | 'wall') { |
|
activeTab = tab; |
|
const url = new URL($page.url); |
|
url.searchParams.set('tab', tab); |
|
goto(url.pathname + url.search, { replaceState: true, noScroll: true }); |
|
|
|
// Handle tab-specific logic |
|
if (tab === 'wall') { |
|
// Load profile event if not loaded yet |
|
if (!profileEvent && profilePubkey) { |
|
await loadProfileEvent(profilePubkey); |
|
} |
|
// Load wall comments if profile event is available and not already loaded |
|
if (profileEvent && wallComments.length === 0 && !loadingWall) { |
|
await loadWallComments(profileEvent.id); |
|
} |
|
} |
|
} |
|
|
|
// 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()); |
|
|
|
// Pins state |
|
let pins = $state<NostrEvent[]>([]); |
|
|
|
// Cleanup tracking |
|
let isMounted = $state(true); |
|
let activeFetchPromises = $state<Set<Promise<any>>>(new Set()); |
|
let currentLoadPubkey = $state<string | null>(null); // Track which pubkey is currently being loaded |
|
let loadAbortController = $state<AbortController | null>(null); // Abort controller for current load |
|
|
|
// Cleanup on unmount |
|
$effect(() => { |
|
return () => { |
|
isMounted = false; |
|
activeFetchPromises.clear(); |
|
if (loadAbortController) { |
|
loadAbortController.abort(); |
|
loadAbortController = null; |
|
} |
|
loading = false; |
|
currentLoadPubkey = null; |
|
}; |
|
}); |
|
|
|
const isOwnProfile = $derived.by(() => { |
|
const pubkey = decodePubkey($page.params.pubkey); |
|
return currentUserPubkey && pubkey && currentUserPubkey === pubkey; |
|
}); |
|
|
|
// Subscribe to session changes |
|
$effect(() => { |
|
const unsubscribe = sessionManager.session.subscribe((session) => { |
|
currentUserPubkey = session?.pubkey || null; |
|
// Reload notifications/interactions if session changes |
|
if (profile) { |
|
const pubkey = decodePubkey($page.params.pubkey); |
|
if (pubkey && currentUserPubkey) { |
|
if (currentUserPubkey === pubkey) { |
|
// Reload notifications for own profile |
|
loadNotifications(pubkey); |
|
} else { |
|
// Reload interactions for other user's profile |
|
loadInteractionsWithMe(pubkey, currentUserPubkey); |
|
} |
|
} |
|
} |
|
}); |
|
return unsubscribe; |
|
}); |
|
|
|
/** |
|
* Get relays for loading a profile, including the profile owner's relay lists |
|
*/ |
|
async function getProfileRelaysForPubkey(pubkey: string): Promise<string[]> { |
|
// Start with base profile relays |
|
const baseRelays = relayManager.getProfileReadRelays(); |
|
|
|
// Try to fetch relay lists from the profile owner (non-blocking) |
|
try { |
|
const { inbox } = await Promise.race([ |
|
fetchRelayLists(pubkey, baseRelays), |
|
new Promise<{ inbox: string[]; outbox: string[] }>((resolve) => |
|
setTimeout(() => resolve({ inbox: [], outbox: [] }), 2000) |
|
) |
|
]); |
|
|
|
// Combine base relays with profile owner's inbox relays |
|
const allRelays = [...baseRelays, ...inbox]; |
|
|
|
// Normalize and deduplicate |
|
const normalized = allRelays.map(r => r.toLowerCase().trim()).filter(r => r.length > 0); |
|
return Array.from(new Set(normalized)); |
|
} catch (error) { |
|
// If fetching relay lists fails, just use base relays |
|
return baseRelays; |
|
} |
|
} |
|
|
|
async function loadProfileEvent(pubkey: string) { |
|
if (!isMounted) return; |
|
try { |
|
// Try cache first |
|
const cached = await getProfile(pubkey); |
|
if (cached) { |
|
profileEvent = cached.event; |
|
// Don't load wall comments here - load them when Wall tab is clicked |
|
return; |
|
} |
|
|
|
// Fetch from relays if not in cache - shorter timeout |
|
// Include profile owner's relay lists |
|
const relays = await getProfileRelaysForPubkey(pubkey); |
|
const events = await nostrClient.fetchEvents( |
|
[{ kinds: [KIND.METADATA], authors: [pubkey], limit: 1 }], |
|
relays, |
|
{ useCache: 'cache-first', cacheResults: true, timeout: config.shortTimeout } |
|
); |
|
|
|
if (events.length > 0 && isMounted) { |
|
profileEvent = events[0]; |
|
// Don't load wall comments here - load them when Wall tab is clicked |
|
} |
|
} catch (error) { |
|
// Failed to load profile event - non-critical |
|
} |
|
} |
|
|
|
async function loadWallComments(profileEventId: string) { |
|
if (!isMounted || !profileEventId) return; |
|
|
|
// Load from cache first (fast - instant display) |
|
try { |
|
const { getRecentCachedEvents } = await import('../../services/cache/event-cache.js'); |
|
const cachedComments = await getRecentCachedEvents([KIND.COMMENT], 60 * 60 * 1000, 100); // 1 hour cache |
|
|
|
// Filter to only comments that reference this profile event as root |
|
const filtered = cachedComments.filter(comment => { |
|
const kTag = comment.tags.find(t => t[0] === 'K' && t[1] === '0'); |
|
const eTag = comment.tags.find(t => t[0] === 'E' && t[1] === profileEventId); |
|
return kTag && eTag; |
|
}); |
|
|
|
if (filtered.length > 0 && isMounted) { |
|
wallComments = filtered.sort((a, b) => b.created_at - a.created_at); |
|
loadingWall = false; // Show cached content immediately |
|
} else { |
|
loadingWall = true; // Only show loading if no cache |
|
} |
|
} catch (error) { |
|
// Cache error is non-critical |
|
loadingWall = true; // Show loading if cache check fails |
|
} |
|
|
|
// Stream fresh data from relays (progressive enhancement) |
|
try { |
|
// Fetch kind 1111 comments that reference this kind 0 event |
|
// NIP-22 format: K="0", E=profileEventId |
|
const relays = relayManager.getCommentReadRelays(); |
|
const comments = await nostrClient.fetchEvents( |
|
[ |
|
{ |
|
kinds: [KIND.COMMENT], |
|
'#K': ['0'], // Root kind is 0 |
|
'#E': [profileEventId], // Root event is the profile event |
|
limit: 100 |
|
} |
|
], |
|
relays, |
|
{ |
|
useCache: 'cache-first', // Already shown cache above, now stream updates |
|
cacheResults: true, |
|
timeout: config.mediumTimeout, |
|
onUpdate: (newComments) => { |
|
if (!isMounted) return; |
|
// Merge with existing comments |
|
const commentMap = new Map(wallComments.map(c => [c.id, c])); |
|
for (const comment of newComments) { |
|
const kTag = comment.tags.find(t => t[0] === 'K' && t[1] === '0'); |
|
const eTag = comment.tags.find(t => t[0] === 'E' && t[1] === profileEventId); |
|
if (kTag && eTag) { |
|
commentMap.set(comment.id, comment); |
|
} |
|
} |
|
wallComments = Array.from(commentMap.values()).sort((a, b) => b.created_at - a.created_at); |
|
loadingWall = false; |
|
} |
|
} |
|
); |
|
|
|
if (!isMounted) return; |
|
|
|
// Filter to only comments that actually reference this profile event as root |
|
// and sort by created_at descending |
|
wallComments = comments |
|
.filter(comment => { |
|
const kTag = comment.tags.find(t => t[0] === 'K' && t[1] === '0'); |
|
const eTag = comment.tags.find(t => t[0] === 'E' && t[1] === profileEventId); |
|
return kTag && eTag; |
|
}) |
|
.sort((a, b) => b.created_at - a.created_at); |
|
} catch (error) { |
|
// Failed to load wall comments |
|
if (isMounted) { |
|
wallComments = []; |
|
} |
|
} finally { |
|
if (isMounted) { |
|
loadingWall = false; |
|
} |
|
} |
|
} |
|
|
|
async function loadPins(pubkey: string) { |
|
if (!isMounted) return; |
|
try { |
|
// Fetch the user's pin list (kind 10001) - cache first, short timeout |
|
const profileRelays = relayManager.getProfileReadRelays(); |
|
const pinLists = await nostrClient.fetchEvents( |
|
[{ kinds: [KIND.PIN_LIST], authors: [pubkey], limit: 1 }], |
|
profileRelays, |
|
{ useCache: 'cache-first', cacheResults: true, timeout: config.shortTimeout } |
|
); |
|
|
|
if (!isMounted || pinLists.length === 0) { |
|
if (isMounted) pins = []; |
|
return; |
|
} |
|
|
|
// Extract event IDs from pin list |
|
const pinnedIds = new Set<string>(); |
|
for (const tag of pinLists[0].tags) { |
|
if (tag[0] === 'e' && tag[1]) { |
|
pinnedIds.add(tag[1]); |
|
} |
|
} |
|
|
|
if (pinnedIds.size === 0) { |
|
if (isMounted) pins = []; |
|
return; |
|
} |
|
|
|
// Fetch the actual pinned events with cache-first and streaming |
|
// Use shorter timeout since cache should be fast |
|
const fetchPromise = nostrClient.fetchEvents( |
|
[{ ids: Array.from(pinnedIds), limit: config.feedLimit }], |
|
profileRelays, |
|
{ |
|
useCache: 'cache-first', // Load from cache first |
|
cacheResults: true, |
|
timeout: config.shortTimeout, // Shorter timeout for cache-first |
|
onUpdate: (newPins) => { |
|
if (!isMounted) return; |
|
// Merge with existing pins |
|
const pinMap = new Map(pins.map(p => [p.id, p])); |
|
for (const pin of newPins) { |
|
pinMap.set(pin.id, pin); |
|
} |
|
pins = Array.from(pinMap.values()).sort((a, b) => b.created_at - a.created_at); |
|
} |
|
} |
|
); |
|
activeFetchPromises.add(fetchPromise); |
|
const pinnedEvents = await fetchPromise; |
|
activeFetchPromises.delete(fetchPromise); |
|
|
|
if (!isMounted) return; |
|
|
|
// Sort by created_at descending |
|
pins = pinnedEvents.sort((a, b) => b.created_at - a.created_at); |
|
} catch (error) { |
|
// Failed to load pins |
|
if (isMounted) pins = []; |
|
} |
|
} |
|
|
|
async function loadNotifications(pubkey: string) { |
|
if (!isMounted) return; |
|
try { |
|
const notificationRelays = relayManager.getFeedReadRelays(); |
|
|
|
// Fetch user's posts to find replies - cache first, shorter timeout |
|
const userPosts = await nostrClient.fetchEvents( |
|
[{ kinds: [KIND.SHORT_TEXT_NOTE], authors: [pubkey], limit: 100 }], |
|
notificationRelays, |
|
{ useCache: 'cache-first', cacheResults: true, timeout: config.shortTimeout } |
|
); |
|
|
|
if (!isMounted) return; |
|
|
|
const userPostIds = new Set(userPosts.map(p => p.id)); |
|
|
|
// Only fetch notifications if we have posts to check |
|
if (userPostIds.size === 0) { |
|
if (isMounted) notifications = []; |
|
return; |
|
} |
|
|
|
// Fetch notifications: replies, mentions, reactions with cache-first and streaming |
|
// Use shorter timeout since cache should be fast |
|
const notificationEvents = await nostrClient.fetchEvents( |
|
[ |
|
{ kinds: [KIND.SHORT_TEXT_NOTE], '#e': Array.from(userPostIds).slice(0, 50), limit: 100 }, // Replies to user's posts |
|
{ kinds: [KIND.SHORT_TEXT_NOTE], '#p': [pubkey], limit: 100 }, // Mentions |
|
{ kinds: [KIND.REACTION], '#p': [pubkey], limit: 100 } // Reactions |
|
], |
|
notificationRelays, |
|
{ |
|
useCache: 'cache-first', // Load from cache first |
|
cacheResults: true, |
|
timeout: config.shortTimeout, // Shorter timeout for cache-first |
|
onUpdate: (newNotifications) => { |
|
if (!isMounted) return; |
|
// Merge with existing notifications |
|
const notificationMap = new Map(notifications.map(n => [n.id, n])); |
|
for (const notification of newNotifications) { |
|
notificationMap.set(notification.id, notification); |
|
} |
|
notifications = Array.from(notificationMap.values()).sort((a, b) => b.created_at - a.created_at); |
|
} |
|
} |
|
); |
|
|
|
if (!isMounted) return; |
|
|
|
// Deduplicate and sort by created_at descending |
|
const seenIds = new Set<string>(); |
|
notifications = notificationEvents |
|
.filter(e => { |
|
if (seenIds.has(e.id)) return false; |
|
seenIds.add(e.id); |
|
// Exclude user's own events |
|
return e.pubkey !== pubkey; |
|
}) |
|
.sort((a, b) => b.created_at - a.created_at) |
|
.slice(0, 100); // Limit to 100 most recent |
|
} catch (error) { |
|
// Failed to load notifications |
|
if (isMounted) notifications = []; |
|
} |
|
} |
|
|
|
async function loadInteractionsWithMe(profilePubkey: string, currentUserPubkey: string) { |
|
if (!isMounted || !currentUserPubkey || currentUserPubkey === profilePubkey) { |
|
if (isMounted) interactionsWithMe = []; |
|
return; |
|
} |
|
|
|
try { |
|
const interactionRelays = relayManager.getFeedResponseReadRelays(); |
|
|
|
// Fetch current user's posts from cache first (fast) - shorter timeout |
|
const fetchPromise1 = nostrClient.fetchEvents( |
|
[{ kinds: [KIND.SHORT_TEXT_NOTE], authors: [currentUserPubkey], limit: config.mediumBatchLimit }], |
|
interactionRelays, |
|
{ useCache: 'cache-first', cacheResults: true, timeout: config.shortTimeout } // Short timeout for cache |
|
); |
|
activeFetchPromises.add(fetchPromise1); |
|
const currentUserPosts = await fetchPromise1; |
|
activeFetchPromises.delete(fetchPromise1); |
|
|
|
if (!isMounted) return; |
|
|
|
const currentUserPostIds = new Set(currentUserPosts.map(p => p.id)); |
|
|
|
// Only fetch interactions if we have some posts to check against |
|
if (currentUserPostIds.size === 0) { |
|
if (isMounted) interactionsWithMe = []; |
|
return; |
|
} |
|
|
|
// Fetch interactions with cache-first and streaming - shorter timeout |
|
const fetchPromise2 = nostrClient.fetchEvents( |
|
[ |
|
{ kinds: [KIND.SHORT_TEXT_NOTE], authors: [profilePubkey], '#e': Array.from(currentUserPostIds).slice(0, config.smallBatchLimit), limit: config.smallBatchLimit }, // Limit IDs to avoid huge queries |
|
{ kinds: [KIND.SHORT_TEXT_NOTE], authors: [profilePubkey], '#p': [currentUserPubkey], limit: config.smallBatchLimit } |
|
], |
|
interactionRelays, |
|
{ useCache: 'cache-first', cacheResults: true, timeout: config.shortTimeout } // Shorter timeout for cache-first |
|
); |
|
activeFetchPromises.add(fetchPromise2); |
|
const interactionEvents = await fetchPromise2; |
|
activeFetchPromises.delete(fetchPromise2); |
|
|
|
if (!isMounted) return; |
|
|
|
// 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) { |
|
// Cache error is non-critical |
|
interactionsWithMe = []; |
|
} |
|
} |
|
|
|
|
|
onMount(async () => { |
|
await nostrClient.initialize(); |
|
// Initialize tab from URL |
|
activeTab = getTabFromUrl(); |
|
// 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) { |
|
// Invalid bech32 format |
|
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) { |
|
// Check cache first |
|
const cacheKey = `${nip05}:${expectedPubkey}`; |
|
if (nip05ValidationCache.has(cacheKey)) { |
|
nip05Validations[nip05] = nip05ValidationCache.get(cacheKey)!; |
|
return; |
|
} |
|
|
|
nip05Validations[nip05] = null; |
|
|
|
try { |
|
const [localPart, domain] = nip05.split('@'); |
|
if (!localPart || !domain) { |
|
nip05Validations[nip05] = false; |
|
nip05ValidationCache.set(cacheKey, false); |
|
return; |
|
} |
|
|
|
const url = getNIP05WellKnownUrl(nip05); |
|
if (!url) { |
|
nip05Validations[nip05] = false; |
|
nip05ValidationCache.set(cacheKey, false); |
|
return; |
|
} |
|
|
|
const controller = new AbortController(); |
|
const timeoutId = setTimeout(() => controller.abort(), 5000); |
|
|
|
try { |
|
const response = await fetch(url, { |
|
method: 'GET', |
|
headers: { 'Accept': 'application/json' }, |
|
signal: controller.signal |
|
}); |
|
|
|
clearTimeout(timeoutId); |
|
|
|
if (!response.ok) { |
|
nip05Validations[nip05] = false; |
|
nip05ValidationCache.set(cacheKey, false); |
|
return; |
|
} |
|
|
|
let data; |
|
try { |
|
data = await response.json(); |
|
} catch { |
|
nip05Validations[nip05] = false; |
|
nip05ValidationCache.set(cacheKey, false); |
|
return; |
|
} |
|
|
|
const verifiedPubkey = data.names?.[localPart]; |
|
const isValid = verifiedPubkey && typeof verifiedPubkey === 'string' |
|
? verifiedPubkey.toLowerCase() === expectedPubkey.toLowerCase() |
|
: false; |
|
|
|
nip05Validations[nip05] = isValid; |
|
nip05ValidationCache.set(cacheKey, isValid); |
|
} catch (fetchError) { |
|
clearTimeout(timeoutId); |
|
if (fetchError instanceof Error && fetchError.name === 'AbortError') { |
|
nip05Validations[nip05] = false; |
|
nip05ValidationCache.set(cacheKey, false); |
|
} else { |
|
throw fetchError; |
|
} |
|
} |
|
} catch (error) { |
|
nip05Validations[nip05] = false; |
|
nip05ValidationCache.set(cacheKey, false); |
|
} |
|
} |
|
|
|
async function loadProfile() { |
|
if (!isMounted) return; |
|
const param = $page.params.pubkey; |
|
if (!param) { |
|
// Invalid route parameter |
|
loading = false; |
|
profile = null; |
|
currentLoadPubkey = null; |
|
return; |
|
} |
|
|
|
// Decode the parameter to hex pubkey |
|
const pubkey = decodePubkey(param); |
|
if (!pubkey) { |
|
// Invalid pubkey format |
|
loading = false; |
|
profile = null; |
|
currentLoadPubkey = null; |
|
return; |
|
} |
|
|
|
// Cancel any previous load for a different pubkey |
|
if (loadAbortController && currentLoadPubkey !== pubkey) { |
|
loadAbortController.abort(); |
|
loadAbortController = null; |
|
} |
|
|
|
// If we're already loading this pubkey, don't start another load |
|
if (currentLoadPubkey === pubkey && loading) { |
|
return; |
|
} |
|
|
|
// Create new abort controller for this load |
|
const abortController = new AbortController(); |
|
loadAbortController = abortController; |
|
currentLoadPubkey = pubkey; |
|
|
|
loading = true; |
|
try { |
|
// Step 0: Get relays including profile owner's relay lists (non-blocking, with timeout) |
|
const profileRelaysPromise = getProfileRelaysForPubkey(pubkey); |
|
|
|
// Step 1: Load profile and status first (fast from cache) - display immediately |
|
// fetchProfile uses parseProfile which prioritizes tags over JSON content |
|
// Use profile owner's relays if available, otherwise fall back to default |
|
const relaysPromise = profileRelaysPromise.catch(() => relayManager.getProfileReadRelays()); |
|
const relays = await relaysPromise; |
|
|
|
const profilePromise = fetchProfile(pubkey, relays); |
|
const statusPromise = fetchUserStatus(pubkey, relays); |
|
const statusEventPromise = fetchUserStatusEvent(pubkey, relays); |
|
activeFetchPromises.add(profilePromise); |
|
activeFetchPromises.add(statusPromise); |
|
activeFetchPromises.add(statusEventPromise); |
|
const [profileData, status, statusEvent] = await Promise.all([profilePromise, statusPromise, statusEventPromise]); |
|
activeFetchPromises.delete(profilePromise); |
|
activeFetchPromises.delete(statusPromise); |
|
activeFetchPromises.delete(statusEventPromise); |
|
|
|
// Check if this load was aborted or if pubkey changed |
|
if (!isMounted || abortController.signal.aborted || currentLoadPubkey !== pubkey) { |
|
return; |
|
} |
|
|
|
profile = profileData; |
|
userStatus = status; |
|
userStatusEvent = statusEvent; |
|
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 => { |
|
// NIP-05 validation failed |
|
// Ensure state is set even on unhandled errors |
|
nip05Validations[nip05] = false; |
|
const cacheKey = `${nip05}:${pubkey}`; |
|
nip05ValidationCache.set(cacheKey, false); |
|
}); |
|
} |
|
} |
|
|
|
// Load pins and notifications/interactions in parallel (non-blocking) |
|
// Don't wait for these - they'll update the UI as they load |
|
const loadPromises: Promise<void>[] = []; |
|
|
|
// Always load pins (for both own and other profiles) |
|
loadPromises.push(loadPins(pubkey).catch(() => { |
|
// Failed to load pins - non-critical |
|
})); |
|
|
|
// Load notifications or interactions based on profile type |
|
if (isOwnProfile) { |
|
loadPromises.push(loadNotifications(pubkey).catch(() => { |
|
// Failed to load notifications - non-critical |
|
})); |
|
interactionsWithMe = []; |
|
} else { |
|
notifications = []; |
|
// Load interactions if logged in and viewing another user's profile |
|
if (currentUserPubkey) { |
|
loadPromises.push(loadInteractionsWithMe(pubkey, currentUserPubkey).catch(() => { |
|
// Failed to load interactions - non-critical |
|
})); |
|
} else { |
|
interactionsWithMe = []; |
|
} |
|
} |
|
|
|
// Wait for initial loads to complete to set default tab |
|
Promise.all(loadPromises).then(() => { |
|
if (!isMounted || abortController.signal.aborted || currentLoadPubkey !== pubkey) return; |
|
|
|
if (isOwnProfile) { |
|
// Set default tab: if no pins, default to notifications |
|
if (pins.length === 0 && notifications.length > 0) { |
|
activeTab = 'notifications'; |
|
} else if (pins.length > 0) { |
|
activeTab = 'pins'; |
|
} |
|
} else { |
|
// Set default tab to pins if available |
|
if (pins.length > 0) { |
|
activeTab = 'pins'; |
|
} |
|
} |
|
}); |
|
|
|
// Load profile event in background (only needed for wall tab) |
|
// Don't await - load it when user clicks Wall tab |
|
loadProfileEvent(pubkey).catch(() => { |
|
// Failed to load profile event - non-critical |
|
}); |
|
} catch (error) { |
|
// Only update state if this load wasn't aborted |
|
if (!abortController.signal.aborted && currentLoadPubkey === pubkey) { |
|
// Failed to load profile |
|
loading = false; |
|
profile = null; |
|
} |
|
} finally { |
|
// Clear load tracking if this was the current load |
|
if (currentLoadPubkey === pubkey) { |
|
loadAbortController = null; |
|
currentLoadPubkey = 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 rounded-full mb-4" |
|
/> |
|
{/if} |
|
<h1 class="profile-name 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 userStatusEvent && userStatusEvent.content && userStatusEvent.content.trim()} |
|
<div class="user-status-content mb-2"> |
|
<MarkdownRenderer content={userStatusEvent.content} event={userStatusEvent} /> |
|
</div> |
|
{:else if userStatus && userStatus.trim()} |
|
<p class="text-fog-text-light dark:text-fog-dark-text-light italic mb-2" style="font-size: 0.875em;"> |
|
{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-fog-text-light dark:text-fog-dark-text-light mr-2" style="font-size: 0.875em;"> |
|
{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} |
|
|
|
{#if profilePubkey} |
|
<div class="profile-npub-section mb-2"> |
|
<div class="npub-display"> |
|
<code class="npub-text">{nip19.npubEncode(profilePubkey)}</code> |
|
<ProfileMenu pubkey={profilePubkey} /> |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<PaymentAddresses pubkey={decodePubkey($page.params.pubkey) || ''} /> |
|
</div> |
|
|
|
<div class="profile-posts"> |
|
<div class="tabs mb-4 flex gap-2 sm:gap-4 border-b border-fog-border dark:border-fog-dark-border overflow-x-auto scrollbar-hide"> |
|
<button |
|
onclick={() => setActiveTab('pins')} |
|
class="px-2 sm:px-4 py-2 font-semibold whitespace-nowrap flex-shrink-0 {activeTab === 'pins' ? 'border-b-2 border-fog-accent dark:border-fog-dark-accent' : ''}" |
|
> |
|
Pins ({pins.length}) |
|
</button> |
|
<button |
|
onclick={() => setActiveTab('wall')} |
|
class="px-2 sm:px-4 py-2 font-semibold whitespace-nowrap flex-shrink-0 {activeTab === 'wall' ? 'border-b-2 border-fog-accent dark:border-fog-dark-accent' : ''}" |
|
> |
|
Wall ({wallComments.length}) |
|
</button> |
|
{#if isOwnProfile} |
|
<button |
|
onclick={() => setActiveTab('notifications')} |
|
class="px-2 sm:px-4 py-2 font-semibold whitespace-nowrap flex-shrink-0 {activeTab === 'notifications' ? 'border-b-2 border-fog-accent dark:border-fog-dark-accent' : ''}" |
|
> |
|
Notifications ({notifications.length}) |
|
</button> |
|
{:else if currentUserPubkey && currentUserPubkey !== profilePubkey} |
|
<button |
|
onclick={() => setActiveTab('interactions')} |
|
class="px-2 sm:px-4 py-2 font-semibold whitespace-nowrap flex-shrink-0 {activeTab === 'interactions' ? 'border-b-2 border-fog-accent dark:border-fog-dark-accent' : ''}" |
|
> |
|
Interactions ({interactionsWithMe.length}) |
|
</button> |
|
{/if} |
|
</div> |
|
|
|
{#if activeTab === 'pins'} |
|
{#if pins.length === 0} |
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">No pinned posts yet.</p> |
|
{:else} |
|
<div class="pins-list"> |
|
{#each pins as pin (pin.id)} |
|
<FeedPost post={pin} /> |
|
{/each} |
|
</div> |
|
{/if} |
|
{:else if activeTab === 'notifications'} |
|
{#if notifications.length === 0} |
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">No notifications yet.</p> |
|
{:else} |
|
<div class="notifications-list"> |
|
{#each notifications as notification (notification.id)} |
|
<FeedPost post={notification} /> |
|
{/each} |
|
</div> |
|
{/if} |
|
{:else if activeTab === 'wall'} |
|
{#if profileEvent} |
|
<div class="wall-section"> |
|
{#if sessionManager.isLoggedIn()} |
|
<div class="wall-form mb-6"> |
|
<h3 class="wall-title mb-4">Write on the Wall</h3> |
|
<CommentForm |
|
threadId={profileEvent.id} |
|
rootEvent={profileEvent} |
|
onPublished={async () => { |
|
// Reload wall comments after publishing |
|
if (profileEvent) { |
|
await loadWallComments(profileEvent.id); |
|
} |
|
}} |
|
/> |
|
</div> |
|
{:else} |
|
<p class="text-fog-text-light dark:text-fog-dark-text-light mb-4">Log in to write on the wall</p> |
|
{/if} |
|
|
|
<div class="wall-comments-section"> |
|
<h3 class="wall-title mb-4">Wall Posts</h3> |
|
{#if loadingWall} |
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading wall...</p> |
|
{:else if wallComments.length === 0} |
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">No wall posts yet. Be the first to write on the wall!</p> |
|
{:else} |
|
<div class="wall-comments"> |
|
{#each wallComments as comment (comment.id)} |
|
<CommentComponent |
|
comment={comment} |
|
rootEventKind={KIND.METADATA} |
|
/> |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
</div> |
|
{:else} |
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading profile event...</p> |
|
{/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} /> |
|
{/each} |
|
</div> |
|
{/if} |
|
{/if} |
|
</div> |
|
{:else} |
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">Profile not found</p> |
|
{/if} |
|
</div> |
|
|
|
<style> |
|
.profile-page { |
|
max-width: var(--content-width); |
|
margin: 0 auto; |
|
padding: 0.75rem; |
|
} |
|
|
|
@media (min-width: 640px) { |
|
.profile-page { |
|
padding: 1rem; |
|
} |
|
} |
|
|
|
.profile-picture { |
|
object-fit: cover; |
|
width: 4rem; |
|
height: 4rem; |
|
} |
|
|
|
@media (min-width: 640px) { |
|
.profile-picture { |
|
width: 6rem; |
|
height: 6rem; |
|
} |
|
} |
|
|
|
.profile-header { |
|
overflow-wrap: break-word; |
|
word-break: break-word; |
|
} |
|
|
|
.profile-header h1 { |
|
overflow-wrap: break-word; |
|
word-break: break-word; |
|
} |
|
|
|
.profile-name { |
|
font-size: 1.5rem; |
|
line-height: 1.2; |
|
} |
|
|
|
@media (min-width: 640px) { |
|
.profile-name { |
|
font-size: 2rem; |
|
} |
|
} |
|
|
|
.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: #a8b8d0; |
|
margin-left: 0.25rem; |
|
display: inline-block; |
|
animation: spin 1s linear infinite; |
|
} |
|
|
|
@keyframes spin { |
|
from { |
|
transform: rotate(0deg); |
|
} |
|
to { |
|
transform: rotate(360deg); |
|
} |
|
} |
|
|
|
.profile-npub-section { |
|
margin-top: 0.5rem; |
|
} |
|
|
|
.npub-display { |
|
display: flex; |
|
align-items: center; |
|
gap: 0.5rem; |
|
flex-wrap: wrap; |
|
} |
|
|
|
.npub-text { |
|
font-family: monospace; |
|
font-size: 0.875rem; |
|
color: var(--fog-text-light, #52667a); |
|
word-break: break-all; |
|
padding: 0.25rem 0.5rem; |
|
background: var(--fog-highlight, #f3f4f6); |
|
border-radius: 0.25rem; |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
} |
|
|
|
:global(.dark) .npub-text { |
|
color: var(--fog-dark-text-light, #a8b8d0); |
|
background: var(--fog-dark-highlight, #374151); |
|
border-color: var(--fog-dark-border, #475569); |
|
} |
|
|
|
.wall-section { |
|
margin-top: 1rem; |
|
} |
|
|
|
.wall-title { |
|
font-size: 1.125rem; |
|
font-weight: 600; |
|
color: var(--fog-text, #1f2937); |
|
margin: 0 0 1rem 0; |
|
} |
|
|
|
:global(.dark) .wall-title { |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
.wall-form { |
|
padding: 1rem; |
|
background: var(--fog-post, #ffffff); |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
border-radius: 0.375rem; |
|
} |
|
|
|
:global(.dark) .wall-form { |
|
background: var(--fog-dark-post, #1f2937); |
|
border-color: var(--fog-dark-border, #374151); |
|
} |
|
|
|
.wall-comments-section { |
|
margin-top: 2rem; |
|
} |
|
|
|
.wall-comments { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 1rem; |
|
} |
|
|
|
/* Hide scrollbar for tabs on mobile */ |
|
.scrollbar-hide { |
|
-ms-overflow-style: none; |
|
scrollbar-width: none; |
|
} |
|
|
|
.scrollbar-hide::-webkit-scrollbar { |
|
display: none; |
|
} |
|
|
|
/* Responsive tabs */ |
|
.tabs { |
|
scrollbar-width: thin; |
|
scrollbar-color: var(--fog-border, #e5e7eb) transparent; |
|
} |
|
|
|
:global(.dark) .tabs { |
|
scrollbar-color: var(--fog-dark-border, #374151) transparent; |
|
} |
|
|
|
@media (max-width: 640px) { |
|
.tabs { |
|
-webkit-overflow-scrolling: touch; |
|
} |
|
|
|
.tabs button { |
|
font-size: 0.875rem; |
|
} |
|
} |
|
|
|
/* Responsive profile header */ |
|
@media (max-width: 640px) { |
|
.profile-header { |
|
margin-bottom: 1rem; |
|
} |
|
|
|
.profile-header p { |
|
font-size: 0.875rem; |
|
} |
|
} |
|
|
|
/* Responsive npub display */ |
|
@media (max-width: 640px) { |
|
.npub-display { |
|
flex-direction: column; |
|
align-items: flex-start; |
|
gap: 0.5rem; |
|
} |
|
|
|
.npub-text { |
|
font-size: 0.75rem; |
|
word-break: break-all; |
|
max-width: 100%; |
|
} |
|
} |
|
</style> |