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.
499 lines
16 KiB
499 lines
16 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'; |
|
|
|
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<Map<string, boolean | null>>(new Map()); // null = checking, true = valid, false = invalid |
|
|
|
// 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 to find replies |
|
const currentUserPosts = await nostrClient.fetchEvents( |
|
[{ kinds: [1], authors: [currentUserPubkey], limit: 50 }], |
|
interactionRelays, |
|
{ useCache: true, cacheResults: true } |
|
); |
|
const currentUserPostIds = new Set(currentUserPosts.map(p => p.id)); |
|
|
|
const interactionEvents = await nostrClient.fetchEvents( |
|
[ |
|
{ kinds: [1], authors: [profilePubkey], '#e': Array.from(currentUserPostIds), limit: 20 }, // Replies to current user's posts |
|
{ kinds: [1], authors: [profilePubkey], '#p': [currentUserPubkey], limit: 20 } // Mentions of current user |
|
], |
|
interactionRelays, |
|
{ useCache: true, cacheResults: true } |
|
); |
|
|
|
// 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.error('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; |
|
} |
|
|
|
/** |
|
* Validate NIP-05 address against well-known.json |
|
*/ |
|
async function validateNIP05(nip05: string, expectedPubkey: string) { |
|
// Mark as checking |
|
nip05Validations.set(nip05, null); |
|
|
|
try { |
|
// Parse NIP-05: format is "local@domain.com" |
|
const [localPart, domain] = nip05.split('@'); |
|
if (!localPart || !domain) { |
|
nip05Validations.set(nip05, false); |
|
return; |
|
} |
|
|
|
// Fetch well-known JSON |
|
// NIP-05 spec: https://[domain]/.well-known/nostr.json?name=[local] |
|
const url = `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(localPart)}`; |
|
|
|
const response = await fetch(url, { |
|
method: 'GET', |
|
headers: { |
|
'Accept': 'application/json' |
|
} |
|
}); |
|
|
|
if (!response.ok) { |
|
nip05Validations.set(nip05, false); |
|
return; |
|
} |
|
|
|
const data = await response.json(); |
|
|
|
// Check if the response contains the expected pubkey |
|
// NIP-05 format: { "names": { "local": "hex-pubkey" } } |
|
if (data.names && data.names[localPart]) { |
|
const verifiedPubkey = data.names[localPart].toLowerCase(); |
|
const expected = expectedPubkey.toLowerCase(); |
|
nip05Validations.set(nip05, verifiedPubkey === expected); |
|
} else { |
|
nip05Validations.set(nip05, false); |
|
} |
|
} catch (error) { |
|
console.error('Error validating NIP-05:', nip05, error); |
|
nip05Validations.set(nip05, false); |
|
} |
|
} |
|
|
|
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 { |
|
console.log('Loading profile for pubkey:', pubkey, '(decoded from:', param + ')'); |
|
|
|
// Load profile |
|
const profileData = await fetchProfile(pubkey); |
|
profile = profileData; |
|
console.log('Profile loaded:', profileData); |
|
|
|
// Validate NIP-05 addresses (async, don't wait) |
|
if (profileData?.nip05 && profileData.nip05.length > 0) { |
|
for (const nip05 of profileData.nip05) { |
|
// Validate in background - don't block page load |
|
validateNIP05(nip05, pubkey).catch(err => { |
|
console.error('NIP-05 validation error:', err); |
|
}); |
|
} |
|
} |
|
|
|
// Load user status |
|
const status = await fetchUserStatus(pubkey); |
|
userStatus = status; |
|
|
|
// Load kind 1 posts |
|
const profileRelays = relayManager.getProfileReadRelays(); |
|
const feedEvents = await nostrClient.fetchEvents( |
|
[{ kinds: [1], authors: [pubkey], limit: 20 }], |
|
profileRelays, |
|
{ useCache: true, cacheResults: true } |
|
); |
|
posts = feedEvents.sort((a, b) => b.created_at - a.created_at); |
|
|
|
// Load kind 1 responses (replies to this user's posts) |
|
const responseRelays = relayManager.getFeedResponseReadRelays(); |
|
const responseEvents = await nostrClient.fetchEvents( |
|
[{ kinds: [1], '#p': [pubkey], limit: 20 }], |
|
responseRelays, |
|
{ useCache: true, cacheResults: true } |
|
); |
|
// Filter to only include actual replies (have e tag pointing to user's posts) |
|
// AND exclude self-replies (where author is the same as the profile owner) |
|
const userPostIds = new Set(posts.map(p => p.id)); |
|
responses = responseEvents |
|
.filter(e => { |
|
// Exclude self-replies |
|
if (e.pubkey === pubkey) { |
|
return false; |
|
} |
|
const eTag = e.tags.find(t => t[0] === 'e'); |
|
return eTag && userPostIds.has(eTag[1]); |
|
}) |
|
.sort((a, b) => b.created_at - a.created_at); |
|
|
|
// Load "Interactions with me" if user is logged in and viewing another user's profile |
|
if (currentUserPubkey && currentUserPubkey !== pubkey) { |
|
await loadInteractionsWithMe(pubkey, currentUserPubkey); |
|
} else { |
|
interactionsWithMe = []; |
|
} |
|
} catch (error) { |
|
console.error('Error loading profile:', error); |
|
// Set loading to false even on error so UI can show error state |
|
loading = false; |
|
profile = null; // Clear profile on error |
|
} finally { |
|
// Ensure loading is always set to false |
|
if (loading) { |
|
loading = false; |
|
} |
|
} |
|
} |
|
</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.get(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} |
|
<span class="nip05-invalid" title="NIP-05 verification failed">✗</span> |
|
{: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-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>
|
|
|