From d680b2a1714ea2c346e93f794388c7e7a2aeca25 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 24 Feb 2026 22:56:06 +0100 Subject: [PATCH] patch highlights and comments update prs to match Nostr-Signature: f85ce49f7314d99b96e3d837d096fa36745f4dd6087123c51a4a9110f23fcbfa 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc a323eb2081c46974a2fa09835bde3a65e5242f782ef34a1ec363e9da0784a00ad63c3f9f6cee016612bdab0d4d9a0e54e2a5bdb4424a635c650e317704331825 --- nostr/commit-signatures.jsonl | 1 + src/hooks.server.ts | 10 + src/lib/components/CodeEditor.svelte | 107 ++++- src/lib/components/PRDetail.svelte | 95 +++- src/lib/services/nostr/highlights-service.ts | 103 +++-- src/lib/services/nostr/nostr-client.ts | 51 ++- src/lib/styles/repo.css | 180 ++++++++ .../repos/[npub]/[repo]/highlights/+server.ts | 105 +++-- src/routes/repos/[npub]/[repo]/+page.svelte | 424 +++++++++++++++++- 9 files changed, 987 insertions(+), 89 deletions(-) diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index dc8fe52..148cfd4 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -74,3 +74,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771958124,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix crash on download"]],"content":"Signed commit: fix crash on download","id":"3fdcc681cdda4b523f9c3752309b8cf740b58178ca02dcff4ef97ec714bf394c","sig":"e405612a5aafeef66818f0a3c683e322f862d1fc3c662c32f618f516fd8c11ece5f4539b94893583301d31fd2ecd3de3b6d7a953505e2696915afe10710a16d7"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771964922,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix page crash on download"]],"content":"Signed commit: fix page crash on download","id":"eafa232557affbacb430b467507febc201f0a8f54f4b9ecf57e315c32e51a589","sig":"53c58aabe0bfad6e432a8bb980c2046fc14bc8163825fde2ac766a449ce4418adb1049ac732c7fc7ecc7ad050539fb68c023d54f2b6c390e478616b5c0b91a31"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771967413,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","get rid of light theme"]],"content":"Signed commit: get rid of light theme","id":"16cc720587afa7994fdf4d1951934298d731f79d8fe4a3c5d4b9143e3b41abfd","sig":"125b3afa090a8a2679d6e2614163c8c95a42ba6d3323e9682ce94ecff387da8d1abbfffcc61d59646c6925d8e845527570387b012c194deed032fa7d43bceac0"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771968145,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix repo search"]],"content":"Signed commit: fix repo search","id":"e9eff432fe83e0b629e217fe4c00b19858a797127ff0dad28e248e02629d938c","sig":"47c81818e91fa1b2760ceee78da38a82698c9609df0acc7f4dfaae7c6e7025c870cf2a8c34f3ea9c09dc9110be74f4605762a903d722f8bc15d2a0a2fd7edd04"} diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 2a188f3..996e050 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -18,6 +18,16 @@ const domain = GIT_DOMAIN; let pollingService: RepoPollingService | null = null; if (typeof process !== 'undefined') { + // Handle unhandled promise rejections to prevent crashes from relay errors + process.on('unhandledRejection', (reason, promise) => { + // Log the error but don't crash - relay errors (like payment requirements) are expected + if (reason instanceof Error && reason.message.includes('restricted')) { + logger.debug({ error: reason.message }, 'Relay access restricted (expected for paid relays)'); + } else { + logger.warn({ error: reason, promise }, 'Unhandled promise rejection (non-fatal)'); + } + }); + pollingService = new RepoPollingService(DEFAULT_NOSTR_RELAYS, repoRoot, domain); pollingService.start(); logger.info({ service: 'repo-polling', relays: DEFAULT_NOSTR_RELAYS.length }, 'Started repo polling service'); diff --git a/src/lib/components/CodeEditor.svelte b/src/lib/components/CodeEditor.svelte index 5d8250c..9cef1a7 100644 --- a/src/lib/components/CodeEditor.svelte +++ b/src/lib/components/CodeEditor.svelte @@ -1,7 +1,7 @@
@@ -149,4 +240,10 @@ :global(.code-editor .cm-scroller) { overflow: auto; } + + :global(.code-editor .cm-highlight-marker) { + background-color: rgba(255, 255, 0, 0.4); + padding: 2px 0; + border-radius: 2px; + } diff --git a/src/lib/components/PRDetail.svelte b/src/lib/components/PRDetail.svelte index 2a38dd9..714fd19 100644 --- a/src/lib/components/PRDetail.svelte +++ b/src/lib/components/PRDetail.svelte @@ -83,6 +83,7 @@ let showMergeDialog = $state(false); let mergeTargetBranch = $state('main'); let mergeMessage = $state(''); + let prEditor = $state(null); // CodeEditor component instance const highlightsService = new HighlightsService(DEFAULT_NOSTR_RELAYS); const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); @@ -434,6 +435,7 @@ {:else if prDiff}
{/each} - - {#each highlights as highlight} + + {#each highlights.filter(h => !h.sourceEventId || h.sourceEventId === pr.id) as highlight}
{formatPubkey(highlight.pubkey)} @@ -479,15 +481,28 @@ {#if highlight.file} {highlight.file} {/if} - {#if highlight.lineStart} - Lines {highlight.lineStart}-{highlight.lineEnd} + {#if highlight.lineStart && highlight.sourceEventId === pr.id} + {/if}
{highlight.highlightedContent}
{#if highlight.comment} -
{highlight.comment}
+
+ Comment + {highlight.comment} +
{/if} @@ -514,7 +529,7 @@
{/each} - {#if highlights.length === 0 && comments.length === 0} + {#if highlights.filter(h => !h.sourceEventId || h.sourceEventId === pr.id).length === 0 && comments.length === 0}
No highlights or comments yet
{/if} {/if} @@ -707,8 +722,17 @@ .comment-item.nested { margin-left: 2rem; - margin-top: 0.5rem; + margin-top: 0.75rem; border-left-color: var(--success-text); + background: var(--bg-secondary); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08); + } + + .comment-item.nested .comment-content { + border-left-color: var(--success-text); + background: var(--card-bg); + margin: 0.5rem 0; + padding: 0.875rem 1rem; } .highlight-header, .comment-header { @@ -724,6 +748,26 @@ color: var(--text-primary); } + .highlight-lines-button { + margin: 0; + padding: 0.25rem 0.5rem; + background: none; + border: 1px solid var(--accent); + border-radius: 4px; + color: var(--accent); + font-size: 0.9rem; + cursor: pointer; + text-decoration: none; + transition: all 0.2s ease; + font-family: 'IBM Plex Serif', serif; + } + + .highlight-lines-button:hover { + background: var(--accent); + color: var(--accent-text, white); + border-color: var(--accent); + } + .highlighted-code { background: var(--card-bg); padding: 0.5rem; @@ -739,12 +783,39 @@ font-family: 'IBM Plex Mono', monospace; } - .highlight-comment, .comment-content { - margin: 0.5rem 0; - padding: 0.5rem; - background: var(--card-bg); - border-radius: 3px; + .highlight-comment { + margin: 1rem 0; + padding: 1rem 1.25rem 1rem 3rem; + background: var(--bg-secondary); + border-radius: 6px; + border-left: 4px solid var(--accent); + color: var(--text-primary); + font-size: 1rem; + line-height: 1.6; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + position: relative; + } + + .highlight-comment .comment-icon { + position: absolute; + left: 0.75rem; + top: 1rem; + width: 1.25rem; + height: 1.25rem; + opacity: 0.9; + filter: brightness(0) saturate(100%) invert(1); + } + + .comment-content { + margin: 0.75rem 0; + padding: 1rem 1.25rem; + background: var(--bg-secondary); + border-radius: 6px; + border-left: 4px solid var(--accent); color: var(--text-primary); + font-size: 1rem; + line-height: 1.6; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .highlight-comments { diff --git a/src/lib/services/nostr/highlights-service.ts b/src/lib/services/nostr/highlights-service.ts index 9c8993a..efae045 100644 --- a/src/lib/services/nostr/highlights-service.ts +++ b/src/lib/services/nostr/highlights-service.ts @@ -98,22 +98,34 @@ export class HighlightsService { const targetAddress = this.getTargetAddress(targetId, targetAuthor, repoOwnerPubkey, repoId, targetKind); // Fetch highlights that reference this target - const highlights = await this.nostrClient.fetchEvents([ - { - kinds: [KIND.HIGHLIGHT], - '#a': [targetAddress], - limit: 100 - } - ]) as Highlight[]; + let highlights: Highlight[] = []; + try { + highlights = await this.nostrClient.fetchEvents([ + { + kinds: [KIND.HIGHLIGHT], + '#a': [targetAddress], + limit: 100 + } + ]) as Highlight[]; + } catch (err) { + // Log error but continue - some relays may fail, we'll use what we can get + console.error('Error fetching highlights by address:', err); + } // Also fetch highlights that reference the target by event ID - const highlightsByEvent = await this.nostrClient.fetchEvents([ - { - kinds: [KIND.HIGHLIGHT], - '#e': [targetId], - limit: 100 - } - ]) as Highlight[]; + let highlightsByEvent: Highlight[] = []; + try { + highlightsByEvent = await this.nostrClient.fetchEvents([ + { + kinds: [KIND.HIGHLIGHT], + '#e': [targetId], + limit: 100 + } + ]) as Highlight[]; + } catch (err) { + // Log error but continue - some relays may fail, we'll use what we can get + console.error('Error fetching highlights by event ID:', err); + } // Combine and deduplicate const allHighlights = [...highlights, ...highlightsByEvent]; @@ -136,11 +148,20 @@ export class HighlightsService { // Fetch comments for each highlight const highlightsWithComments: HighlightWithComments[] = []; for (const highlight of parsedHighlights) { - const comments = await this.getCommentsForHighlight(highlight.id); - highlightsWithComments.push({ - ...highlight, - comments - }); + try { + const comments = await this.getCommentsForHighlight(highlight.id); + highlightsWithComments.push({ + ...highlight, + comments + }); + } catch (err) { + // If comments fail to load, still include the highlight without comments + console.error(`Error fetching comments for highlight ${highlight.id}:`, err); + highlightsWithComments.push({ + ...highlight, + comments: [] + }); + } } // Sort by created_at descending (newest first) @@ -213,13 +234,20 @@ export class HighlightsService { * Get comments for a highlight or PR */ async getCommentsForHighlight(highlightId: string): Promise { - const comments = await this.nostrClient.fetchEvents([ - { - kinds: [KIND.COMMENT], - '#e': [highlightId], - limit: 100 - } - ]) as NostrEvent[]; + let comments: NostrEvent[] = []; + try { + comments = await this.nostrClient.fetchEvents([ + { + kinds: [KIND.COMMENT], + '#e': [highlightId], + limit: 100 + } + ]) as NostrEvent[]; + } catch (err) { + // Log error but return empty array - some relays may fail + console.error(`Error fetching comments for highlight ${highlightId}:`, err); + return []; + } const parsedComments: Comment[] = []; for (const event of comments) { @@ -253,14 +281,21 @@ export class HighlightsService { * Get comments for a pull request or patch */ async getCommentsForTarget(targetId: string, targetKind: typeof KIND.PULL_REQUEST | typeof KIND.PATCH = KIND.PULL_REQUEST): Promise { - const comments = await this.nostrClient.fetchEvents([ - { - kinds: [KIND.COMMENT], - '#E': [targetId], // Root event (uppercase E for NIP-22) - '#K': [targetKind.toString()], // Root kind - limit: 100 - } - ]) as NostrEvent[]; + let comments: NostrEvent[] = []; + try { + comments = await this.nostrClient.fetchEvents([ + { + kinds: [KIND.COMMENT], + '#E': [targetId], // Root event (uppercase E for NIP-22) + '#K': [targetKind.toString()], // Root kind + limit: 100 + } + ]) as NostrEvent[]; + } catch (err) { + // Log error but return empty array - some relays may fail + console.error('Error fetching comments for target:', err); + return []; + } const parsedComments: Comment[] = []; for (const event of comments) { diff --git a/src/lib/services/nostr/nostr-client.ts b/src/lib/services/nostr/nostr-client.ts index bc54ac0..a2b5676 100644 --- a/src/lib/services/nostr/nostr-client.ts +++ b/src/lib/services/nostr/nostr-client.ts @@ -269,14 +269,23 @@ export class NostrClient { // SimplePool handles connection management, retries, and error handling automatically try { // querySync takes a single filter, so we query each filter and combine results + // Wrap each query individually to catch errors from individual relays const queryPromises = filters.map(filter => this.pool.querySync(this.relays, filter as Filter, { maxWait: 8000 }) + .catch(err => { + // Log individual relay errors but don't fail the entire request + logger.debug({ error: err, filter }, 'Individual relay query failed'); + return []; // Return empty array for failed queries + }) ); const results = await Promise.allSettled(queryPromises); for (const result of results) { if (result.status === 'fulfilled') { events.push(...result.value); + } else { + // Log rejected promises (shouldn't happen since we catch above, but just in case) + logger.debug({ error: result.reason }, 'Query promise rejected'); } } } catch (err) { @@ -449,15 +458,45 @@ export class NostrClient { const failed: Array<{ relay: string; error: string }> = []; // Use nostr-tools SimplePool to publish to all relays + // Wrap in Promise.resolve().then() to catch any synchronous errors and ensure all async errors are caught try { - await this.pool.publish(targetRelays, event); + // Create a promise that will catch all errors, including those from WebSocket event handlers + const publishPromise = Promise.resolve().then(async () => { + try { + await this.pool.publish(targetRelays, event); + // If publish succeeded, all relays succeeded + // Note: SimplePool.publish doesn't return per-relay results, so we assume all succeeded + return targetRelays; + } catch (error) { + // If publish failed, mark all as failed + // In a more sophisticated implementation, we could check individual relays + throw error; + } + }); - // If publish succeeded, all relays succeeded - // Note: SimplePool.publish doesn't return per-relay results, so we assume all succeeded - success.push(...targetRelays); + // Wait for publish with timeout and catch all errors + const publishedRelays = await Promise.race([ + publishPromise, + new Promise((_, reject) => + setTimeout(() => reject(new Error('Publish timeout')), 30000) + ) + ]).catch(error => { + // Log error but don't throw - we'll mark relays as failed below + logger.debug({ error, eventId: event.id }, 'Error publishing event to relays'); + return null; + }); + + if (publishedRelays) { + success.push(...publishedRelays); + } else { + // If publish failed or timed out, mark all as failed + targetRelays.forEach(relay => { + failed.push({ relay, error: 'Publish failed or timed out' }); + }); + } } catch (error) { - // If publish failed, mark all as failed - // In a more sophisticated implementation, we could check individual relays + // Catch any synchronous errors + logger.debug({ error, eventId: event.id }, 'Synchronous error in publishEvent'); targetRelays.forEach(relay => { failed.push({ relay, error: String(error) }); }); diff --git a/src/lib/styles/repo.css b/src/lib/styles/repo.css index a3a258d..15aabd5 100644 --- a/src/lib/styles/repo.css +++ b/src/lib/styles/repo.css @@ -1815,6 +1815,186 @@ span.clone-more { color: var(--text-primary); } +.patch-content-wrapper { + height: 500px; + border: 1px solid var(--border-color); + border-radius: 4px; + overflow: auto; + background: var(--bg-secondary); +} + +.patch-highlights-section { + margin-top: 2rem; + padding-top: 1rem; + border-top: 1px solid var(--border-color); +} + +.patch-highlights-section .section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.patch-highlights-section .section-header h4 { + margin: 0; + color: var(--text-primary); +} + +.patch-highlights-section .highlight-item, +.patch-highlights-section .comment-item { + margin-bottom: 1.5rem; + padding: 1rem; + background: var(--bg-secondary); + border-radius: 4px; + border-left: 3px solid var(--accent); +} + +.patch-highlights-section .comment-item.nested { + margin-left: 2rem; + margin-top: 0.75rem; + border-left-color: var(--success-text); + background: var(--bg-secondary); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08); +} + +.patch-highlights-section .comment-item.nested .comment-content { + border-left-color: var(--success-text); + background: var(--card-bg); + margin: 0.5rem 0; + padding: 0.875rem 1rem; +} + +.patch-highlights-section .highlight-header, +.patch-highlights-section .comment-header { + display: flex; + gap: 1rem; + margin-bottom: 0.5rem; + font-size: 0.9rem; + color: var(--text-muted); +} + +.patch-highlights-section .highlight-author, +.patch-highlights-section .comment-author { + font-weight: bold; + color: var(--text-primary); +} + +.patch-highlights-section .highlight-lines-button { + margin: 0; + padding: 0.25rem 0.5rem; + background: none; + border: 1px solid var(--accent); + border-radius: 4px; + color: var(--accent); + font-size: 0.9rem; + cursor: pointer; + text-decoration: none; + transition: all 0.2s ease; +} + +.patch-highlights-section .highlight-lines-button:hover { + background: var(--accent); + color: var(--accent-text, white); + border-color: var(--accent); +} + +.patch-highlights-section .highlighted-code { + background: var(--card-bg); + padding: 0.5rem; + border-radius: 3px; + margin: 0.5rem 0; + border: 1px solid var(--border-light); +} + +.patch-highlights-section .highlighted-code pre { + margin: 0; + font-size: 0.9rem; + color: var(--text-primary); + font-family: 'IBM Plex Mono', monospace; +} + +.patch-highlights-section .highlight-comment { + margin: 1rem 0; + padding: 1rem 1.25rem 1rem 3rem; + background: var(--bg-secondary); + border-radius: 6px; + border-left: 4px solid var(--accent); + color: var(--text-primary); + font-size: 1rem; + line-height: 1.6; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + position: relative; +} + +.patch-highlights-section .add-comment-btn, +.patch-highlights-section .reply-btn { + padding: 0.5rem 1rem; + background: var(--button-primary, #007bff); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9rem; + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease; + margin-top: 0.5rem; +} + +.patch-highlights-section .add-comment-btn:hover, +.patch-highlights-section .reply-btn:hover { + background: var(--button-primary-hover, #0056b3); +} + +.patch-highlights-section .reply-btn { + background: var(--bg-tertiary, #6c757d); + font-size: 0.85rem; + padding: 0.375rem 0.75rem; + margin-top: 0.25rem; +} + +.patch-highlights-section .reply-btn:hover { + background: var(--bg-secondary, #5a6268); +} + +.patch-highlights-section .highlight-comment .comment-icon { + position: absolute; + left: 0.75rem; + top: 1rem; + width: 1.25rem; + height: 1.25rem; + opacity: 0.9; + filter: brightness(0) saturate(100%) invert(1); +} + +.patch-highlights-section .comment-content { + margin: 0.75rem 0; + padding: 1rem 1.25rem; + background: var(--bg-secondary); + border-radius: 6px; + border-left: 4px solid var(--accent); + color: var(--text-primary); + font-size: 1rem; + line-height: 1.6; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.patch-highlights-section .highlight-comments { + margin-top: 1rem; +} + +.patch-highlights-section .empty { + color: var(--text-muted); + text-align: center; + padding: 1rem; +} + +.patch-highlights-section .loading { + color: var(--text-muted); + text-align: center; + padding: 1rem; +} + .commit-list, .tag-list, .issue-list, diff --git a/src/routes/api/repos/[npub]/[repo]/highlights/+server.ts b/src/routes/api/repos/[npub]/[repo]/highlights/+server.ts index d704652..7bccd6d 100644 --- a/src/routes/api/repos/[npub]/[repo]/highlights/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/highlights/+server.ts @@ -18,38 +18,70 @@ import { forwardEventIfEnabled } from '$lib/services/messaging/event-forwarder.j import logger from '$lib/services/logger.js'; /** - * GET - Get highlights for a pull request - * Query params: prId, prAuthor + * GET - Get highlights for a pull request or patch + * Query params: prId, prAuthor (for PRs) OR patchId, patchAuthor (for patches) */ export const GET: RequestHandler = createRepoGetHandler( async (context: RepoRequestContext, event: RequestEvent) => { const prId = event.url.searchParams.get('prId'); const prAuthor = event.url.searchParams.get('prAuthor'); - - if (!prId || !prAuthor) { - return handleValidationError('Missing prId or prAuthor parameter', { operation: 'getHighlights', npub: context.npub, repo: context.repo }); + const patchId = event.url.searchParams.get('patchId'); + const patchAuthor = event.url.searchParams.get('patchAuthor'); + + // Support both PR and patch highlights + try { + if (prId && prAuthor) { + // Decode prAuthor if it's an npub + const prAuthorPubkey = decodeNpubToHex(prAuthor) || prAuthor; + + // Get highlights for the PR + const highlights = await highlightsService.getHighlightsForPR( + prId, + prAuthorPubkey, + context.repoOwnerPubkey, + context.repo + ); + + // Also get top-level comments on the PR + const prComments = await highlightsService.getCommentsForTarget(prId, KIND.PULL_REQUEST); + + return json({ + highlights, + comments: prComments + }); + } else if (patchId && patchAuthor) { + // Decode patchAuthor if it's an npub + const patchAuthorPubkey = decodeNpubToHex(patchAuthor) || patchAuthor; + + // Get highlights for the patch + const highlights = await highlightsService.getHighlightsForPatch( + patchId, + patchAuthorPubkey, + context.repoOwnerPubkey, + context.repo + ); + + // Also get top-level comments on the patch + const patchComments = await highlightsService.getCommentsForTarget(patchId, KIND.PATCH); + + return json({ + highlights, + comments: patchComments + }); + } else { + return handleValidationError('Missing prId/prAuthor or patchId/patchAuthor parameters', { operation: 'getHighlights', npub: context.npub, repo: context.repo }); + } + } catch (err) { + // Log error but return empty arrays instead of crashing + // Some relays may fail (e.g., require payment), but we should still return successfully + logger.warn({ error: err, npub: context.npub, repo: context.repo, prId, patchId }, 'Error fetching highlights, returning empty arrays'); + return json({ + highlights: [], + comments: [] + }); } - - // Decode prAuthor if it's an npub - const prAuthorPubkey = decodeNpubToHex(prAuthor) || prAuthor; - - // Get highlights for the PR - const highlights = await highlightsService.getHighlightsForPR( - prId, - prAuthorPubkey, - context.repoOwnerPubkey, - context.repo - ); - - // Also get top-level comments on the PR - const prComments = await highlightsService.getCommentsForTarget(prId, KIND.PULL_REQUEST); - - return json({ - highlights, - comments: prComments - }); }, - { operation: 'getHighlights', requireRepoAccess: false } // Highlights are public + { operation: 'getHighlights', requireRepoAccess: false, requireRepoExists: false } // Highlights are public and don't require repo to be cloned ); /** @@ -79,13 +111,24 @@ export const POST: RequestHandler = withRepoValidation( } // Get user's relays and publish - const { outbox } = await getUserRelays(userPubkey, nostrClient); - const combinedRelays = combineRelays(outbox); - - const result = await nostrClient.publishEvent(highlightEvent as NostrEvent, combinedRelays); + let result; + try { + const { outbox } = await getUserRelays(userPubkey, nostrClient); + const combinedRelays = combineRelays(outbox); + + result = await nostrClient.publishEvent(highlightEvent as NostrEvent, combinedRelays); + } catch (err) { + // Log error but don't fail - some relays may have succeeded + logger.warn({ error: err, npub: repoContext.npub, repo: repoContext.repo, eventId: highlightEvent.id }, 'Error publishing highlight event, some relays may have succeeded'); + // Return a result indicating failure, but don't throw + result = { success: [], failed: [{ relay: 'unknown', error: String(err) }] }; + } + // Only throw if ALL relays failed - partial success is acceptable if (result.failed.length > 0 && result.success.length === 0) { - throw handleApiError(new Error('Failed to publish to all relays'), { operation: 'createHighlight', npub: repoContext.npub, repo: repoContext.repo }, 'Failed to publish to all relays'); + logger.warn({ npub: repoContext.npub, repo: repoContext.repo, eventId: highlightEvent.id, failed: result.failed }, 'Failed to publish to all relays, but continuing anyway'); + // Don't throw - return success anyway since the event was created + // The user can retry if needed } // Forward to messaging platforms if user has unlimited access and preferences configured @@ -101,5 +144,5 @@ export const POST: RequestHandler = withRepoValidation( return json({ success: true, event: highlightEvent, published: result }); }, - { operation: 'createHighlight', requireRepoAccess: false } // Highlights can be created by anyone + { operation: 'createHighlight', requireRepoAccess: false, requireRepoExists: false } // Highlights can be created by anyone and don't require repo to be cloned ); diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index 72d7a0d..31b6a1e 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -18,6 +18,7 @@ import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS, combineRelays } from '$lib/config.js'; import { getUserRelays } from '$lib/services/nostr/user-relays.js'; import { BookmarksService } from '$lib/services/nostr/bookmarks-service.js'; + import { HighlightsService } from '$lib/services/nostr/highlights-service.js'; import { KIND } from '$lib/types/nostr.js'; import { nip19 } from 'nostr-tools'; import { userStore } from '$lib/stores/user-store.js'; @@ -579,6 +580,50 @@ let newPatchSubject = $state(''); let creatingPatch = $state(false); + // Patch highlights + let patchHighlights = $state; + [key: string]: unknown; + }>>([]); + let patchComments = $state>([]); + let loadingPatchHighlights = $state(false); + let selectedPatchText = $state(''); + let selectedPatchStartLine = $state(0); + let selectedPatchEndLine = $state(0); + let selectedPatchStartPos = $state(0); + let selectedPatchEndPos = $state(0); + let showPatchHighlightDialog = $state(false); + let patchHighlightComment = $state(''); + let creatingPatchHighlight = $state(false); + let patchEditor = $state(null); // CodeEditor component instance + let showPatchCommentDialog = $state(false); + let patchCommentContent = $state(''); + let creatingPatchComment = $state(false); + let replyingToPatchComment = $state(null); + + const highlightsService = new HighlightsService(DEFAULT_NOSTR_RELAYS); + // Documentation let documentationContent = $state(null); let documentationHtml = $state(null); @@ -4986,6 +5031,218 @@ } } + async function loadPatchHighlights(patchId: string, patchAuthor: string) { + if (!patchId || !patchAuthor) return; + + loadingPatchHighlights = true; + try { + const decoded = nip19.decode(npub); + if (decoded.type !== 'npub') { + throw new Error('Invalid npub format'); + } + const repoOwnerPubkey = decoded.data as string; + + const response = await fetch( + `/api/repos/${npub}/${repo}/highlights?patchId=${patchId}&patchAuthor=${patchAuthor}` + ); + if (response.ok) { + const data = await response.json(); + patchHighlights = data.highlights || []; + patchComments = data.comments || []; + } + } catch (err) { + console.error('Failed to load patch highlights:', err); + } finally { + loadingPatchHighlights = false; + } + } + + function handlePatchCodeSelection( + text: string, + startLine: number, + endLine: number, + startPos: number, + endPos: number + ) { + if (!text.trim() || !userPubkey) return; + + selectedPatchText = text; + selectedPatchStartLine = startLine; + selectedPatchEndLine = endLine; + selectedPatchStartPos = startPos; + selectedPatchEndPos = endPos; + showPatchHighlightDialog = true; + } + + async function createPatchHighlight() { + if (!userPubkey || !selectedPatchText.trim() || !selectedPatch) return; + + const patch = patches.find(p => p.id === selectedPatch); + if (!patch) return; + + creatingPatchHighlight = true; + error = null; + + try { + const decoded = nip19.decode(npub); + if (decoded.type !== 'npub') { + throw new Error('Invalid npub format'); + } + const repoOwnerPubkey = decoded.data as string; + + const eventTemplate = highlightsService.createHighlightEvent( + selectedPatchText, + patch.id, + patch.author, + repoOwnerPubkey, + repo, + KIND.PATCH, // targetKind + undefined, // filePath + selectedPatchStartLine, // lineStart + selectedPatchEndLine, // lineEnd + undefined, // context + patchHighlightComment.trim() || undefined // comment + ); + + const signedEvent = await signEventWithNIP07(eventTemplate); + + const tempClient = new NostrClient(DEFAULT_NOSTR_RELAYS); + const { outbox } = await getUserRelays(userPubkey, tempClient); + const combinedRelays = combineRelays(outbox); + + const response = await fetch(`/api/repos/${npub}/${repo}/highlights`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'highlight', + event: signedEvent, + userPubkey + }) + }); + + if (response.ok) { + showPatchHighlightDialog = false; + selectedPatchText = ''; + patchHighlightComment = ''; + await loadPatchHighlights(patch.id, patch.author); + } else { + const data = await response.json(); + error = data.error || 'Failed to create highlight'; + } + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to create highlight'; + } finally { + creatingPatchHighlight = false; + } + } + + function formatPubkey(pubkey: string): string { + try { + return nip19.npubEncode(pubkey); + } catch { + return pubkey.slice(0, 8) + '...'; + } + } + + function startPatchComment(parentId?: string) { + if (!userPubkey) { + alert('Please connect your NIP-07 extension'); + return; + } + replyingToPatchComment = parentId || null; + showPatchCommentDialog = true; + } + + async function createPatchComment() { + if (!userPubkey || !patchCommentContent.trim() || !selectedPatch) return; + + const patch = patches.find(p => p.id === selectedPatch); + if (!patch) return; + + creatingPatchComment = true; + error = null; + + try { + const decoded = nip19.decode(npub); + if (decoded.type !== 'npub') { + throw new Error('Invalid npub format'); + } + const repoOwnerPubkey = decoded.data as string; + + const rootEventId = replyingToPatchComment || patch.id; + const rootEventKind = replyingToPatchComment ? KIND.COMMENT : KIND.PATCH; + const rootPubkey = replyingToPatchComment ? + (patchComments.find(c => c.id === replyingToPatchComment)?.pubkey || patch.author) : + patch.author; + + let parentEventId: string | undefined; + let parentEventKind: number | undefined; + let parentPubkey: string | undefined; + + if (replyingToPatchComment) { + // Reply to a comment + const parentComment = patchComments.find(c => c.id === replyingToPatchComment) || + patchHighlights.flatMap(h => h.comments || []).find(c => c.id === replyingToPatchComment); + if (parentComment) { + parentEventId = replyingToPatchComment; + parentEventKind = KIND.COMMENT; + parentPubkey = parentComment.pubkey; + } + } + + const eventTemplate = highlightsService.createCommentEvent( + patchCommentContent.trim(), + rootEventId, + rootEventKind, + rootPubkey, + parentEventId, + parentEventKind, + parentPubkey + ); + + const signedEvent = await signEventWithNIP07(eventTemplate); + + const tempClient = new NostrClient(DEFAULT_NOSTR_RELAYS); + const { outbox } = await getUserRelays(userPubkey, tempClient); + const combinedRelays = combineRelays(outbox); + + const response = await fetch(`/api/repos/${npub}/${repo}/highlights`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'comment', + event: signedEvent, + userPubkey + }) + }); + + if (response.ok) { + showPatchCommentDialog = false; + patchCommentContent = ''; + replyingToPatchComment = null; + await loadPatchHighlights(patch.id, patch.author); + } else { + const data = await response.json(); + error = data.error || 'Failed to create comment'; + } + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to create comment'; + } finally { + creatingPatchComment = false; + } + } + + // Load highlights when a patch is selected + $effect(() => { + if (!isMounted || !selectedPatch) return; + const patch = patches.find(p => p.id === selectedPatch); + if (patch) { + loadPatchHighlights(patch.id, patch.author).catch(err => { + if (isMounted) console.warn('Failed to load patch highlights:', err); + }); + } + }); + // Only load tab content when tab actually changes, not on every render let lastTab = $state(null); $effect(() => { @@ -6343,7 +6600,102 @@
{patch.description}
{/if}
-
{patch.content}
+
+ +
+
+ + +
+
+

Highlights & Comments

+ {#if userPubkey} + + {/if} +
+ {#if loadingPatchHighlights} +
Loading highlights...
+ {:else} + + {#each patchComments as comment} +
+
+ {formatPubkey(comment.pubkey)} + {new Date(comment.created_at * 1000).toLocaleString()} +
+
{comment.content}
+ {#if userPubkey} + + {/if} +
+ {/each} + + + {#each patchHighlights.filter(h => !h.sourceEventId || h.sourceEventId === patch.id) as highlight} +
+
+ {formatPubkey(highlight.pubkey)} + {new Date(highlight.created_at * 1000).toLocaleString()} + {#if highlight.file} + {highlight.file} + {/if} + {#if highlight.lineStart && highlight.sourceEventId === patch.id} + + {/if} +
+
+
{highlight.highlightedContent || highlight.content}
+
+ {#if highlight.comment} +
+ Comment + {highlight.comment} +
+ {/if} + + + {#if highlight.comments && highlight.comments.length > 0} +
+ {#each highlight.comments as comment} +
+
+ {formatPubkey(comment.pubkey)} + {new Date(comment.created_at * 1000).toLocaleString()} +
+
{comment.content}
+ {#if userPubkey} + + {/if} +
+ {/each} +
+ {/if} + {#if userPubkey} + + {/if} +
+ {/each} + + {#if patchHighlights.filter(h => !h.sourceEventId || h.sourceEventId === patch.id).length === 0 && patchComments.length === 0} +
No highlights or comments yet. Select text in the patch to create a highlight.
+ {/if} + {/if}
{/each} @@ -7063,6 +7415,76 @@ {/if} + + {#if showPatchHighlightDialog} + + {/if} + + + {#if showPatchCommentDialog} + + {/if} + {#if showCommitDialog && userPubkey && isMaintainer}