Browse Source

fix repo discussion threads

main
Silberengel 4 weeks ago
parent
commit
c66ec646ff
  1. 282
      src/lib/services/nostr/discussions-service.ts
  2. 44
      src/lib/services/nostr/maintainer-service.ts
  3. 9
      src/lib/utils/api-auth.ts
  4. 17
      src/lib/utils/api-context.ts
  5. 16
      src/routes/api/repos/[npub]/[repo]/tags/+server.ts
  6. 859
      src/routes/repos/[npub]/[repo]/+page.svelte

282
src/lib/services/nostr/discussions-service.ts

@ -31,7 +31,24 @@ export interface DiscussionEntry { @@ -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,19 +75,22 @@ export class DiscussionsService { @@ -58,19 +75,22 @@ export class DiscussionsService {
async getThreads(
repoOwnerPubkey: string,
repoId: string,
chatRelays: string[]
allRelays: string[]
): Promise<Thread[]> {
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],
@ -78,6 +98,8 @@ export class DiscussionsService { @@ -78,6 +98,8 @@ export class DiscussionsService {
}
]) as NostrEvent[];
console.log('[Discussions] Found', threads.length, 'thread events');
const parsedThreads: Thread[] = [];
for (const event of threads) {
if (!verifyEvent(event)) {
@ -104,61 +126,155 @@ export class DiscussionsService { @@ -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<Comment[]> {
// 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<string>();
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();
// 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)
}
if (!ETag || ETag[1] !== announcementId) {
continue;
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
});
}
}
// Sort by creation time (oldest first for comments)
parsedComments.sort((a, b) => a.created_at - b.created_at);
return parsedComments;
}
if (!KTag || KTag[1] !== KIND.REPO_ANNOUNCEMENT.toString()) {
/**
* 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<Comment[]> {
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<string>();
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');
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()) {
// 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 { @@ -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<string, {
id: string;
content: string;
author: string;
createdAt: number;
parentId?: string;
replies: any[];
}>();
// 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 { @@ -176,22 +368,29 @@ export class DiscussionsService {
repoId: string,
announcementId: string,
announcementPubkey: string,
chatRelays: string[],
defaultRelays: string[]
allRelays: string[],
commentRelays: string[]
): Promise<DiscussionEntry[]> {
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 { @@ -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 { @@ -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
}))
});
}

44
src/lib/services/nostr/maintainer-service.ts

@ -132,15 +132,26 @@ export class MaintainerService { @@ -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<boolean> {
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;
}
@ -155,8 +166,37 @@ export class MaintainerService { @@ -155,8 +166,37 @@ export class MaintainerService {
// 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;
}
/**

9
src/lib/utils/api-auth.ts

@ -27,6 +27,13 @@ export async function requireRepoAccess( @@ -27,6 +27,13 @@ export async function requireRepoAccess(
requestContext: RequestContext,
operation?: string
): Promise<void> {
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( @@ -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
}

17
src/lib/utils/api-context.ts

@ -56,9 +56,26 @@ export function extractRequestContext( @@ -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<string, string> = {};
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 {

16
src/routes/api/repos/[npub]/[repo]/tags/+server.ts

@ -8,10 +8,24 @@ import type { RequestHandler } from './$types'; @@ -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);
},

859
src/routes/repos/[npub]/[repo]/+page.svelte

@ -6,7 +6,7 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -162,8 +167,47 @@
let documentationHtml = $state<string | null>(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<Set<string>>(new Set());
let showReplyDialog = $state(false);
let replyingToThreadId = $state<string | null>(null);
let replyingToCommentId = $state<string | null>(null); // For replying to comments
let replyContent = $state('');
let creatingReply = $state(false);
// Discussions
let discussions = $state<Array<{ type: 'thread' | 'comments'; id: string; title: string; content: string; author: string; createdAt: number; comments?: Array<{ id: string; content: string; author: string; createdAt: number }> }>>([]);
let discussions = $state<Array<{
type: 'thread' | 'comments';
id: string;
title: string;
content: string;
author: string;
createdAt: number;
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;
}>;
}>;
}>
}>>([]);
let loadingDiscussions = $state(false);
// README
@ -640,29 +684,40 @@ @@ -640,29 +684,40 @@
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,
id: entry.id,
@ -670,12 +725,7 @@ @@ -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 @@ @@ -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<NostrEvent, 'sig' | 'id'> = {
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<NostrEvent, 'sig' | 'id'> = {
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 @@ @@ -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 @@ @@ -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 @@ @@ -2493,24 +2849,74 @@
{#if activeTab === 'discussions'}
<div class="discussions-content">
<div class="discussions-header">
<h2>Discussions</h2>
<div class="discussions-actions">
<button
class="btn btn-secondary"
onclick={() => loadDiscussions()}
disabled={loadingDiscussions}
title="Refresh discussions"
>
{loadingDiscussions ? 'Refreshing...' : '↻ Refresh'}
</button>
{#if userPubkey}
<button
class="btn btn-primary"
onclick={() => showCreateThreadDialog = true}
disabled={creatingThread}
>
{creatingThread ? 'Creating...' : 'New Discussion Thread'}
</button>
{/if}
</div>
</div>
{#if loadingDiscussions}
<div class="loading">Loading discussions...</div>
{:else if discussions.length === 0}
<div class="empty-state">
<p>No discussions found. Check your repository settings to configure chat relays for kind 11 threads.</p>
<p>No discussions found. {#if userPubkey}Create a new discussion thread to get started!{:else}Log in to create a discussion thread.{/if}</p>
</div>
{:else}
{#each discussions as discussion}
{@const isExpanded = discussion.type === 'thread' && expandedThreads.has(discussion.id)}
{@const hasComments = discussion.comments && discussion.comments.length > 0}
<div class="discussion-item">
<div class="discussion-header">
<h3>{discussion.title}</h3>
<div class="discussion-title-row">
{#if discussion.type === 'thread'}
<button
class="expand-button"
onclick={() => toggleThread(discussion.id)}
aria-expanded={isExpanded}
aria-label={isExpanded ? 'Collapse thread' : 'Expand thread'}
>
{isExpanded ? '▼' : '▶'}
</button>
{/if}
<h3>{discussion.title}</h3>
</div>
<div class="discussion-meta">
{#if discussion.type === 'thread'}
<span class="discussion-type">Thread</span>
{#if hasComments}
<span class="comment-count">{discussion.comments!.length} {discussion.comments!.length === 1 ? 'reply' : 'replies'}</span>
{/if}
{:else}
<span class="discussion-type">Comments</span>
{/if}
<span>Created {new Date(discussion.createdAt * 1000).toLocaleString()}</span>
{#if discussion.type === 'thread' && userPubkey}
<button
class="btn btn-small"
onclick={() => {
replyingToThreadId = discussion.id;
showReplyDialog = true;
}}
>
Reply
</button>
{/if}
</div>
</div>
{#if discussion.content}
@ -2518,18 +2924,165 @@ @@ -2518,18 +2924,165 @@
<p>{discussion.content}</p>
</div>
{/if}
{#if discussion.comments && discussion.comments.length > 0}
{#if discussion.type === 'thread' && isExpanded && hasComments}
<div class="comments-section">
<h4>Comments ({discussion.comments.length})</h4>
{#each discussion.comments as comment}
<h4>Replies ({discussion.comments!.length})</h4>
{#each discussion.comments! as comment}
<div class="comment-item">
<div class="comment-meta">
<UserBadge pubkey={comment.author} />
<span>{new Date(comment.createdAt * 1000).toLocaleString()}</span>
{#if userPubkey}
<button
class="btn btn-small"
onclick={() => {
replyingToThreadId = discussion.id;
replyingToCommentId = comment.id;
showReplyDialog = true;
}}
>
Reply
</button>
{/if}
</div>
<div class="comment-content">
<p>{comment.content}</p>
</div>
{#if comment.replies && comment.replies.length > 0}
<div class="nested-replies">
{#each comment.replies as reply}
<div class="comment-item nested-comment">
<div class="comment-meta">
<UserBadge pubkey={reply.author} />
<span>{new Date(reply.createdAt * 1000).toLocaleString()}</span>
{#if userPubkey}
<button
class="btn btn-small"
onclick={() => {
replyingToThreadId = discussion.id;
replyingToCommentId = reply.id;
showReplyDialog = true;
}}
>
Reply
</button>
{/if}
</div>
<div class="comment-content">
<p>{reply.content}</p>
</div>
{#if reply.replies && reply.replies.length > 0}
<div class="nested-replies">
{#each reply.replies as nestedReply}
<div class="comment-item nested-comment">
<div class="comment-meta">
<UserBadge pubkey={nestedReply.author} />
<span>{new Date(nestedReply.createdAt * 1000).toLocaleString()}</span>
{#if userPubkey}
<button
class="btn btn-small"
onclick={() => {
replyingToThreadId = discussion.id;
replyingToCommentId = nestedReply.id;
showReplyDialog = true;
}}
>
Reply
</button>
{/if}
</div>
<div class="comment-content">
<p>{nestedReply.content}</p>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{:else if discussion.type === 'comments' && hasComments}
<div class="comments-section">
<h4>Comments ({discussion.comments!.length})</h4>
{#each discussion.comments! as comment}
<div class="comment-item">
<div class="comment-meta">
<UserBadge pubkey={comment.author} />
<span>{new Date(comment.createdAt * 1000).toLocaleString()}</span>
{#if userPubkey}
<button
class="btn btn-small"
onclick={() => {
replyingToThreadId = null;
replyingToCommentId = comment.id;
showReplyDialog = true;
}}
>
Reply
</button>
{/if}
</div>
<div class="comment-content">
<p>{comment.content}</p>
</div>
{#if comment.replies && comment.replies.length > 0}
<div class="nested-replies">
{#each comment.replies as reply}
<div class="comment-item nested-comment">
<div class="comment-meta">
<UserBadge pubkey={reply.author} />
<span>{new Date(reply.createdAt * 1000).toLocaleString()}</span>
{#if userPubkey}
<button
class="btn btn-small"
onclick={() => {
replyingToThreadId = null;
replyingToCommentId = reply.id;
showReplyDialog = true;
}}
>
Reply
</button>
{/if}
</div>
<div class="comment-content">
<p>{reply.content}</p>
</div>
{#if reply.replies && reply.replies.length > 0}
<div class="nested-replies">
{#each reply.replies as nestedReply}
<div class="comment-item nested-comment">
<div class="comment-meta">
<UserBadge pubkey={nestedReply.author} />
<span>{new Date(nestedReply.createdAt * 1000).toLocaleString()}</span>
{#if userPubkey}
<button
class="btn btn-small"
onclick={() => {
replyingToThreadId = null;
replyingToCommentId = nestedReply.id;
showReplyDialog = true;
}}
>
Reply
</button>
{/if}
</div>
<div class="comment-content">
<p>{nestedReply.content}</p>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
@ -2699,6 +3252,103 @@ @@ -2699,6 +3252,103 @@
</div>
{/if}
<!-- Create Discussion Thread Dialog -->
{#if showCreateThreadDialog && userPubkey}
<div
class="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="Create new discussion thread"
onclick={() => showCreateThreadDialog = false}
onkeydown={(e) => e.key === 'Escape' && (showCreateThreadDialog = false)}
tabindex="-1"
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="modal"
role="document"
onclick={(e) => e.stopPropagation()}
>
<h3>Create New Discussion Thread</h3>
<label>
Title:
<input type="text" bind:value={newThreadTitle} placeholder="Thread title..." />
</label>
<label>
Content:
<textarea bind:value={newThreadContent} rows="10" placeholder="Start the discussion..."></textarea>
</label>
<div class="modal-actions">
<button onclick={() => showCreateThreadDialog = false} class="cancel-button">Cancel</button>
<button onclick={createDiscussionThread} disabled={!newThreadTitle.trim() || creatingThread} class="save-button">
{creatingThread ? 'Creating...' : 'Create Thread'}
</button>
</div>
</div>
</div>
{/if}
<!-- Reply to Thread/Comment Dialog -->
{#if showReplyDialog && userPubkey && (replyingToThreadId || replyingToCommentId)}
<div
class="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="Reply to thread"
onclick={() => {
showReplyDialog = false;
replyingToThreadId = null;
replyingToCommentId = null;
replyContent = '';
}}
onkeydown={(e) => e.key === 'Escape' && (showReplyDialog = false)}
tabindex="-1"
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="modal"
role="document"
onclick={(e) => e.stopPropagation()}
>
<h3>
{#if replyingToCommentId}
Reply to Comment
{:else if replyingToThreadId}
Reply to Thread
{:else}
Reply
{/if}
</h3>
<label>
Your Reply:
<textarea bind:value={replyContent} rows="8" placeholder="Write your reply..."></textarea>
</label>
<div class="modal-actions">
<button
onclick={() => {
showReplyDialog = false;
replyingToThreadId = null;
replyingToCommentId = null;
replyContent = '';
}}
class="cancel-button"
>
Cancel
</button>
<button
onclick={() => createThreadReply(replyingToThreadId, replyingToCommentId)}
disabled={!replyContent.trim() || creatingReply}
class="save-button"
>
{creatingReply ? 'Posting...' : 'Post Reply'}
</button>
</div>
</div>
</div>
{/if}
<!-- Create PR Dialog -->
{#if showCreatePRDialog && userPubkey}
<div
@ -4166,6 +4816,171 @@ @@ -4166,6 +4816,171 @@
align-items: center;
}
.discussions-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--card-bg);
}
.discussions-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.discussions-actions {
display: flex;
gap: 0.75rem;
align-items: center;
}
.btn-secondary {
padding: 0.5rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.25rem;
color: var(--text-primary);
cursor: pointer;
font-size: 0.875rem;
transition: background 0.2s;
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-tertiary);
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.discussions-header h2 {
margin: 0;
font-size: 1.25rem;
color: var(--text-primary);
}
.discussion-item {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.discussion-header {
margin-bottom: 0.5rem;
}
.discussion-header h3 {
margin: 0 0 0.25rem 0;
font-size: 1.1rem;
color: var(--text-primary);
}
.discussion-meta {
display: flex;
gap: 1rem;
font-size: 0.875rem;
color: var(--text-muted);
}
.discussion-type {
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
background: var(--bg-secondary);
font-weight: 500;
}
.discussion-body {
margin-top: 0.5rem;
color: var(--text-primary);
white-space: pre-wrap;
}
.discussion-title-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.expand-button {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
display: flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
transition: color 0.2s;
}
.expand-button:hover {
color: var(--text-primary);
}
.comment-count {
font-weight: 500;
}
.btn-small {
padding: 0.25rem 0.75rem;
font-size: 0.875rem;
}
.comments-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.comments-section h4 {
margin: 0 0 0.75rem 0;
font-size: 0.9rem;
color: var(--text-muted);
font-weight: 500;
}
.comment-item {
padding: 0.75rem 0;
border-bottom: 1px solid var(--border-light);
}
.comment-item:last-child {
border-bottom: none;
}
.comment-meta {
display: flex;
gap: 0.75rem;
align-items: center;
margin-bottom: 0.5rem;
font-size: 0.875rem;
color: var(--text-muted);
}
.comment-content {
color: var(--text-primary);
white-space: pre-wrap;
line-height: 1.5;
}
.nested-replies {
margin-left: 2rem;
margin-top: 0.75rem;
padding-left: 1rem;
border-left: 2px solid var(--border-light);
}
.nested-comment {
margin-top: 0.75rem;
}
.readme-content {
flex: 1;
overflow-y: auto;

Loading…
Cancel
Save