You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

456 lines
14 KiB

/**
* 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;
kind?: number; // Event kind (11 for threads, 1111 for comments)
pubkey?: string; // Event pubkey (for naddr encoding)
comments?: Array<{
id: string;
content: string;
author: string;
createdAt: number;
kind?: number; // Event kind (1111 for comments)
pubkey?: string; // Event pubkey
replies?: Array<{
id: string;
content: string;
author: string;
createdAt: number;
kind?: number;
pubkey?: string;
replies?: Array<{
id: string;
content: string;
author: string;
createdAt: number;
kind?: number;
pubkey?: string;
}>;
}>;
}>;
}
/**
* 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,
allRelays: string[]
): Promise<Thread[]> {
// 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 all available relays
const client = new NostrClient(allRelays);
// 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) {
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 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[],
repoOwnerPubkey?: string,
repoId?: string
): Promise<Comment[]> {
// Create a client for the specified relays
const relayClient = new NostrClient(relays);
// 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],
'#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 allComments) {
// Skip duplicates
if (seenIds.has(event.id)) {
continue;
}
seenIds.add(event.id);
if (!verifyEvent(event)) {
continue;
}
// 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)
}
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;
}
/**
* 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;
}
// 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');
// 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: parseInt(KTag[1]),
parentKind: kTag ? parseInt(kTag[1]) : KIND.THREAD,
rootPubkey: PTag?.[1] || threadPubkey,
parentPubkey: pTag?.[1] || threadPubkey
});
}
}
// Sort by creation time (oldest first for comments)
parsedComments.sort((a, b) => a.created_at - b.created_at);
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;
kind?: number;
pubkey?: string;
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,
kind: comment.kind,
pubkey: comment.pubkey,
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,
kind: comment.kind,
pubkey: comment.pubkey,
replies: comment.replies.length > 0 ? comment.replies.map(formatComment) : undefined
};
};
return rootComments.map(formatComment);
}
/**
* Get all discussions (threads + comments) for a repository
*/
async getDiscussions(
repoOwnerPubkey: string,
repoId: string,
announcementId: string,
announcementPubkey: string,
allRelays: string[],
commentRelays: string[]
): Promise<DiscussionEntry[]> {
const entries: DiscussionEntry[] = [];
// 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,
kind: thread.kind,
pubkey: thread.pubkey,
comments: commentTree
});
}
// Fetch comments directly on the announcement
const comments = await this.getCommentsOnAnnouncement(
announcementId,
announcementPubkey,
commentRelays,
repoOwnerPubkey,
repoId
);
// 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: comments.map(c => ({
id: c.id,
content: c.content,
author: c.pubkey,
createdAt: c.created_at,
kind: c.kind,
pubkey: c.pubkey
}))
});
}
// 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;
}
}