|
|
|
|
@ -20,7 +20,11 @@
@@ -20,7 +20,11 @@
|
|
|
|
|
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 |
|
|
|
|
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()); |
|
|
|
|
@ -65,22 +69,33 @@
@@ -65,22 +69,33 @@
|
|
|
|
|
|
|
|
|
|
try { |
|
|
|
|
const interactionRelays = relayManager.getFeedResponseReadRelays(); |
|
|
|
|
// Fetch current user's posts to find replies |
|
|
|
|
|
|
|
|
|
// Fetch current user's posts from cache first (fast) |
|
|
|
|
const currentUserPosts = await nostrClient.fetchEvents( |
|
|
|
|
[{ kinds: [1], authors: [currentUserPubkey], limit: 50 }], |
|
|
|
|
interactionRelays, |
|
|
|
|
{ useCache: true, cacheResults: true } |
|
|
|
|
{ useCache: true, cacheResults: true, timeout: 2000 } // Short timeout for cache |
|
|
|
|
); |
|
|
|
|
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 } |
|
|
|
|
); |
|
|
|
|
// 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: [1], authors: [profilePubkey], '#e': Array.from(currentUserPostIds).slice(0, 20), limit: 20 }, // Limit IDs to avoid huge queries |
|
|
|
|
{ kinds: [1], 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>(); |
|
|
|
|
@ -101,7 +116,7 @@
@@ -101,7 +116,7 @@
|
|
|
|
|
}) |
|
|
|
|
.sort((a, b) => b.created_at - a.created_at); |
|
|
|
|
} catch (error) { |
|
|
|
|
console.error('Error loading interactions with me:', error); |
|
|
|
|
console.debug('Error loading interactions with me:', error); |
|
|
|
|
interactionsWithMe = []; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
@ -162,52 +177,127 @@
@@ -162,52 +177,127 @@
|
|
|
|
|
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 |
|
|
|
|
nip05Validations.set(nip05, null); |
|
|
|
|
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) { |
|
|
|
|
nip05Validations.set(nip05, false); |
|
|
|
|
console.log(`[NIP-05] Invalid format for ${nip05}`); |
|
|
|
|
nip05Validations[nip05] = false; |
|
|
|
|
nip05ValidationCache.set(cacheKey, 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); |
|
|
|
|
// 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; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
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); |
|
|
|
|
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('Error validating NIP-05:', nip05, error); |
|
|
|
|
nip05Validations.set(nip05, false); |
|
|
|
|
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() { |
|
|
|
|
@ -230,73 +320,71 @@
@@ -230,73 +320,71 @@
|
|
|
|
|
|
|
|
|
|
loading = true; |
|
|
|
|
try { |
|
|
|
|
console.log('Loading profile for pubkey:', pubkey, '(decoded from:', param + ')'); |
|
|
|
|
|
|
|
|
|
// Load profile |
|
|
|
|
const profileData = await fetchProfile(pubkey); |
|
|
|
|
// Step 1: Load profile and status first (fast from cache) - display immediately |
|
|
|
|
const [profileData, status] = await Promise.all([ |
|
|
|
|
fetchProfile(pubkey), |
|
|
|
|
fetchUserStatus(pubkey) |
|
|
|
|
]); |
|
|
|
|
|
|
|
|
|
profile = profileData; |
|
|
|
|
console.log('Profile loaded:', profileData); |
|
|
|
|
|
|
|
|
|
// Validate NIP-05 addresses (async, don't wait) |
|
|
|
|
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) { |
|
|
|
|
// Validate in background - don't block page load |
|
|
|
|
validateNIP05(nip05, pubkey).catch(err => { |
|
|
|
|
console.error('NIP-05 validation error:', 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); |
|
|
|
|
}); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// Load user status |
|
|
|
|
const status = await fetchUserStatus(pubkey); |
|
|
|
|
userStatus = status; |
|
|
|
|
|
|
|
|
|
// Load kind 1 posts |
|
|
|
|
// 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: [1], authors: [pubkey], limit: 20 }], |
|
|
|
|
profileRelays, |
|
|
|
|
{ useCache: true, cacheResults: true } |
|
|
|
|
{ useCache: true, cacheResults: true, timeout: 5000 } |
|
|
|
|
); |
|
|
|
|
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(); |
|
|
|
|
// 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: [1], '#p': [pubkey], limit: 20 }], |
|
|
|
|
[{ kinds: [1], '#p': [pubkey], limit: 50 }], // Fetch more to account for filtering |
|
|
|
|
responseRelays, |
|
|
|
|
{ useCache: true, cacheResults: true } |
|
|
|
|
{ useCache: true, cacheResults: true, timeout: 5000 } |
|
|
|
|
); |
|
|
|
|
// 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)); |
|
|
|
|
|
|
|
|
|
// Filter responses (exclude self-replies, only include replies to user's posts) |
|
|
|
|
responses = responseEvents |
|
|
|
|
.filter(e => { |
|
|
|
|
// Exclude self-replies |
|
|
|
|
if (e.pubkey === pubkey) { |
|
|
|
|
return false; |
|
|
|
|
} |
|
|
|
|
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); |
|
|
|
|
.sort((a, b) => b.created_at - a.created_at) |
|
|
|
|
.slice(0, 20); // Limit to 20 after filtering |
|
|
|
|
|
|
|
|
|
// Load "Interactions with me" if user is logged in and viewing another user's profile |
|
|
|
|
// Step 3: Load interactions in background (non-blocking) |
|
|
|
|
if (currentUserPubkey && currentUserPubkey !== pubkey) { |
|
|
|
|
await loadInteractionsWithMe(pubkey, currentUserPubkey); |
|
|
|
|
loadInteractionsWithMe(pubkey, currentUserPubkey).catch(err => { |
|
|
|
|
console.debug('Error loading interactions:', err); |
|
|
|
|
}); |
|
|
|
|
} 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; |
|
|
|
|
} |
|
|
|
|
profile = null; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
</script> |
|
|
|
|
@ -339,13 +427,25 @@
@@ -339,13 +427,25 @@
|
|
|
|
|
{#if profile.nip05 && profile.nip05.length > 0} |
|
|
|
|
<div class="nip05 mb-2"> |
|
|
|
|
{#each profile.nip05 as nip05} |
|
|
|
|
{@const isValid = nip05Validations.get(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} |
|
|
|
|
<span class="nip05-invalid" title="NIP-05 verification failed">✗</span> |
|
|
|
|
{#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} |
|
|
|
|
@ -481,6 +581,28 @@
@@ -481,6 +581,28 @@
|
|
|
|
|
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; |
|
|
|
|
|