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

<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>