From c66ec646ff2d3119887b42af3497333219595ded Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 18 Feb 2026 18:10:29 +0100 Subject: [PATCH] fix repo discussion threads --- src/lib/services/nostr/discussions-service.ts | 286 +++++- src/lib/services/nostr/maintainer-service.ts | 44 +- src/lib/utils/api-auth.ts | 9 + src/lib/utils/api-context.ts | 17 + .../api/repos/[npub]/[repo]/tags/+server.ts | 16 +- src/routes/repos/[npub]/[repo]/+page.svelte | 859 +++++++++++++++++- 6 files changed, 1166 insertions(+), 65 deletions(-) diff --git a/src/lib/services/nostr/discussions-service.ts b/src/lib/services/nostr/discussions-service.ts index 4a6a205..84c7246 100644 --- a/src/lib/services/nostr/discussions-service.ts +++ b/src/lib/services/nostr/discussions-service.ts @@ -31,7 +31,24 @@ export interface DiscussionEntry { content: string; author: string; createdAt: number; - comments?: Comment[]; + comments?: Array<{ + id: string; + content: string; + author: string; + createdAt: number; + replies?: Array<{ + id: string; + content: string; + author: string; + createdAt: number; + replies?: Array<{ + id: string; + content: string; + author: string; + createdAt: number; + }>; + }>; + }>; } /** @@ -58,25 +75,30 @@ export class DiscussionsService { async getThreads( repoOwnerPubkey: string, repoId: string, - chatRelays: string[] + allRelays: string[] ): Promise { - if (!chatRelays || chatRelays.length === 0) { + // If no relays provided, return empty + if (!allRelays || allRelays.length === 0) { + console.warn('[Discussions] No relays provided to getThreads'); return []; } const repoAddress = this.getRepoAddress(repoOwnerPubkey, repoId); + console.log('[Discussions] Fetching threads for repo address:', repoAddress, 'from relays:', allRelays); - // Create a client for chat relays - const chatClient = new NostrClient(chatRelays); + // Create a client for all available relays + const client = new NostrClient(allRelays); - // Fetch threads from chat relays - const threads = await chatClient.fetchEvents([ + // Fetch threads from all available relays + const threads = await client.fetchEvents([ { kinds: [KIND.THREAD], '#a': [repoAddress], limit: 100 } ]) as NostrEvent[]; + + console.log('[Discussions] Found', threads.length, 'thread events'); const parsedThreads: Thread[] = []; for (const event of threads) { @@ -104,61 +126,155 @@ export class DiscussionsService { /** * Fetch kind 1111 comments directly on the repo announcement event - * Comments should reference the repo announcement via 'E' and 'K' tags + * Comments can reference the repo announcement via: + * 1. 'a' tag with repo address (e.g., "30617:pubkey:repo") + * 2. 'E' and 'K' tags (NIP-22 standard) */ async getCommentsOnAnnouncement( announcementId: string, announcementPubkey: string, - relays: string[] + relays: string[], + repoOwnerPubkey?: string, + repoId?: string ): Promise { // Create a client for the specified relays const relayClient = new NostrClient(relays); - const comments = await relayClient.fetchEvents([ - { + // Build repo address for a-tag matching + const repoAddress = repoOwnerPubkey && repoId + ? this.getRepoAddress(repoOwnerPubkey, repoId) + : null; + + // Fetch comments using both methods: + // 1. Comments with a-tag (repo address) + // 2. Comments with E/K tags (NIP-22) + // Fetch ALL comments with announcement as root (including nested replies) + const filters: any[] = []; + + if (repoAddress) { + // Filter for comments with a-tag matching repo address + filters.push({ kinds: [KIND.COMMENT], - '#E': [announcementId], // Uppercase E for root event (NIP-22) - '#K': [KIND.REPO_ANNOUNCEMENT.toString()], // Uppercase K for root kind (NIP-22) - limit: 100 - } - ]) as NostrEvent[]; - + '#a': [repoAddress], + limit: 500 // Increased limit to get nested replies + }); + } + + // Also fetch comments using NIP-22 tags - fetch ALL with announcement as root + filters.push({ + kinds: [KIND.COMMENT], + '#E': [announcementId], // Uppercase E for root event (NIP-22) + '#K': [KIND.REPO_ANNOUNCEMENT.toString()], // Uppercase K for root kind (NIP-22) + limit: 500 // Increased limit to get nested replies + }); + + const allComments = await relayClient.fetchEvents(filters) as NostrEvent[]; + + // Deduplicate by event ID + const seenIds = new Set(); const parsedComments: Comment[] = []; - for (const event of comments) { + + for (const event of allComments) { + // Skip duplicates + if (seenIds.has(event.id)) { + continue; + } + seenIds.add(event.id); + if (!verifyEvent(event)) { continue; } - // Verify this comment is for the repo announcement - // NIP-22 uses uppercase 'E' for root event ID + // Check if comment references repo via a-tag + const aTag = event.tags.find(t => t[0] === 'a'); + const hasATag = aTag && repoAddress && aTag[1] === repoAddress; + + // Check if comment references repo via NIP-22 tags const ETag = event.tags.find(t => t[0] === 'E'); const KTag = event.tags.find(t => t[0] === 'K'); const PTag = event.tags.find(t => t[0] === 'P'); + const hasNIP22Tags = ETag && ETag[1] === announcementId && + KTag && KTag[1] === KIND.REPO_ANNOUNCEMENT.toString(); - if (!ETag || ETag[1] !== announcementId) { - continue; + // Include comment if it matches either method + // For NIP-22, include ALL comments with announcement as root (including nested replies) + if (hasATag || hasNIP22Tags) { + // For a-tag comments, only include top-level (parent is announcement) + if (hasATag && !hasNIP22Tags) { + const eTag = event.tags.find(t => t[0] === 'e'); + // If it has an 'e' tag that's not the announcement, it's a nested reply + // We'll include it if it has the repo address in 'a' tag + // (The buildCommentTree will handle nesting) + } + + parsedComments.push({ + ...event, + kind: KIND.COMMENT, + rootKind: KTag ? parseInt(KTag[1]) : KIND.REPO_ANNOUNCEMENT, + parentKind: event.tags.find(t => t[0] === 'k') + ? parseInt(event.tags.find(t => t[0] === 'k')![1]) + : KIND.REPO_ANNOUNCEMENT, + rootPubkey: PTag?.[1] || announcementPubkey, + parentPubkey: event.tags.find(t => t[0] === 'p')?.[1] || announcementPubkey + }); } + } - if (!KTag || KTag[1] !== KIND.REPO_ANNOUNCEMENT.toString()) { + // Sort by creation time (oldest first for comments) + parsedComments.sort((a, b) => a.created_at - b.created_at); + return parsedComments; + } + + /** + * Fetch kind 1111 comments for a specific thread (kind 11 event) + * Fetches ALL comments that have this thread as root (including nested replies) + */ + async getThreadComments( + threadId: string, + threadPubkey: string, + relays: string[] + ): Promise { + const relayClient = new NostrClient(relays); + + // Fetch ALL comments that have this thread as root (via E tag) + // This includes both direct replies and nested replies + const comments = await relayClient.fetchEvents([ + { + kinds: [KIND.COMMENT], + '#E': [threadId], // Root event (the thread) + '#K': [KIND.THREAD.toString()], // Root kind (11) + limit: 500 // Increased limit to get all nested replies + } + ]) as NostrEvent[]; + + const parsedComments: Comment[] = []; + const seenIds = new Set(); + + for (const event of comments) { + if (seenIds.has(event.id)) continue; + seenIds.add(event.id); + + if (!verifyEvent(event)) { continue; } - // For top-level comments, parent should also be the announcement - // NIP-22 uses lowercase 'e' for parent event ID + // Check if comment has this thread as root + const ETag = event.tags.find(t => t[0] === 'E'); + const KTag = event.tags.find(t => t[0] === 'K'); const eTag = event.tags.find(t => t[0] === 'e'); const kTag = event.tags.find(t => t[0] === 'k'); const pTag = event.tags.find(t => t[0] === 'p'); - - // Only include comments that are direct replies to the announcement - // (parent is the announcement, not another comment) - if (eTag && eTag[1] === announcementId && kTag && kTag[1] === KIND.REPO_ANNOUNCEMENT.toString()) { + const PTag = event.tags.find(t => t[0] === 'P'); + + // Comment must have this thread as root + if (ETag && ETag[1] === threadId && KTag && KTag[1] === KIND.THREAD.toString()) { parsedComments.push({ ...event, kind: KIND.COMMENT, - rootKind: KTag ? parseInt(KTag[1]) : 0, - parentKind: kTag ? parseInt(kTag[1]) : 0, - rootPubkey: PTag?.[1] || announcementPubkey, - parentPubkey: pTag?.[1] || announcementPubkey + rootKind: parseInt(KTag[1]), + parentKind: kTag ? parseInt(kTag[1]) : KIND.THREAD, + rootPubkey: PTag?.[1] || threadPubkey, + parentPubkey: pTag?.[1] || threadPubkey }); } } @@ -168,6 +284,82 @@ export class DiscussionsService { return parsedComments; } + /** + * Build nested comment structure from flat list + */ + private buildCommentTree(comments: Comment[]): Array<{ + id: string; + content: string; + author: string; + createdAt: number; + replies?: Array<{ + id: string; + content: string; + author: string; + createdAt: number; + replies?: Array<{ + id: string; + content: string; + author: string; + createdAt: number; + }>; + }>; + }> { + if (comments.length === 0) return []; + + // Create a map of comment ID to comment data + const commentMap = new Map(); + + // First pass: create all comment nodes + for (const comment of comments) { + const eTag = comment.tags.find(t => t[0] === 'e'); + // Parent is the 'e' tag value (the comment/thread this replies to) + const parentId = eTag ? eTag[1] : undefined; + + commentMap.set(comment.id, { + id: comment.id, + content: comment.content, + author: comment.pubkey, + createdAt: comment.created_at, + parentId, + replies: [] + }); + } + + // Second pass: build tree structure + const rootComments: any[] = []; + for (const [id, comment] of commentMap) { + if (comment.parentId && commentMap.has(comment.parentId)) { + // This is a reply to another comment, add it to parent's replies + const parent = commentMap.get(comment.parentId)!; + parent.replies.push(comment); + } else { + // This is a top-level comment (replies directly to thread/announcement) + rootComments.push(comment); + } + } + + // Recursively convert to the expected format + const formatComment = (comment: any): any => { + return { + id: comment.id, + content: comment.content, + author: comment.author, + createdAt: comment.createdAt, + replies: comment.replies.length > 0 ? comment.replies.map(formatComment) : undefined + }; + }; + + return rootComments.map(formatComment); + } + /** * Get all discussions (threads + comments) for a repository */ @@ -176,22 +368,29 @@ export class DiscussionsService { repoId: string, announcementId: string, announcementPubkey: string, - chatRelays: string[], - defaultRelays: string[] + allRelays: string[], + commentRelays: string[] ): Promise { const entries: DiscussionEntry[] = []; - // Fetch threads from chat relays - const threads = await this.getThreads(repoOwnerPubkey, repoId, chatRelays); + // Fetch threads from all available relays + const threads = await this.getThreads(repoOwnerPubkey, repoId, allRelays); + // Fetch comments for each thread for (const thread of threads) { + const threadComments = await this.getThreadComments(thread.id, thread.pubkey, allRelays); + + // Build nested comment tree + const commentTree = threadComments.length > 0 ? this.buildCommentTree(threadComments) : undefined; + entries.push({ type: 'thread', id: thread.id, title: thread.title || 'Untitled Thread', content: thread.content, author: thread.pubkey, - createdAt: thread.created_at + createdAt: thread.created_at, + comments: commentTree }); } @@ -199,7 +398,9 @@ export class DiscussionsService { const comments = await this.getCommentsOnAnnouncement( announcementId, announcementPubkey, - defaultRelays + commentRelays, + repoOwnerPubkey, + repoId ); // If there are comments, create a pseudo-thread entry called "Comments" @@ -211,7 +412,12 @@ export class DiscussionsService { content: '', // No content for the pseudo-thread author: '', createdAt: comments[0]?.created_at || 0, - comments + comments: comments.map(c => ({ + id: c.id, + content: c.content, + author: c.pubkey, + createdAt: c.created_at + })) }); } diff --git a/src/lib/services/nostr/maintainer-service.ts b/src/lib/services/nostr/maintainer-service.ts index 89c74aa..3c4692e 100644 --- a/src/lib/services/nostr/maintainer-service.ts +++ b/src/lib/services/nostr/maintainer-service.ts @@ -132,15 +132,26 @@ export class MaintainerService { * Private repos: only owners and maintainers can view */ async canView(userPubkey: string | null, repoOwnerPubkey: string, repoId: string): Promise { - const { isPrivate, maintainers } = await this.getMaintainers(repoOwnerPubkey, repoId); + const { isPrivate, maintainers, owner } = await this.getMaintainers(repoOwnerPubkey, repoId); + + logger.debug({ + isPrivate, + repoOwnerPubkey: repoOwnerPubkey.substring(0, 16) + '...', + currentOwner: owner.substring(0, 16) + '...', + repoId, + userPubkey: userPubkey ? userPubkey.substring(0, 16) + '...' : null, + maintainerCount: maintainers.length + }, 'canView check'); // Public repos are viewable by anyone if (!isPrivate) { + logger.debug({ repoOwnerPubkey: repoOwnerPubkey.substring(0, 16) + '...', repoId }, 'Access granted: repo is public'); return true; } // Private repos require authentication if (!userPubkey) { + logger.debug({ repoOwnerPubkey: repoOwnerPubkey.substring(0, 16) + '...', repoId }, 'Access denied: no user pubkey provided for private repo'); return false; } @@ -154,9 +165,38 @@ export class MaintainerService { } catch { // Assume it's already a hex pubkey } + + // Normalize to lowercase for comparison + userPubkeyHex = userPubkeyHex.toLowerCase(); + const normalizedMaintainers = maintainers.map(m => m.toLowerCase()); + const normalizedOwner = owner.toLowerCase(); + + logger.debug({ + userPubkeyHex: userPubkeyHex.substring(0, 16) + '...', + normalizedOwner: normalizedOwner.substring(0, 16) + '...', + maintainers: normalizedMaintainers.map(m => m.substring(0, 16) + '...') + }, 'Comparing pubkeys'); + + // Check if user is in maintainers list OR is the current owner + const hasAccess = normalizedMaintainers.includes(userPubkeyHex) || userPubkeyHex === normalizedOwner; + + if (!hasAccess) { + logger.debug({ + userPubkeyHex: userPubkeyHex.substring(0, 16) + '...', + currentOwner: normalizedOwner.substring(0, 16) + '...', + repoId, + maintainers: normalizedMaintainers.map(m => m.substring(0, 16) + '...') + }, 'Access denied: user not in maintainers list and not current owner'); + } else { + logger.debug({ + userPubkeyHex: userPubkeyHex.substring(0, 16) + '...', + currentOwner: normalizedOwner.substring(0, 16) + '...', + repoId + }, 'Access granted: user is maintainer or current owner'); + } // Check if user is owner or maintainer - return maintainers.includes(userPubkeyHex); + return hasAccess; } /** diff --git a/src/lib/utils/api-auth.ts b/src/lib/utils/api-auth.ts index f141187..717a99a 100644 --- a/src/lib/utils/api-auth.ts +++ b/src/lib/utils/api-auth.ts @@ -27,6 +27,13 @@ export async function requireRepoAccess( requestContext: RequestContext, operation?: string ): Promise { + console.debug('[API Auth] requireRepoAccess check:', { + operation, + userPubkeyHex: requestContext.userPubkeyHex ? requestContext.userPubkeyHex.substring(0, 16) + '...' : null, + repoOwnerPubkey: repoContext.repoOwnerPubkey.substring(0, 16) + '...', + repo: repoContext.repo + }); + // First check if user is owner/maintainer (or repo is public) const canView = await maintainerService.canView( requestContext.userPubkeyHex || null, @@ -34,6 +41,8 @@ export async function requireRepoAccess( repoContext.repo ); + console.debug('[API Auth] canView result:', canView); + if (canView) { return; // User is owner/maintainer or repo is public, allow access } diff --git a/src/lib/utils/api-context.ts b/src/lib/utils/api-context.ts index 190441f..bebbaf4 100644 --- a/src/lib/utils/api-context.ts +++ b/src/lib/utils/api-context.ts @@ -56,9 +56,26 @@ export function extractRequestContext( event.request.headers.get('x-user-pubkey') || null; + // Debug logging + if (userPubkey) { + console.debug('[API Context] Extracted userPubkey from request:', userPubkey.substring(0, 16) + '...'); + } else { + console.debug('[API Context] No userPubkey found in request headers or query params'); + // Log all headers for debugging + const allHeaders: Record = {}; + event.request.headers.forEach((value, key) => { + allHeaders[key] = value; + }); + console.debug('[API Context] Request headers:', allHeaders); + } + // Convert to hex if needed const userPubkeyHex = userPubkey ? (decodeNpubToHex(userPubkey) || userPubkey) : null; + if (userPubkeyHex) { + console.debug('[API Context] Converted to hex:', userPubkeyHex.substring(0, 16) + '...'); + } + // Extract client IP let clientIp: string; try { diff --git a/src/routes/api/repos/[npub]/[repo]/tags/+server.ts b/src/routes/api/repos/[npub]/[repo]/tags/+server.ts index 228de56..a64acd6 100644 --- a/src/routes/api/repos/[npub]/[repo]/tags/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/tags/+server.ts @@ -8,10 +8,24 @@ import type { RequestHandler } from './$types'; import { fileManager } from '$lib/services/service-registry.js'; import { createRepoGetHandler, createRepoPostHandler } from '$lib/utils/api-handlers.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; -import { handleValidationError } from '$lib/utils/error-handler.js'; +import { handleValidationError, handleNotFoundError } from '$lib/utils/error-handler.js'; +import { join } from 'path'; +import { existsSync } from 'fs'; + +const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT + ? process.env.GIT_REPO_ROOT + : '/repos'; export const GET: RequestHandler = createRepoGetHandler( async (context: RepoRequestContext) => { + const repoPath = join(repoRoot, context.npub, `${context.repo}.git`); + + // If repo doesn't exist locally, return empty tags array + // Tags are only available for locally cloned repositories + if (!existsSync(repoPath)) { + return json([]); + } + const tags = await fileManager.getTags(context.npub, context.repo); return json(tags); }, diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index a6e3f3c..b55b760 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -6,7 +6,7 @@ import PRDetail from '$lib/components/PRDetail.svelte'; import UserBadge from '$lib/components/UserBadge.svelte'; import ForwardingConfig from '$lib/components/ForwardingConfig.svelte'; - import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js'; + import { getPublicKeyWithNIP07, isNIP07Available, signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; import { NostrClient } from '$lib/services/nostr/nostr-client.js'; import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS, combineRelays } from '$lib/config.js'; import { getUserRelays } from '$lib/services/nostr/user-relays.js'; @@ -70,6 +70,9 @@ // Reload data when user logs in or pubkey changes if (wasDifferent) { + // Reset repoNotFound flag when user logs in, so we can retry loading + repoNotFound = false; + checkMaintainerStatus().catch(err => console.warn('Failed to reload maintainer status after login:', err)); loadBookmarkStatus().catch(err => console.warn('Failed to reload bookmark status after login:', err)); // Reload all repository data with the new user context @@ -78,6 +81,8 @@ loadFiles().catch(err => console.warn('Failed to reload files after login:', err)); loadReadme().catch(err => console.warn('Failed to reload readme after login:', err)); loadTags().catch(err => console.warn('Failed to reload tags after login:', err)); + // Reload discussions when user logs in (needs user context for relay selection) + loadDiscussions().catch(err => console.warn('Failed to reload discussions after login:', err)); } } } else { @@ -162,8 +167,47 @@ let documentationHtml = $state(null); let loadingDocs = $state(false); + // Discussion threads + let showCreateThreadDialog = $state(false); + let newThreadTitle = $state(''); + let newThreadContent = $state(''); + let creatingThread = $state(false); + + // Thread replies + let expandedThreads = $state>(new Set()); + let showReplyDialog = $state(false); + let replyingToThreadId = $state(null); + let replyingToCommentId = $state(null); // For replying to comments + let replyContent = $state(''); + let creatingReply = $state(false); + // Discussions - let discussions = $state }>>([]); + let discussions = $state; + }>; + }> + }>>([]); let loadingDiscussions = $state(false); // README @@ -640,28 +684,39 @@ const { DiscussionsService } = await import('$lib/services/nostr/discussions-service.js'); // Get user's relays if available - let combinedRelays = DEFAULT_NOSTR_RELAYS; - if (userPubkey) { + let userRelays: string[] = []; + const currentUserPubkey = $userStore.userPubkey || userPubkey; + if (currentUserPubkey) { try { - const { outbox } = await getUserRelays(userPubkey, client); - combinedRelays = combineRelays(outbox); + const { outbox } = await getUserRelays(currentUserPubkey, client); + userRelays = outbox; } catch (err) { console.warn('Failed to get user relays, using defaults:', err); } } - // If no chat relays are defined for the project, use default relays - const relaysToUse = chatRelays.length > 0 ? chatRelays : DEFAULT_NOSTR_RELAYS; + // Combine all available relays: default + search + chat + user relays + const allRelays = [...new Set([ + ...DEFAULT_NOSTR_RELAYS, + ...DEFAULT_NOSTR_SEARCH_RELAYS, + ...chatRelays, + ...userRelays + ])]; + + console.log('[Discussions] Using all available relays for threads:', allRelays); + console.log('[Discussions] Chat relays from announcement:', chatRelays); - const discussionsService = new DiscussionsService(combinedRelays); + const discussionsService = new DiscussionsService(allRelays); const discussionEntries = await discussionsService.getDiscussions( repoOwnerPubkey, repo, announcement.id, announcement.pubkey, - relaysToUse, - combinedRelays + allRelays, // Use all relays for threads + allRelays // Use all relays for comments too ); + + console.log('[Discussions] Found', discussionEntries.length, 'discussion entries'); discussions = discussionEntries.map(entry => ({ type: entry.type, @@ -670,12 +725,7 @@ content: entry.content, author: entry.author, createdAt: entry.createdAt, - comments: entry.comments?.map(c => ({ - id: c.id, - content: c.content, - author: c.pubkey, - createdAt: c.created_at - })) + comments: entry.comments })); } catch (err) { error = err instanceof Error ? err.message : 'Failed to load discussions'; @@ -685,6 +735,303 @@ } } + async function createDiscussionThread() { + if (!userPubkey || !userPubkeyHex) { + error = 'You must be logged in to create a discussion thread'; + return; + } + + if (!newThreadTitle.trim()) { + error = 'Thread title is required'; + return; + } + + creatingThread = 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; + + // Get repo announcement to get the repo address + const client = new NostrClient(DEFAULT_NOSTR_RELAYS); + const events = await client.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [repoOwnerPubkey], + '#d': [repo], + limit: 1 + } + ]); + + if (events.length === 0) { + throw new Error('Repository announcement not found'); + } + + const announcement = events[0]; + const repoAddress = `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${repo}`; + + // Get chat relays from announcement, or use default relays + const chatRelays = announcement.tags + .filter(t => t[0] === 'chat-relay') + .flatMap(t => t.slice(1)) + .filter(url => url && typeof url === 'string') as string[]; + + // Combine all available relays + let allRelays = [...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS, ...chatRelays]; + if (userPubkey) { + try { + const { outbox } = await getUserRelays(userPubkey, client); + allRelays = [...allRelays, ...outbox]; + } catch (err) { + console.warn('Failed to get user relays:', err); + } + } + allRelays = [...new Set(allRelays)]; // Deduplicate + + // Create kind 11 thread event + const threadEventTemplate: Omit = { + kind: KIND.THREAD, + pubkey: userPubkeyHex, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['a', repoAddress], + ['title', newThreadTitle.trim()], + ['t', 'repo'] + ], + content: newThreadContent.trim() || '' + }; + + // Sign the event using NIP-07 + const signedEvent = await signEventWithNIP07(threadEventTemplate); + + // Publish to all available relays + const publishClient = new NostrClient(allRelays); + const result = await publishClient.publishEvent(signedEvent, allRelays); + + if (result.failed.length > 0 && result.success.length === 0) { + throw new Error('Failed to publish thread to all relays'); + } + + // Clear form and close dialog + newThreadTitle = ''; + newThreadContent = ''; + showCreateThreadDialog = false; + + // Reload discussions + await loadDiscussions(); + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to create discussion thread'; + console.error('Error creating discussion thread:', err); + } finally { + creatingThread = false; + } + } + + async function createThreadReply(threadId: string | null, commentId: string | null) { + if (!userPubkey || !userPubkeyHex) { + error = 'You must be logged in to reply'; + return; + } + + if (!replyContent.trim()) { + error = 'Reply content is required'; + return; + } + + if (!threadId && !commentId) { + error = 'Must reply to either a thread or a comment'; + return; + } + + creatingReply = 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; + + // Get repo announcement to get the repo address and relays + const client = new NostrClient(DEFAULT_NOSTR_RELAYS); + const events = await client.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [repoOwnerPubkey], + '#d': [repo], + limit: 1 + } + ]); + + if (events.length === 0) { + throw new Error('Repository announcement not found'); + } + + const announcement = events[0]; + + // Get chat relays from announcement, or use default relays + const chatRelays = announcement.tags + .filter(t => t[0] === 'chat-relay') + .flatMap(t => t.slice(1)) + .filter(url => url && typeof url === 'string') as string[]; + + // Combine all available relays + let allRelays = [...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS, ...chatRelays]; + if (userPubkey) { + try { + const { outbox } = await getUserRelays(userPubkey, client); + allRelays = [...allRelays, ...outbox]; + } catch (err) { + console.warn('Failed to get user relays:', err); + } + } + allRelays = [...new Set(allRelays)]; // Deduplicate + + let rootEventId: string; + let rootKind: number; + let rootPubkey: string; + let parentEventId: string; + let parentKind: number; + let parentPubkey: string; + + if (commentId) { + // Replying to a comment - get the comment event + const commentEvents = await client.fetchEvents([ + { + kinds: [KIND.COMMENT], + ids: [commentId], + limit: 1 + } + ]); + + if (commentEvents.length === 0) { + throw new Error('Comment not found'); + } + + const commentEvent = commentEvents[0]; + + // Find root event (E tag) or use thread ID if replying to thread comment + const ETag = commentEvent.tags.find(t => t[0] === 'E'); + const KTag = commentEvent.tags.find(t => t[0] === 'K'); + const PTag = commentEvent.tags.find(t => t[0] === 'P'); + + if (ETag && KTag) { + // Comment has root tags, use them + rootEventId = ETag[1]; + rootKind = parseInt(KTag[1]); + rootPubkey = PTag?.[1] || commentEvent.pubkey; + } else if (threadId) { + // Replying to a comment in a thread, use thread as root + const threadEvents = await client.fetchEvents([ + { + kinds: [KIND.THREAD], + ids: [threadId], + limit: 1 + } + ]); + if (threadEvents.length === 0) { + throw new Error('Thread not found'); + } + rootEventId = threadId; + rootKind = KIND.THREAD; + rootPubkey = threadEvents[0].pubkey; + } else { + throw new Error('Cannot determine root event'); + } + + // Parent is the comment we're replying to + parentEventId = commentId; + parentKind = KIND.COMMENT; + parentPubkey = commentEvent.pubkey; + } else if (threadId) { + // Replying directly to a thread + const threadEvents = await client.fetchEvents([ + { + kinds: [KIND.THREAD], + ids: [threadId], + limit: 1 + } + ]); + + if (threadEvents.length === 0) { + throw new Error('Thread not found'); + } + + const threadEvent = threadEvents[0]; + rootEventId = threadId; + rootKind = KIND.THREAD; + rootPubkey = threadEvent.pubkey; + parentEventId = threadId; + parentKind = KIND.THREAD; + parentPubkey = threadEvent.pubkey; + } else { + throw new Error('Must specify thread or comment to reply to'); + } + + // Create kind 1111 comment event + const commentEventTemplate: Omit = { + kind: KIND.COMMENT, + pubkey: userPubkeyHex, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['e', parentEventId, '', 'reply'], // Parent event + ['k', parentKind.toString()], // Parent kind + ['p', parentPubkey], // Parent pubkey + ['E', rootEventId], // Root event + ['K', rootKind.toString()], // Root kind + ['P', rootPubkey] // Root pubkey + ], + content: replyContent.trim() + }; + + // Sign the event using NIP-07 + const signedEvent = await signEventWithNIP07(commentEventTemplate); + + // Publish to all available relays + const publishClient = new NostrClient(allRelays); + const result = await publishClient.publishEvent(signedEvent, allRelays); + + if (result.failed.length > 0 && result.success.length === 0) { + throw new Error('Failed to publish reply to all relays'); + } + + // Clear form and close dialog + replyContent = ''; + showReplyDialog = false; + replyingToThreadId = null; + replyingToCommentId = null; + + // Reload discussions to show the new reply + await loadDiscussions(); + + // Expand the thread if we were replying to a thread + if (threadId) { + expandedThreads.add(threadId); + expandedThreads = new Set(expandedThreads); // Trigger reactivity + } + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to create reply'; + console.error('Error creating reply:', err); + } finally { + creatingReply = false; + } + } + + function toggleThread(threadId: string) { + if (expandedThreads.has(threadId)) { + expandedThreads.delete(threadId); + } else { + expandedThreads.add(threadId); + } + // Trigger reactivity + expandedThreads = new Set(expandedThreads); + } + async function loadDocumentation() { if (loadingDocs) return; // Only skip if we already have rendered HTML (successful load) @@ -1216,6 +1563,11 @@ // Repository not provisioned yet - set error message and flag repoNotFound = true; error = `Repository not found. This repository exists in Nostr but hasn't been provisioned on this server yet. The server will automatically provision it soon, or you can contact the server administrator.`; + } else if (response.status === 403) { + // Access denied - don't set repoNotFound, allow retry after login + const errorText = await response.text().catch(() => response.statusText); + error = `Access denied: ${errorText}. You may need to log in or you may not have permission to view this repository.`; + console.warn('[Branches] Access denied, user may need to log in'); } } catch (err) { console.error('Failed to load branches:', err); @@ -1238,6 +1590,10 @@ if (response.status === 404) { repoNotFound = true; throw new Error(`Repository not found. This repository exists in Nostr but hasn't been provisioned on this server yet. The server will automatically provision it soon, or you can contact the server administrator.`); + } else if (response.status === 403) { + // 403 means access denied - don't set repoNotFound, just show error + // This allows retry after login + throw new Error(`Access denied: ${response.statusText}. You may need to log in or you may not have permission to view this repository.`); } throw new Error(`Failed to load files: ${response.statusText}`); } @@ -2493,24 +2849,74 @@ {#if activeTab === 'discussions'}
+
+

Discussions

+
+ + {#if userPubkey} + + {/if} +
+
{#if loadingDiscussions}
Loading discussions...
{:else if discussions.length === 0}
-

No discussions found. Check your repository settings to configure chat relays for kind 11 threads.

+

No discussions found. {#if userPubkey}Create a new discussion thread to get started!{:else}Log in to create a discussion thread.{/if}

{:else} {#each discussions as discussion} + {@const isExpanded = discussion.type === 'thread' && expandedThreads.has(discussion.id)} + {@const hasComments = discussion.comments && discussion.comments.length > 0}
-

{discussion.title}

+
+ {#if discussion.type === 'thread'} + + {/if} +

{discussion.title}

+
{#if discussion.type === 'thread'} Thread + {#if hasComments} + {discussion.comments!.length} {discussion.comments!.length === 1 ? 'reply' : 'replies'} + {/if} {:else} Comments {/if} Created {new Date(discussion.createdAt * 1000).toLocaleString()} + {#if discussion.type === 'thread' && userPubkey} + + {/if}
{#if discussion.content} @@ -2518,18 +2924,165 @@

{discussion.content}

{/if} - {#if discussion.comments && discussion.comments.length > 0} + {#if discussion.type === 'thread' && isExpanded && hasComments}
-

Comments ({discussion.comments.length})

- {#each discussion.comments as comment} +

Replies ({discussion.comments!.length})

+ {#each discussion.comments! as comment}
{new Date(comment.createdAt * 1000).toLocaleString()} + {#if userPubkey} + + {/if}

{comment.content}

+ {#if comment.replies && comment.replies.length > 0} +
+ {#each comment.replies as reply} +
+
+ + {new Date(reply.createdAt * 1000).toLocaleString()} + {#if userPubkey} + + {/if} +
+
+

{reply.content}

+
+ {#if reply.replies && reply.replies.length > 0} +
+ {#each reply.replies as nestedReply} +
+
+ + {new Date(nestedReply.createdAt * 1000).toLocaleString()} + {#if userPubkey} + + {/if} +
+
+

{nestedReply.content}

+
+
+ {/each} +
+ {/if} +
+ {/each} +
+ {/if} +
+ {/each} +
+ {:else if discussion.type === 'comments' && hasComments} +
+

Comments ({discussion.comments!.length})

+ {#each discussion.comments! as comment} +
+
+ + {new Date(comment.createdAt * 1000).toLocaleString()} + {#if userPubkey} + + {/if} +
+
+

{comment.content}

+
+ {#if comment.replies && comment.replies.length > 0} +
+ {#each comment.replies as reply} +
+
+ + {new Date(reply.createdAt * 1000).toLocaleString()} + {#if userPubkey} + + {/if} +
+
+

{reply.content}

+
+ {#if reply.replies && reply.replies.length > 0} +
+ {#each reply.replies as nestedReply} +
+
+ + {new Date(nestedReply.createdAt * 1000).toLocaleString()} + {#if userPubkey} + + {/if} +
+
+

{nestedReply.content}

+
+
+ {/each} +
+ {/if} +
+ {/each} +
+ {/if}
{/each}
@@ -2699,6 +3252,103 @@
{/if} + + {#if showCreateThreadDialog && userPubkey} + + {/if} + + + {#if showReplyDialog && userPubkey && (replyingToThreadId || replyingToCommentId)} + + {/if} + {#if showCreatePRDialog && userPubkey}