Browse Source

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
main
Silberengel 4 weeks ago
parent
commit
6e642fb5da
  1. 4
      README.md
  2. 4
      docs/01.md
  3. 4
      docs/98.md
  4. 2
      docs/CustomKinds.md
  5. 1
      docs/NIP_COMPLIANCE.md
  6. 232
      src/lib/services/nostr/discussions-service.ts
  7. 100
      src/lib/services/nostr/public-messages-service.ts
  8. 26
      src/lib/services/nostr/relay-write-proof.ts
  9. 2
      src/lib/services/nostr/user-level-service.ts
  10. 15
      src/lib/types/nostr.ts
  11. 10
      src/routes/api/repos/[npub]/[repo]/settings/+server.ts
  12. 144
      src/routes/repos/[npub]/[repo]/+page.svelte
  13. 25
      src/routes/repos/[npub]/[repo]/settings/+page.svelte

4
README.md

@ -78,7 +78,7 @@ This project uses the following Nostr event kinds. For complete JSON examples an @@ -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 @@ -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

4
docs/01.md

@ -189,8 +189,8 @@ All repository-related events (announcements, PRs, issues, patches, etc.) follow @@ -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`

4
docs/98.md

@ -100,8 +100,8 @@ GitRepublic normalizes URLs before comparison to handle trailing slashes and ens @@ -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

2
docs/CustomKinds.md

@ -53,7 +53,7 @@ Git commit signature events are used to cryptographically sign git commits using @@ -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`

1
docs/NIP_COMPLIANCE.md

@ -10,7 +10,6 @@ GitRepublic implements the following standard NIPs: @@ -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

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

@ -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;
}
}

100
src/lib/services/nostr/public-messages-service.ts

@ -121,15 +121,101 @@ export class PublicMessagesService { @@ -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<PublicMessage | null> {
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<PublicMessage[]> {
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<PublicMessage> {
if (!content.trim()) {
throw new Error('Message content cannot be empty');
@ -148,12 +234,22 @@ export class PublicMessagesService { @@ -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<PublicMessage, 'id' | 'sig'> = {
pubkey: senderPubkey,
kind: KIND.PUBLIC_MESSAGE,
created_at: Math.floor(Date.now() / 1000),
tags: pTags,
tags,
content: content.trim()
};

26
src/lib/services/nostr/relay-write-proof.ts

@ -27,9 +27,11 @@ export interface RelayWriteProof { @@ -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( @@ -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( @@ -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( @@ -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<NostrEvent, 'sig' | 'id'> {
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']
]
};
}

2
src/lib/services/nostr/user-level-service.ts

@ -36,7 +36,7 @@ export async function checkRelayWriteAccess( @@ -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()}`

15
src/lib/types/nostr.ts

@ -16,10 +16,17 @@ export interface NostrFilter { @@ -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 { @@ -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 = { @@ -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

10
src/routes/api/repos/[npub]/[repo]/settings/+server.ts

@ -53,6 +53,10 @@ export const GET: RequestHandler = createRepoGetHandler( @@ -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( @@ -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( @@ -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( @@ -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

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

@ -50,7 +50,7 @@ @@ -50,7 +50,7 @@
let commitMessage = $state('');
let userPubkey = $state<string | null>(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<string[]>([]);
@ -111,6 +111,10 @@ @@ -111,6 +111,10 @@
let documentationHtml = $state<string | null>(null);
let loadingDocs = $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 loadingDiscussions = $state(false);
// README
let readmeContent = $state<string | null>(null);
let readmePath = $state<string | null>(null);
@ -479,6 +483,86 @@ @@ -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 @@ @@ -1323,6 +1407,8 @@
loadPRs();
} else if (activeTab === 'docs') {
loadDocumentation();
} else if (activeTab === 'discussions') {
loadDiscussions();
}
}
});
@ -1515,6 +1601,13 @@ @@ -1515,6 +1601,13 @@
<!-- Tabs -->
<div class="tabs">
<button
class="tab-button"
class:active={activeTab === 'discussions'}
onclick={() => activeTab = 'discussions'}
>
Discussions
</button>
<button
class="tab-button"
class:active={activeTab === 'files'}
@ -1941,6 +2034,55 @@ @@ -1941,6 +2034,55 @@
{/if}
</div>
{/if}
{#if activeTab === 'discussions'}
<div class="discussions-content">
{#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>
</div>
{:else}
{#each discussions as discussion}
<div class="discussion-item">
<div class="discussion-header">
<h3>{discussion.title}</h3>
<div class="discussion-meta">
{#if discussion.type === 'thread'}
<span class="discussion-type">Thread</span>
{:else}
<span class="discussion-type">Comments</span>
{/if}
<span>Created {new Date(discussion.createdAt * 1000).toLocaleString()}</span>
</div>
</div>
{#if discussion.content}
<div class="discussion-body">
<p>{discussion.content}</p>
</div>
{/if}
{#if discussion.comments && discussion.comments.length > 0}
<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>
</div>
<div class="comment-content">
<p>{comment.content}</p>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/each}
{/if}
</div>
{/if}
</div>
</div>
</main>

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

@ -16,6 +16,7 @@ @@ -16,6 +16,7 @@
let description = $state('');
let cloneUrls = $state<string[]>(['']);
let maintainers = $state<string[]>(['']);
let chatRelays = $state<string[]>(['']);
let isPrivate = $state(false);
onMount(async () => {
@ -45,6 +46,7 @@ @@ -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 @@ @@ -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 @@ @@ -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);
}
</script>
<div class="container">
@ -176,6 +187,20 @@ @@ -176,6 +187,20 @@
<button type="button" onclick={addMaintainer} class="add-button">+ Add Maintainer</button>
</div>
<div class="form-section">
<h2>Chat Relays</h2>
<p class="help-text">WebSocket relays for kind 11 discussion threads (e.g., wss://myprojechat.com, ws://localhost:2937)</p>
{#each chatRelays as relay, index}
<div class="array-input">
<input type="text" bind:value={chatRelays[index]} placeholder="wss://example.com" />
{#if chatRelays.length > 1}
<button type="button" onclick={() => removeChatRelay(index)} class="remove-button">Remove</button>
{/if}
</div>
{/each}
<button type="button" onclick={addChatRelay} class="add-button">+ Add Chat Relay</button>
</div>
{#if error}
<div class="error">{error}</div>
{/if}

Loading…
Cancel
Save