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}
{highlight.highlightedContent}
{#if 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}
+
+
+
+
+
+
+
+
+ {#if loadingPatchHighlights}
+
Loading highlights...
+ {:else}
+
+ {#each patchComments as comment}
+
+ {/each}
+
+
+ {#each patchHighlights.filter(h => !h.sourceEventId || h.sourceEventId === patch.id) as highlight}
+
+
+
+
{highlight.highlightedContent || highlight.content}
+
+ {#if highlight.comment}
+
+ {/if}
+
+
+ {#if highlight.comments && highlight.comments.length > 0}
+
+ {/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}
+ showPatchHighlightDialog = false}
+ onkeydown={(e) => {
+ if (e.key === 'Escape') {
+ showPatchHighlightDialog = false;
+ }
+ }}
+ >
+
+
+
e.stopPropagation()}>
+
Create Highlight
+
+
+
+
+
+
+
+
+ {/if}
+
+
+ {#if showPatchCommentDialog}
+ { showPatchCommentDialog = false; replyingToPatchComment = null; }}
+ onkeydown={(e) => {
+ if (e.key === 'Escape') {
+ showPatchCommentDialog = false;
+ replyingToPatchComment = null;
+ }
+ }}
+ >
+
+
+
e.stopPropagation()}>
+
{replyingToPatchComment ? 'Reply to Comment' : 'Add Comment'}
+
+
+
+
+
+
+
+ {/if}
+
{#if showCommitDialog && userPubkey && isMaintainer}