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

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