diff --git a/src/app.css b/src/app.css index ddc4b87..875c14b 100644 --- a/src/app.css +++ b/src/app.css @@ -467,6 +467,210 @@ input:disabled, textarea:disabled, select:disabled { } } +/* Lookup Button Styles */ +.lookup-button { + padding: 0.5rem 0.75rem; + font-size: 0.9rem; + background: var(--button-secondary); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + transition: background 0.2s; + white-space: nowrap; +} + +.lookup-button:hover:not(:disabled) { + background: var(--button-secondary-hover); +} + +.lookup-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.lookup-error { + margin-top: 0.5rem; + padding: 0.5rem; + background: var(--error-bg); + color: var(--error-text); + border-radius: 4px; + font-size: 0.9rem; +} + +.lookup-results { + margin-top: 0.5rem; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-secondary); + max-height: 300px; + overflow-y: auto; +} + +.lookup-results-header { + padding: 0.5rem; + background: var(--bg-tertiary); + font-weight: 600; + font-size: 0.9rem; + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; +} + +.clear-lookup-button { + background: transparent; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 1.2rem; + padding: 0.25rem 0.5rem; + border-radius: 4px; + transition: background 0.2s, color 0.2s; +} + +.clear-lookup-button:hover { + background: var(--bg-secondary); + color: var(--text-primary); +} + +.lookup-result-item { + padding: 0.75rem; + cursor: pointer; + border-bottom: 1px solid var(--border-light); + transition: background 0.2s; +} + +.lookup-result-item:hover { + background: var(--bg-tertiary); +} + +.lookup-result-item:last-child { + border-bottom: none; +} + +.lookup-result-item strong { + display: block; + margin-bottom: 0.25rem; + color: var(--text-primary); +} + +.lookup-result-item small { + display: block; + color: var(--text-muted); + font-family: 'IBM Plex Mono', monospace; + font-size: 0.85rem; +} + +.lookup-result-item.repo-result, +.lookup-result-item.profile-result { + padding: 1rem; +} + +.result-header { + display: flex; + gap: 1rem; + align-items: flex-start; +} + +.result-image { + width: 60px; + height: 60px; + object-fit: cover; + border-radius: 4px; + flex-shrink: 0; +} + +.result-avatar { + width: 50px; + height: 50px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + +.result-avatar-placeholder { + width: 50px; + height: 50px; + border-radius: 50%; + background: var(--accent); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + font-weight: bold; + flex-shrink: 0; +} + +.result-info { + flex: 1; + min-width: 0; +} + +.result-info strong { + display: block; + margin-bottom: 0.25rem; + color: var(--text-primary); + font-size: 1rem; +} + +.result-description { + margin: 0.5rem 0; + color: var(--text-secondary); + font-size: 0.9rem; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.result-meta { + display: flex; + gap: 1rem; + margin-top: 0.5rem; + flex-wrap: wrap; +} + +.result-meta small { + color: var(--text-muted); + font-size: 0.8rem; +} + +.d-tag { + display: inline-block; + margin-top: 0.25rem; + padding: 0.125rem 0.5rem; + background: var(--bg-tertiary); + border-radius: 3px; + font-family: 'IBM Plex Mono', monospace; + font-size: 0.8rem; +} + +.npub-display { + display: block; + margin-top: 0.5rem; + word-break: break-all; +} + +.result-tags { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.tag-badge { + display: inline-block; + padding: 0.125rem 0.5rem; + background: var(--accent-light); + color: var(--accent); + border-radius: 12px; + font-size: 0.75rem; + font-weight: 500; +} + .search-input { width: 100%; padding: 0.75rem; diff --git a/src/lib/services/nostr/user-relays.ts b/src/lib/services/nostr/user-relays.ts index 0deb533..e0c4c4c 100644 --- a/src/lib/services/nostr/user-relays.ts +++ b/src/lib/services/nostr/user-relays.ts @@ -16,16 +16,18 @@ export async function getUserRelays( const outbox: string[] = []; try { - // Fetch kind 10002 (relay list) + // Fetch kind 10002 (relay list) - get multiple to find the newest const relayListEvents = await nostrClient.fetchEvents([ { kinds: [KIND.RELAY_LIST], authors: [pubkey], - limit: 1 + limit: 10 // Get multiple to ensure we find the newest } ]); if (relayListEvents.length > 0) { + // Sort by created_at descending to get the newest event first + relayListEvents.sort((a, b) => b.created_at - a.created_at); const event = relayListEvents[0]; for (const tag of event.tags) { if (tag[0] === 'relay' && tag[1]) { diff --git a/src/routes/signup/+page.svelte b/src/routes/signup/+page.svelte index 823e16a..ea3c7d6 100644 --- a/src/routes/signup/+page.svelte +++ b/src/routes/signup/+page.svelte @@ -40,9 +40,15 @@ let previewLoading = $state(false); let previewTimeout: ReturnType | null = null; - import { DEFAULT_NOSTR_RELAYS, combineRelays } from '../../lib/config.js'; + // Lookup state + let lookupLoading = $state<{ [key: string]: boolean }>({}); + let lookupError = $state<{ [key: string]: string | null }>({}); + let lookupResults = $state<{ [key: string]: any }>({}); + + import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS, combineRelays } from '../../lib/config.js'; const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); + const searchClient = new NostrClient(DEFAULT_NOSTR_SEARCH_RELAYS); onMount(() => { nip07Available = isNIP07Available(); @@ -185,6 +191,313 @@ } } + // Validation functions + function validateCloneUrl(url: string): string | null { + if (!url.trim()) return null; // Empty is OK + if (!isValidUrl(url.trim())) { + return 'Invalid URL format. Must start with http:// or https://'; + } + if (!url.trim().endsWith('.git') && !url.trim().includes('/')) { + return 'Clone URL should end with .git or be a valid repository URL'; + } + return null; + } + + function validateWebUrl(url: string): string | null { + if (!url.trim()) return null; // Empty is OK + if (!isValidUrl(url.trim())) { + return 'Invalid URL format. Must start with http:// or https://'; + } + return null; + } + + function validateMaintainer(maintainer: string): string | null { + if (!maintainer.trim()) return null; // Empty is OK + // Check if it's a valid npub or hex pubkey + try { + if (maintainer.startsWith('npub')) { + nip19.decode(maintainer); + return null; + } else if (maintainer.length === 64 && /^[0-9a-f]+$/i.test(maintainer)) { + return null; // Valid hex pubkey + } else { + return 'Invalid maintainer format. Use npub1... or 64-character hex pubkey'; + } + } catch { + return 'Invalid maintainer format. Use npub1... or 64-character hex pubkey'; + } + } + + function validateDocumentation(doc: string): string | null { + if (!doc.trim()) return null; // Empty is OK + // Check if it's in naddr format or 30618:pubkey:identifier format + if (doc.startsWith('naddr')) { + try { + const decoded = nip19.decode(doc); + if (decoded.type === 'naddr') return null; + } catch { + return 'Invalid naddr format'; + } + } else if (/^\d+:[0-9a-f]{64}:[a-zA-Z0-9_-]+$/.test(doc)) { + return null; // Valid kind:pubkey:identifier format + } + return 'Invalid documentation format. Use naddr1... or kind:pubkey:identifier'; + } + + function validateImageUrl(url: string): string | null { + if (!url.trim()) return null; // Empty is OK + if (!isValidUrl(url.trim())) { + return 'Invalid URL format. Must start with http:// or https://'; + } + // Check if it's likely an image URL + const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg']; + const lowerUrl = url.toLowerCase(); + if (!imageExtensions.some(ext => lowerUrl.includes(ext)) && !lowerUrl.includes('image') && !lowerUrl.includes('img')) { + return 'Warning: URL does not appear to be an image'; + } + return null; + } + + // Lookup functions + // Use only default search relays for lookups to avoid connecting to random/unreachable user relays + function getSearchRelays(): string[] { + return DEFAULT_NOSTR_SEARCH_RELAYS; + } + + async function lookupRepoAnnouncement(query: string, fieldName: string) { + const lookupKey = `repo-${fieldName}`; + lookupLoading[lookupKey] = true; + lookupError[lookupKey] = null; + lookupResults[lookupKey] = null; + + try { + const relays = await getSearchRelays(); + const client = new NostrClient(relays); + + // Try to decode as naddr, nevent, or hex + const decoded = decodeNostrAddress(query.trim()); + let events: NostrEvent[] = []; + + if (decoded) { + if (decoded.type === 'note' && decoded.id) { + events = await client.fetchEvents([{ ids: [decoded.id], limit: 10 }]); + } else if (decoded.type === 'nevent' && decoded.id) { + events = await client.fetchEvents([{ ids: [decoded.id], limit: 10 }]); + } else if (decoded.type === 'naddr' && decoded.pubkey && decoded.kind && decoded.identifier) { + events = await client.fetchEvents([ + { + kinds: [decoded.kind], + authors: [decoded.pubkey], + '#d': [decoded.identifier], + limit: 10 + } + ]); + } + } + + // Also search by name or d-tag if query doesn't look like an address + if (events.length === 0 && !query.startsWith('naddr') && !query.startsWith('nevent') && !/^[0-9a-f]{64}$/i.test(query)) { + const repoEvents = await client.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + limit: 20 + } + ]); + + const searchLower = query.toLowerCase(); + events = repoEvents.filter(event => { + const name = event.tags.find(t => t[0] === 'name')?.[1] || ''; + const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''; + return name.toLowerCase().includes(searchLower) || dTag.toLowerCase().includes(searchLower); + }); + } + + if (events.length === 0) { + lookupError[lookupKey] = 'No repository announcements found'; + } else { + lookupResults[lookupKey] = events; + } + } catch (err) { + lookupError[lookupKey] = `Lookup failed: ${String(err)}`; + } finally { + lookupLoading[lookupKey] = false; + } + } + + async function lookupNpub(query: string, fieldName: string, index?: number) { + const lookupKey = index !== undefined ? `npub-${fieldName}-${index}` : `npub-${fieldName}`; + lookupLoading[lookupKey] = true; + lookupError[lookupKey] = null; + lookupResults[lookupKey] = null; + + try { + // Try to decode as npub + let pubkey: string | null = null; + try { + if (query.startsWith('npub')) { + const decoded = nip19.decode(query); + if (decoded.type === 'npub') { + pubkey = decoded.data as string; + } + } else if (query.length === 64 && /^[0-9a-f]+$/i.test(query)) { + pubkey = query; + } + } catch { + // Invalid format + } + + if (!pubkey) { + // Search for npubs by name (requires kind 0 metadata) + const relays = getSearchRelays(); + const client = new NostrClient(relays); + + // Search for profiles + const profileEvents = await client.fetchEvents([ + { + kinds: [0], // Metadata events + limit: 20 + } + ]); + + const searchLower = query.toLowerCase(); + const matches = profileEvents.filter(event => { + try { + const content = JSON.parse(event.content); + const name = content.name || content.display_name || ''; + return name.toLowerCase().includes(searchLower); + } catch { + return false; + } + }); + + if (matches.length > 0) { + lookupResults[lookupKey] = matches.map(e => { + try { + const content = JSON.parse(e.content); + return { + pubkey: e.pubkey, + npub: nip19.npubEncode(e.pubkey), + name: content.name || content.display_name || 'Unknown', + about: content.about || '', + picture: content.picture || '' + }; + } catch { + return { + pubkey: e.pubkey, + npub: nip19.npubEncode(e.pubkey), + name: 'Unknown' + }; + } + }); + } else { + lookupError[lookupKey] = 'No profiles found matching the query'; + } + } else { + // Valid pubkey, try to fetch profile + const relays = getSearchRelays(); + const client = new NostrClient(relays); + const profileEvents = await client.fetchEvents([ + { + kinds: [0], + authors: [pubkey], + limit: 1 + } + ]); + + let profileData: any = { + pubkey, + npub: query.startsWith('npub') ? query : nip19.npubEncode(pubkey) + }; + + if (profileEvents.length > 0) { + try { + const content = JSON.parse(profileEvents[0].content); + profileData.name = content.name || content.display_name || ''; + profileData.about = content.about || ''; + profileData.picture = content.picture || ''; + } catch { + // Invalid JSON, use defaults + } + } + + lookupResults[lookupKey] = [profileData]; + } + } catch (err) { + lookupError[lookupKey] = `Lookup failed: ${String(err)}`; + } finally { + lookupLoading[lookupKey] = false; + } + } + + async function lookupNevent(query: string, fieldName: string) { + const lookupKey = `nevent-${fieldName}`; + lookupLoading[lookupKey] = true; + lookupError[lookupKey] = null; + lookupResults[lookupKey] = null; + + try { + const relays = await getSearchRelays(); + const client = new NostrClient(relays); + + let events: NostrEvent[] = []; + const decoded = decodeNostrAddress(query.trim()); + + if (decoded && decoded.id) { + events = await client.fetchEvents([{ ids: [decoded.id], limit: 10 }]); + } else if (/^[0-9a-f]{64}$/i.test(query.trim())) { + // Hex event ID + events = await client.fetchEvents([{ ids: [query.trim()], limit: 10 }]); + } + + if (events.length === 0) { + lookupError[lookupKey] = 'No events found'; + } else { + lookupResults[lookupKey] = events; + } + } catch (err) { + lookupError[lookupKey] = `Lookup failed: ${String(err)}`; + } finally { + lookupLoading[lookupKey] = false; + } + } + + function selectRepoResult(result: NostrEvent, fieldName: string) { + if (fieldName === 'existingRepoRef') { + existingRepoRef = result.id; + loadExistingRepo(); + } else if (fieldName === 'forkOriginalRepo') { + // Convert to naddr format if possible + const dTag = result.tags.find(t => t[0] === 'd')?.[1]; + if (dTag) { + try { + const naddr = nip19.naddrEncode({ + pubkey: result.pubkey, + kind: result.kind, + identifier: dTag, + relays: [] + }); + forkOriginalRepo = naddr; + } catch { + forkOriginalRepo = `${result.kind}:${result.pubkey}:${dTag}`; + } + } + } + lookupResults[`repo-${fieldName}`] = null; + } + + function selectNpubResult(result: { pubkey: string; npub: string; name?: string; about?: string; picture?: string }, fieldName: string, index?: number) { + if (fieldName === 'maintainers' && index !== undefined) { + updateMaintainer(index, result.npub); + } + const lookupKey = index !== undefined ? `npub-${fieldName}-${index}` : `npub-${fieldName}`; + lookupResults[lookupKey] = null; + } + + function clearLookupResults(key: string) { + lookupResults[key] = null; + lookupError[key] = null; + } + async function loadExistingRepo() { if (!existingRepoRef.trim()) return; @@ -360,6 +673,61 @@ return; } + // Validate all fields + const validationErrors: string[] = []; + + // Validate clone URLs + for (let i = 0; i < cloneUrls.length; i++) { + const urlError = validateCloneUrl(cloneUrls[i]); + if (urlError) { + validationErrors.push(`Clone URL ${i + 1}: ${urlError}`); + } + } + + // Validate web URLs + for (let i = 0; i < webUrls.length; i++) { + const urlError = validateWebUrl(webUrls[i]); + if (urlError) { + validationErrors.push(`Web URL ${i + 1}: ${urlError}`); + } + } + + // Validate maintainers + for (let i = 0; i < maintainers.length; i++) { + const maintainerError = validateMaintainer(maintainers[i]); + if (maintainerError) { + validationErrors.push(`Maintainer ${i + 1}: ${maintainerError}`); + } + } + + // Validate documentation + for (let i = 0; i < documentation.length; i++) { + const docError = validateDocumentation(documentation[i]); + if (docError) { + validationErrors.push(`Documentation ${i + 1}: ${docError}`); + } + } + + // Validate image URLs + if (imageUrl.trim()) { + const imageError = validateImageUrl(imageUrl); + if (imageError) { + validationErrors.push(`Image URL: ${imageError}`); + } + } + + if (bannerUrl.trim()) { + const bannerError = validateImageUrl(bannerUrl); + if (bannerError) { + validationErrors.push(`Banner URL: ${bannerError}`); + } + } + + if (validationErrors.length > 0) { + error = 'Validation errors:\n' + validationErrors.join('\n'); + return; + } + loading = true; error = null; @@ -537,9 +905,12 @@ // Sign with NIP-07 const signedEvent = await signEventWithNIP07(eventTemplate); - // Get user's inbox/outbox relays (from kind 3 or 10002) + // Get user's inbox/outbox relays (from kind 10002) using full relay set to find newest const { getUserRelays } = await import('../../lib/services/nostr/user-relays.js'); - const { inbox, outbox } = await getUserRelays(pubkey, nostrClient); + // Use comprehensive relay set to ensure we get the newest kind 10002 event + const fullRelaySet = combineRelays([], [...DEFAULT_NOSTR_SEARCH_RELAYS, ...DEFAULT_NOSTR_RELAYS]); + const fullRelayClient = new NostrClient(fullRelaySet); + const { inbox, outbox } = await getUserRelays(pubkey, fullRelayClient); // Combine user's outbox with default relays const userRelays = combineRelays(outbox); @@ -614,6 +985,15 @@ placeholder="hex event ID, nevent1..., or naddr1..." disabled={loading || loadingExisting} /> + + + {#each lookupResults['repo-existingRepoRef'] as result} + {@const nameTag = result.tags.find((t: string[]) => t[0] === 'name')?.[1]} + {@const dTag = result.tags.find((t: string[]) => t[0] === 'd')?.[1]} + {@const descTag = result.tags.find((t: string[]) => t[0] === 'description')?.[1]} + {@const imageTag = result.tags.find((t: string[]) => t[0] === 'image')?.[1]} + {@const ownerNpub = nip19.npubEncode(result.pubkey)} + {@const tags = result.tags.filter((t: string[]) => t[0] === 't' && t[1] && t[1] !== 'private' && t[1] !== 'fork').map((t: string[]) => t[1])} +
selectRepoResult(result, 'existingRepoRef')} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + selectRepoResult(result, 'existingRepoRef'); + } + }} + > +
+ {#if imageTag} + + {/if} +
+ {nameTag || dTag || 'Unnamed'} + {#if dTag} + d-tag: {dTag} + {/if} + {#if descTag} +

{descTag}

+ {/if} +
+ Owner: {ownerNpub.slice(0, 16)}... + Event: {result.id.slice(0, 16)}... +
+ {#if tags.length > 0} +
+ {#each tags as tag} + #{tag} + {/each} +
+ {/if} +
+
+
+ {/each} + + {/if}
@@ -756,6 +1200,15 @@ placeholder="npub1abc... or hex pubkey" disabled={loading} /> + {#if maintainers.length > 1}
+ {#if lookupError[`npub-maintainers-${index}`]} +
{lookupError[`npub-maintainers-${index}`]}
+ {/if} + {#if lookupResults[`npub-maintainers-${index}`]} +
+
+ Found {lookupResults[`npub-maintainers-${index}`].length} profile(s): + +
+ {#each lookupResults[`npub-maintainers-${index}`] as result} +
selectNpubResult(result, 'maintainers', index)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + selectNpubResult(result, 'maintainers', index); + } + }} + > +
+ {#if result.picture} + + {:else} +
+ {(result.name || result.npub).slice(0, 2).toUpperCase()} +
+ {/if} +
+ {result.name || 'Unknown'} + {#if result.about} +

{result.about}

+ {/if} + {result.npub} +
+
+
+ {/each} +
+ {/if} {/each} + + {#if lookupError['repo-forkOriginalRepo']} +
{lookupError['repo-forkOriginalRepo']}
+ {/if} + {#if lookupResults['repo-forkOriginalRepo']} +
+
+ Found {lookupResults['repo-forkOriginalRepo'].length} repository announcement(s): + +
+ {#each lookupResults['repo-forkOriginalRepo'] as result} + {@const nameTag = result.tags.find((t: string[]) => t[0] === 'name')?.[1]} + {@const dTag = result.tags.find((t: string[]) => t[0] === 'd')?.[1]} + {@const descTag = result.tags.find((t: string[]) => t[0] === 'description')?.[1]} + {@const imageTag = result.tags.find((t: string[]) => t[0] === 'image')?.[1]} + {@const ownerNpub = nip19.npubEncode(result.pubkey)} + {@const tags = result.tags.filter((t: string[]) => t[0] === 't' && t[1] && t[1] !== 'private' && t[1] !== 'fork').map((t: string[]) => t[1])} +
selectRepoResult(result, 'forkOriginalRepo')} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + selectRepoResult(result, 'forkOriginalRepo'); + } + }} + > +
+ {#if imageTag} + + {/if} +
+ {nameTag || dTag || 'Unnamed'} + {#if dTag} + d-tag: {dTag} + {/if} + {#if descTag} +

{descTag}

+ {/if} +
+ Owner: {ownerNpub.slice(0, 16)}... + Event: {result.id.slice(0, 16)}... +
+ {#if tags.length > 0} +
+ {#each tags as tag} + #{tag} + {/each} +
+ {/if} +
+
+
+ {/each} +
+ {/if} {/if}