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

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