From 6e642fb5da608d766b548f6ae5a3614c287a58a2 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 18 Feb 2026 12:16:55 +0100 Subject: [PATCH] add repo-level discussion threads and chat add chat-relay tag to repo announcements support all standard filters switch write-access checks to public messages --- README.md | 4 +- docs/01.md | 4 +- docs/98.md | 4 +- docs/CustomKinds.md | 2 +- docs/NIP_COMPLIANCE.md | 1 - src/lib/services/nostr/discussions-service.ts | 232 ++++++++++++++++++ .../services/nostr/public-messages-service.ts | 100 +++++++- src/lib/services/nostr/relay-write-proof.ts | 26 +- src/lib/services/nostr/user-level-service.ts | 2 +- src/lib/types/nostr.ts | 15 +- .../repos/[npub]/[repo]/settings/+server.ts | 10 +- src/routes/repos/[npub]/[repo]/+page.svelte | 144 ++++++++++- .../repos/[npub]/[repo]/settings/+page.svelte | 25 ++ 13 files changed, 545 insertions(+), 24 deletions(-) create mode 100644 src/lib/services/nostr/discussions-service.ts diff --git a/README.md b/README.md index 3306559..e76310c 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ This project uses the following Nostr event kinds. For complete JSON examples an - **27235** (`NIP98_AUTH`): NIP-98 HTTP authentication events - **3**: Contact list (NIP-02, for relay discovery) - **10002**: Relay list metadata (NIP-65, for relay discovery) -- **1**: Text note (NIP-01, for relay write proof, fallback) +- **24**: Public message (NIP-24, for relay write proof) - **5**: Event deletion request (NIP-09) ### Custom Event Kinds @@ -227,7 +227,7 @@ Instead of traditional rate limiting, users must prove they can write to at leas - User publishes a NIP-98 event (kind 27235) to a default relay - Event must be within 60 seconds (per NIP-98 spec) - Server verifies event exists on relay - - Alternative: User publishes kind 1 text note (5-minute window) + - Alternative: User publishes kind 24 public message (5-minute window) 2. **Verification**: - Server queries relay for the proof event diff --git a/docs/01.md b/docs/01.md index 63545d1..5a30630 100644 --- a/docs/01.md +++ b/docs/01.md @@ -189,8 +189,8 @@ All repository-related events (announcements, PRs, issues, patches, etc.) follow - Signatures use Schnorr signatures on secp256k1 - The `nostr-tools` library is used for event serialization, ID computation, and signature verification -### Kind 1 (Text Note) Usage +### Kind 24 (Public Message) Usage for Relay Write Proof -GitRepublic uses kind 1 events as a fallback mechanism for relay write proofs when NIP-98 authentication events are not available. This ensures git operations can still be authenticated even if the NIP-98 flow fails. +GitRepublic uses kind 24 (public message) events for relay write proofs when NIP-98 authentication events are not available. This ensures git operations can still be authenticated even if the NIP-98 flow fails. **Implementation**: `src/lib/services/nostr/nostr-client.ts`, `src/lib/types/nostr.ts` diff --git a/docs/98.md b/docs/98.md index 96e8f8f..6094e75 100644 --- a/docs/98.md +++ b/docs/98.md @@ -100,8 +100,8 @@ GitRepublic normalizes URLs before comparison to handle trailing slashes and ens - Preserves query parameters - Handles both HTTP and HTTPS -### Fallback to Kind 1 +### Fallback to Kind 24 -If NIP-98 authentication fails, GitRepublic can fall back to kind 1 (text note) events for relay write proofs, though this is less secure and not recommended. +If NIP-98 authentication fails, GitRepublic can fall back to kind 24 (public message) events for relay write proofs. **Implementation**: `src/lib/services/nostr/nip98-auth.ts`, used in all git operation endpoints and API routes diff --git a/docs/CustomKinds.md b/docs/CustomKinds.md index 7b96e5f..f50ec14 100644 --- a/docs/CustomKinds.md +++ b/docs/CustomKinds.md @@ -53,7 +53,7 @@ Git commit signature events are used to cryptographically sign git commits using ### Rationale -Using a dedicated kind (1640) instead of kind 1 (text note) prevents spamming the user's feed with commit signatures. It also provides a clear, searchable way to find all commits signed by a specific user. +Using a dedicated kind (1640) prevents spamming the user's feed with commit signatures. It also provides a clear, searchable way to find all commits signed by a specific user. **Implementation**: `src/lib/services/git/commit-signer.ts` diff --git a/docs/NIP_COMPLIANCE.md b/docs/NIP_COMPLIANCE.md index d3d7269..d2afeac 100644 --- a/docs/NIP_COMPLIANCE.md +++ b/docs/NIP_COMPLIANCE.md @@ -10,7 +10,6 @@ GitRepublic implements the following standard NIPs: - **[NIP-01: Basic Protocol Flow](01.md)** - Event structure, signatures, and client-relay communication - Foundation for all Nostr events - - Used for relay write proof fallback (kind 1) ### Authentication & Identity diff --git a/src/lib/services/nostr/discussions-service.ts b/src/lib/services/nostr/discussions-service.ts new file mode 100644 index 0000000..4a6a205 --- /dev/null +++ b/src/lib/services/nostr/discussions-service.ts @@ -0,0 +1,232 @@ +/** + * Service for managing repository discussions (NIP-7D kind 11 threads and NIP-22 kind 1111 comments) + */ + +import { NostrClient } from './nostr-client.js'; +import { KIND } from '../../types/nostr.js'; +import type { NostrEvent } from '../../types/nostr.js'; +import { verifyEvent } from 'nostr-tools'; + +export interface Thread extends NostrEvent { + kind: typeof KIND.THREAD; + title?: string; +} + +export interface Comment extends NostrEvent { + kind: typeof KIND.COMMENT; + rootKind: number; + parentKind: number; + rootPubkey?: string; + parentPubkey?: string; +} + +export interface DiscussionThread extends Thread { + comments?: Comment[]; +} + +export interface DiscussionEntry { + type: 'thread' | 'comments'; + id: string; + title: string; + content: string; + author: string; + createdAt: number; + comments?: Comment[]; +} + +/** + * Service for managing discussions + */ +export class DiscussionsService { + private nostrClient: NostrClient; + + constructor(relays: string[] = []) { + this.nostrClient = new NostrClient(relays); + } + + /** + * Get repo address from owner and repo ID + */ + private getRepoAddress(repoOwnerPubkey: string, repoId: string): string { + return `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${repoId}`; + } + + /** + * Fetch kind 11 discussion threads from chat relays + * Threads should reference the repo announcement via an 'a' tag + */ + async getThreads( + repoOwnerPubkey: string, + repoId: string, + chatRelays: string[] + ): Promise { + if (!chatRelays || chatRelays.length === 0) { + return []; + } + + const repoAddress = this.getRepoAddress(repoOwnerPubkey, repoId); + + // Create a client for chat relays + const chatClient = new NostrClient(chatRelays); + + // Fetch threads from chat relays + const threads = await chatClient.fetchEvents([ + { + kinds: [KIND.THREAD], + '#a': [repoAddress], + limit: 100 + } + ]) as NostrEvent[]; + + const parsedThreads: Thread[] = []; + for (const event of threads) { + if (!verifyEvent(event)) { + continue; + } + + if (event.kind !== KIND.THREAD) { + continue; + } + + const titleTag = event.tags.find(t => t[0] === 'title'); + + parsedThreads.push({ + ...event, + kind: KIND.THREAD, + title: titleTag?.[1] + }); + } + + // Sort by creation time (newest first) + parsedThreads.sort((a, b) => b.created_at - a.created_at); + return parsedThreads; + } + + /** + * Fetch kind 1111 comments directly on the repo announcement event + * Comments should reference the repo announcement via 'E' and 'K' tags + */ + async getCommentsOnAnnouncement( + announcementId: string, + announcementPubkey: string, + relays: string[] + ): Promise { + // Create a client for the specified relays + const relayClient = new NostrClient(relays); + + const comments = await relayClient.fetchEvents([ + { + 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[]; + + const parsedComments: Comment[] = []; + for (const event of comments) { + if (!verifyEvent(event)) { + continue; + } + + // Verify this comment is for the repo announcement + // NIP-22 uses uppercase 'E' for root event ID + 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'); + + if (!ETag || ETag[1] !== announcementId) { + continue; + } + + if (!KTag || KTag[1] !== KIND.REPO_ANNOUNCEMENT.toString()) { + continue; + } + + // For top-level comments, parent should also be the announcement + // NIP-22 uses lowercase 'e' for parent event ID + 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()) { + 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 + }); + } + } + + // Sort by creation time (oldest first for comments) + parsedComments.sort((a, b) => a.created_at - b.created_at); + return parsedComments; + } + + /** + * Get all discussions (threads + comments) for a repository + */ + async getDiscussions( + repoOwnerPubkey: string, + repoId: string, + announcementId: string, + announcementPubkey: string, + chatRelays: string[], + defaultRelays: string[] + ): Promise { + const entries: DiscussionEntry[] = []; + + // Fetch threads from chat relays + const threads = await this.getThreads(repoOwnerPubkey, repoId, chatRelays); + + for (const thread of threads) { + entries.push({ + type: 'thread', + id: thread.id, + title: thread.title || 'Untitled Thread', + content: thread.content, + author: thread.pubkey, + createdAt: thread.created_at + }); + } + + // Fetch comments directly on the announcement + const comments = await this.getCommentsOnAnnouncement( + announcementId, + announcementPubkey, + defaultRelays + ); + + // If there are comments, create a pseudo-thread entry called "Comments" + if (comments.length > 0) { + entries.push({ + type: 'comments', + id: `comments-${announcementId}`, + title: 'Comments', + content: '', // No content for the pseudo-thread + author: '', + createdAt: comments[0]?.created_at || 0, + comments + }); + } + + // Sort entries: threads first (by creation time, newest first), then comments + entries.sort((a, b) => { + if (a.type === 'comments' && b.type === 'thread') { + return 1; // Comments always go last + } + if (a.type === 'thread' && b.type === 'comments') { + return -1; // Threads always go first + } + // Both same type, sort by creation time + return b.createdAt - a.createdAt; + }); + + return entries; + } +} diff --git a/src/lib/services/nostr/public-messages-service.ts b/src/lib/services/nostr/public-messages-service.ts index 0c0f8bb..af46ea4 100644 --- a/src/lib/services/nostr/public-messages-service.ts +++ b/src/lib/services/nostr/public-messages-service.ts @@ -121,15 +121,101 @@ export class PublicMessagesService { } } + /** + * Get the referenced message ID from a public message's q tag (NIP-18) + * Returns the event ID if the message is replying to another message + */ + getQuotedMessageId(message: PublicMessage): string | null { + const qTag = message.tags.find(tag => tag[0] === 'q' && tag[1]); + return qTag?.[1] || null; + } + + /** + * Fetch a public message by ID (for displaying reply context) + */ + async getMessageById( + messageId: string, + relayHint?: string + ): Promise { + try { + const filters: any[] = [ + { + kinds: [KIND.PUBLIC_MESSAGE], + ids: [messageId], + limit: 1 + } + ]; + + // If relay hint is provided, try that relay first + if (relayHint) { + const hintClient = new NostrClient([relayHint]); + const events = await hintClient.fetchEvents(filters); + if (events.length > 0 && events[0].kind === KIND.PUBLIC_MESSAGE && verifyEvent(events[0])) { + return events[0] as PublicMessage; + } + } + + // Fallback to default relays + const events = await this.nostrClient.fetchEvents(filters); + if (events.length > 0 && events[0].kind === KIND.PUBLIC_MESSAGE && verifyEvent(events[0])) { + return events[0] as PublicMessage; + } + + return null; + } catch (error) { + logger.error({ error, messageId: messageId.slice(0, 16) + '...' }, 'Failed to fetch message by ID'); + return null; + } + } + + /** + * Fetch messages that quote/reference a specific message (using q tags, NIP-18) + */ + async getMessagesQuotingMessage( + quotedMessageId: string, + limit: number = 50 + ): Promise { + try { + const events = await this.nostrClient.fetchEvents([ + { + kinds: [KIND.PUBLIC_MESSAGE], + '#q': [quotedMessageId], // Messages that quote this message (NIP-18) + limit + } + ]); + + // Verify events + const validMessages = events + .filter((e): e is PublicMessage => { + if (e.kind !== KIND.PUBLIC_MESSAGE) return false; + if (!verifyEvent(e)) { + logger.warn({ eventId: e.id.slice(0, 16) + '...' }, 'Invalid signature in public message'); + return false; + } + return true; + }) + .sort((a, b) => b.created_at - a.created_at); // Newest first + + return validMessages; + } catch (error) { + logger.error({ error, quotedMessageId: quotedMessageId.slice(0, 16) + '...' }, 'Failed to fetch messages quoting message'); + throw error; + } + } + /** * Create and publish a public message * Messages are sent to inbox relays of recipients and outbox relay of sender + * @param quotedMessageId - Optional: ID of message being replied to (adds q tag for context, NIP-18) + * @param quotedMessageRelay - Optional: Relay hint for fetching the quoted message */ async sendPublicMessage( senderPubkey: string, content: string, recipients: Array<{ pubkey: string; relay?: string }>, - senderRelays?: string[] + senderRelays?: string[], + quotedMessageId?: string, + quotedMessageRelay?: string ): Promise { if (!content.trim()) { throw new Error('Message content cannot be empty'); @@ -148,12 +234,22 @@ export class PublicMessagesService { return tag; }); + // Add q tag if replying to another message (NIP-18) + const tags: string[][] = [...pTags]; + if (quotedMessageId) { + const qTag: string[] = ['q', quotedMessageId]; + if (quotedMessageRelay) { + qTag.push(quotedMessageRelay); + } + tags.push(qTag); + } + // Create the event (will be signed by client) const messageEvent: Omit = { pubkey: senderPubkey, kind: KIND.PUBLIC_MESSAGE, created_at: Math.floor(Date.now() / 1000), - tags: pTags, + tags, content: content.trim() }; diff --git a/src/lib/services/nostr/relay-write-proof.ts b/src/lib/services/nostr/relay-write-proof.ts index 5281fd4..b9f3a63 100644 --- a/src/lib/services/nostr/relay-write-proof.ts +++ b/src/lib/services/nostr/relay-write-proof.ts @@ -27,9 +27,11 @@ export interface RelayWriteProof { * * Accepts: * - NIP-98 events (kind 27235) - preferred, since they're already used for HTTP auth - * - Kind 1 (text note) events - for backward compatibility + * - Kind 24 (public message) events - for relay write proof + * - Must be addressed to the user themselves (their pubkey in the p tag) + * - User writes a public message to themselves on default relays to prove write access * - * The proof should be a recent event (within 60 seconds for NIP-98, 5 minutes for kind 1) + * The proof should be a recent event (within 60 seconds for NIP-98, 5 minutes for kind 24) * published to a default relay. */ export async function verifyRelayWriteProof( @@ -49,7 +51,7 @@ export async function verifyRelayWriteProof( // Determine time window based on event kind // NIP-98 events (27235) should be within 60 seconds per spec - // Other events (like kind 1) can be within 5 minutes + // Other events (like kind 24) can be within 5 minutes const isNIP98Event = proofEvent.kind === KIND.NIP98_AUTH; const maxAge = isNIP98Event ? 60 : 300; // 60 seconds for NIP-98, 5 minutes for others @@ -84,6 +86,14 @@ export async function verifyRelayWriteProof( } } + // For kind 24 (public message) events, validate they are addressed to the user themselves + if (proofEvent.kind === KIND.PUBLIC_MESSAGE) { + const pTag = proofEvent.tags.find(t => t[0] === 'p' && t[1]); + if (!pTag || pTag[1] !== userPubkey) { + return { valid: false, error: 'Public message proof must be addressed to the user themselves (p tag must contain user pubkey)' }; + } + } + // Try to verify the event exists on at least one default relay // User only needs write access to ONE of the default relays, not all // This is a trust mechanism - if they can write to any trusted relay, they're trusted @@ -128,15 +138,19 @@ export async function verifyRelayWriteProof( * For new implementations, prefer using NIP-98 events (kind 27235) as they * serve dual purpose: HTTP authentication and relay write proof. * - * This function creates a simple kind 1 event for backward compatibility. + * This function creates a kind 24 (public message) event addressed to the user + * themselves (their own pubkey in the p tag) to prove they can write to default relays. */ export function createProofEvent(userPubkey: string, content: string = 'gitrepublic-write-proof'): Omit { return { - kind: KIND.TEXT_NOTE, + kind: KIND.PUBLIC_MESSAGE, pubkey: userPubkey, created_at: Math.floor(Date.now() / 1000), content: content, - tags: [['t', 'gitrepublic-proof']] + tags: [ + ['p', userPubkey], // Send to self to prove write access + ['t', 'gitrepublic-proof'] + ] }; } diff --git a/src/lib/services/nostr/user-level-service.ts b/src/lib/services/nostr/user-level-service.ts index 52bceca..076bcde 100644 --- a/src/lib/services/nostr/user-level-service.ts +++ b/src/lib/services/nostr/user-level-service.ts @@ -36,7 +36,7 @@ export async function checkRelayWriteAccess( } try { - // Create a proof event (kind 1 text note) + // Create a proof event (kind 24 public message) const proofEventTemplate = createProofEvent( userPubkeyHex, `gitrepublic-write-proof-${Date.now()}` diff --git a/src/lib/types/nostr.ts b/src/lib/types/nostr.ts index 9bebbad..b4aba9e 100644 --- a/src/lib/types/nostr.ts +++ b/src/lib/types/nostr.ts @@ -16,10 +16,17 @@ export interface NostrFilter { ids?: string[]; authors?: string[]; kinds?: number[]; - '#e'?: string[]; - '#p'?: string[]; + '#e'?: string[]; // Lowercase: event references (parent in NIP-22) + '#p'?: string[]; // Lowercase: pubkey references (parent in NIP-22) '#d'?: string[]; - '#a'?: string[]; + '#a'?: string[]; // Lowercase: address references (parent in NIP-22) + '#E'?: string[]; // Uppercase: root event references (NIP-22) + '#K'?: string[]; // Uppercase: root kind references (NIP-22) + '#P'?: string[]; // Uppercase: root pubkey references (NIP-22) + '#A'?: string[]; // Uppercase: root address references (NIP-22) + '#I'?: string[]; // Uppercase: root I-tag references (NIP-22) + '#i'?: string[]; // Lowercase: parent I-tag references (NIP-22) + '#q'?: string[]; // Quoted event references (NIP-18, NIP-21, NIP-22, NIP-24) since?: number; until?: number; limit?: number; @@ -27,7 +34,6 @@ export interface NostrFilter { } export const KIND = { - TEXT_NOTE: 1, // NIP-01: Text note (used for relay write proof fallback) CONTACT_LIST: 3, // NIP-02: Contact list - See /docs for GitRepublic usage documentation DELETION_REQUEST: 5, // NIP-09: Event deletion request REPO_ANNOUNCEMENT: 30617, // NIP-34: Repository announcement @@ -43,6 +49,7 @@ export const KIND = { COMMIT_SIGNATURE: 1640, // Custom: Git commit signature event OWNERSHIP_TRANSFER: 1641, // Custom: Repository ownership transfer event (non-replaceable for chain integrity) COMMENT: 1111, // NIP-22: Comment event + THREAD: 11, // NIP-7D: Discussion thread BRANCH_PROTECTION: 30620, // Custom: Branch protection rules RELAY_LIST: 10002, // NIP-65: Relay list metadata NIP98_AUTH: 27235, // NIP-98: HTTP authentication event diff --git a/src/routes/api/repos/[npub]/[repo]/settings/+server.ts b/src/routes/api/repos/[npub]/[repo]/settings/+server.ts index 052ca08..5420ab2 100644 --- a/src/routes/api/repos/[npub]/[repo]/settings/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/settings/+server.ts @@ -53,6 +53,10 @@ export const GET: RequestHandler = createRepoGetHandler( .filter(t => t[0] === 'maintainers') .flatMap(t => t.slice(1)) .filter(m => m && typeof m === 'string') as string[]; + const chatRelays = announcement.tags + .filter(t => t[0] === 'chat-relay') + .flatMap(t => t.slice(1)) + .filter(url => url && typeof url === 'string') as string[]; const privacyInfo = await maintainerService.getPrivacyInfo(currentOwner, context.repo); const isPrivate = privacyInfo.isPrivate; @@ -61,6 +65,7 @@ export const GET: RequestHandler = createRepoGetHandler( description, cloneUrls, maintainers, + chatRelays, isPrivate, owner: currentOwner, npub: context.npub @@ -79,7 +84,7 @@ export const POST: RequestHandler = withRepoValidation( } const body = await event.request.json(); - const { name, description, cloneUrls, maintainers, isPrivate } = body; + const { name, description, cloneUrls, maintainers, chatRelays, isPrivate } = body; // Check if user is owner const currentOwner = await ownershipTransferService.getCurrentOwner(repoContext.repoOwnerPubkey, repoContext.repo); @@ -150,7 +155,8 @@ export const POST: RequestHandler = withRepoValidation( ['clone', ...cloneUrlList], ['relays', ...DEFAULT_NOSTR_RELAYS], ...(isPrivate ? [['private', 'true']] : []), - ...(maintainers || []).map((m: string) => ['maintainers', m]) + ...(maintainers || []).map((m: string) => ['maintainers', m]), + ...(chatRelays && chatRelays.length > 0 ? [['chat-relay', ...chatRelays]] : []) ]; // Preserve other tags from original announcement diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index e94415e..a3c0e3e 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -50,7 +50,7 @@ let commitMessage = $state(''); let userPubkey = $state(null); let showCommitDialog = $state(false); - let activeTab = $state<'files' | 'history' | 'tags' | 'issues' | 'prs' | 'docs'>('files'); + let activeTab = $state<'files' | 'history' | 'tags' | 'issues' | 'prs' | 'docs' | 'discussions'>('discussions'); // Navigation stack for directories let pathStack = $state([]); @@ -111,6 +111,10 @@ let documentationHtml = $state(null); let loadingDocs = $state(false); + // Discussions + let discussions = $state }>>([]); + let loadingDiscussions = $state(false); + // README let readmeContent = $state(null); let readmePath = $state(null); @@ -479,6 +483,86 @@ } } + async function loadDiscussions() { + if (repoNotFound) return; + loadingDiscussions = 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; + + // Fetch repo announcement to get chat-relay tags and announcement ID + 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) { + discussions = []; + return; + } + + const announcement = events[0]; + const chatRelays = announcement.tags + .filter(t => t[0] === 'chat-relay') + .flatMap(t => t.slice(1)) + .filter(url => url && typeof url === 'string') as string[]; + + // Get default relays + const { getGitUrl } = await import('$lib/config.js'); + const { DiscussionsService } = await import('$lib/services/nostr/discussions-service.js'); + + // Get user's relays if available + let combinedRelays = DEFAULT_NOSTR_RELAYS; + if (userPubkey) { + try { + const { outbox } = await getUserRelays(userPubkey, client); + combinedRelays = combineRelays(outbox); + } catch (err) { + console.warn('Failed to get user relays, using defaults:', err); + } + } + + const discussionsService = new DiscussionsService(combinedRelays); + const discussionEntries = await discussionsService.getDiscussions( + repoOwnerPubkey, + repo, + announcement.id, + announcement.pubkey, + chatRelays, + combinedRelays + ); + + discussions = discussionEntries.map(entry => ({ + type: entry.type, + id: entry.id, + title: entry.title, + 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 + })) + })); + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to load discussions'; + console.error('Error loading discussions:', err); + } finally { + loadingDiscussions = false; + } + } + async function loadDocumentation() { if (loadingDocs || documentationContent !== null) return; @@ -1323,6 +1407,8 @@ loadPRs(); } else if (activeTab === 'docs') { loadDocumentation(); + } else if (activeTab === 'discussions') { + loadDiscussions(); } } }); @@ -1515,6 +1601,13 @@
+
diff --git a/src/routes/repos/[npub]/[repo]/settings/+page.svelte b/src/routes/repos/[npub]/[repo]/settings/+page.svelte index 2c9797b..566a547 100644 --- a/src/routes/repos/[npub]/[repo]/settings/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/settings/+page.svelte @@ -16,6 +16,7 @@ let description = $state(''); let cloneUrls = $state(['']); let maintainers = $state(['']); + let chatRelays = $state(['']); let isPrivate = $state(false); onMount(async () => { @@ -45,6 +46,7 @@ description = data.description || ''; cloneUrls = data.cloneUrls?.length > 0 ? data.cloneUrls : ['']; maintainers = data.maintainers?.length > 0 ? data.maintainers : ['']; + chatRelays = data.chatRelays?.length > 0 ? data.chatRelays : ['']; isPrivate = data.isPrivate || false; } else { const data = await response.json(); @@ -79,6 +81,7 @@ description, cloneUrls: cloneUrls.filter(url => url.trim()), maintainers: maintainers.filter(m => m.trim()), + chatRelays: chatRelays.filter(url => url.trim()), isPrivate }) }); @@ -112,6 +115,14 @@ function removeMaintainer(index: number) { maintainers = maintainers.filter((_, i) => i !== index); } + + function addChatRelay() { + chatRelays = [...chatRelays, '']; + } + + function removeChatRelay(index: number) { + chatRelays = chatRelays.filter((_, i) => i !== index); + }
@@ -176,6 +187,20 @@
+
+

Chat Relays

+

WebSocket relays for kind 11 discussion threads (e.g., wss://myprojechat.com, ws://localhost:2937)

+ {#each chatRelays as relay, index} +
+ + {#if chatRelays.length > 1} + + {/if} +
+ {/each} + +
+ {#if error}
{error}
{/if}