Browse Source
add chat-relay tag to repo announcements support all standard filters switch write-access checks to public messagesmain
13 changed files with 545 additions and 24 deletions
@ -0,0 +1,232 @@
@@ -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<Thread[]> { |
||||
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<Comment[]> { |
||||
// 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<DiscussionEntry[]> { |
||||
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; |
||||
} |
||||
} |
||||
Loading…
Reference in new issue