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.
 
 
 
 
 

661 lines
19 KiB

<script lang="ts">
import { onMount } from 'svelte';
import TabLayout from './TabLayout.svelte';
import DiscussionRenderer, { type Discussion } from '$lib/components/DiscussionRenderer.svelte';
import CommentRenderer from '$lib/components/CommentRenderer.svelte';
// Define Comment type locally to match CommentRenderer's export
type Comment = {
id: string;
content: string;
author: string;
createdAt: number;
kind: number;
pubkey: string;
replies?: Comment[];
};
import EventCopyButton from '$lib/components/EventCopyButton.svelte';
import { DiscussionsService } from '$lib/services/nostr/discussions-service.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';
import { loadNostrLinks } from '$lib/utils/nostr-links.js';
import type { NostrEvent } from '$lib/types/nostr.js';
import { KIND } from '$lib/types/nostr.js';
import { signEventWithNIP07, getPublicKeyWithNIP07 } from '$lib/services/nostr/nip07-signer.js';
interface Props {
npub: string;
repo: string;
repoAnnouncement?: NostrEvent;
userPubkey?: string | null;
}
let { npub, repo, repoAnnouncement, userPubkey }: Props = $props();
let discussions = $state<Discussion[]>([]);
let loadingDiscussions = $state(false);
let selectedDiscussion = $state<string | null>(null);
let error = $state<string | null>(null);
// Event caches for Nostr links
let discussionEvents = $state<Map<string, NostrEvent>>(new Map());
let nostrLinkEvents = $state<Map<string, NostrEvent>>(new Map());
let nostrLinkProfiles = $state<Map<string, string>>(new Map());
// Dialog states
let showCreateThreadDialog = $state(false);
let showReplyDialog = $state(false);
let creatingThread = $state(false);
let creatingReply = $state(false);
let replyingToThread = $state<{ id: string; kind: number; pubkey: string; author: string } | null>(null);
let replyingToComment = $state<{ id: string; kind: number; pubkey: string; author: string } | null>(null);
let newThreadTitle = $state('');
let newThreadContent = $state('');
let replyContent = $state('');
const discussionsService = new DiscussionsService(DEFAULT_NOSTR_RELAYS);
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
onMount(async () => {
await loadDiscussions();
});
async function loadDiscussions() {
if (!repoAnnouncement) return;
loadingDiscussions = true;
error = null;
try {
const userRelays = userPubkey ? await getUserRelays(userPubkey, nostrClient) : null;
const allDefaultRelays = [...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS];
const combinedRelays = combineRelays(
userRelays?.outbox || [],
allDefaultRelays
);
const { nip19 } = await import('nostr-tools');
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') {
throw new Error('Invalid npub format');
}
const repoOwnerPubkey = decoded.data as string;
const discussionsServiceWithRelays = new DiscussionsService(combinedRelays);
const discussionEntries = await discussionsServiceWithRelays.getDiscussions(
repoOwnerPubkey,
repo,
repoAnnouncement.id,
repoAnnouncement.pubkey,
combinedRelays,
combinedRelays
);
const fetchedDiscussions = discussionEntries.map(entry => ({
type: entry.type,
id: entry.id,
title: entry.title,
content: entry.content,
author: entry.author,
createdAt: entry.createdAt,
kind: entry.kind ?? KIND.THREAD,
pubkey: entry.pubkey ?? '',
comments: entry.comments
}));
discussions = fetchedDiscussions;
// Load events for discussions and comments
await loadDiscussionEvents(fetchedDiscussions);
// Load Nostr links from all discussion content
for (const discussion of fetchedDiscussions) {
if (discussion.content) {
await loadNostrLinks(discussion.content, nostrClient, nostrLinkEvents, nostrLinkProfiles);
}
if (discussion.comments) {
for (const comment of discussion.comments) {
await loadNostrLinks(comment.content, nostrClient, nostrLinkEvents, nostrLinkProfiles);
if (comment.replies) {
for (const reply of comment.replies) {
await loadNostrLinks(reply.content, nostrClient, nostrLinkEvents, nostrLinkProfiles);
}
}
}
}
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load discussions';
console.error('Error loading discussions:', err);
} finally {
loadingDiscussions = false;
}
}
async function loadDiscussionEvents(discussionsList: Discussion[]) {
const eventIds = new Set<string>();
// Collect all event IDs
for (const discussion of discussionsList) {
if (discussion.id) {
eventIds.add(discussion.id);
}
if (discussion.comments) {
for (const comment of discussion.comments) {
if (comment.id) {
eventIds.add(comment.id);
}
if (comment.replies) {
for (const reply of comment.replies) {
if (reply.id) {
eventIds.add(reply.id);
}
}
}
}
}
}
if (eventIds.size === 0) return;
try {
const events = await Promise.race([
nostrClient.fetchEvents([{ ids: Array.from(eventIds), limit: eventIds.size }]),
new Promise<NostrEvent[]>((resolve) => setTimeout(() => resolve([]), 10000))
]);
for (const event of events) {
discussionEvents.set(event.id, event);
}
} catch {
// Ignore fetch errors
}
}
function countAllReplies(comments?: Comment[]): number {
if (!comments) return 0;
let count = comments.length;
for (const comment of comments) {
if (comment.replies) {
count += countAllReplies(comment.replies);
}
}
return count;
}
async function createDiscussionThread() {
if (!userPubkey || !repoAnnouncement || !newThreadTitle.trim()) return;
creatingThread = true;
error = null;
try {
const userPubkeyHex = await getPublicKeyWithNIP07();
if (!userPubkeyHex) throw new Error('Failed to get user pubkey');
const { nip19 } = await import('nostr-tools');
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') {
throw new Error('Invalid npub format');
}
const repoOwnerPubkey = decoded.data as string;
const repoAddress = `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${repo}`;
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() || ''
};
const signedEvent = await signEventWithNIP07(threadEventTemplate);
const userRelays = await getUserRelays(userPubkeyHex, nostrClient);
const allDefaultRelays = [...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS];
const combinedRelays = combineRelays(
userRelays?.outbox || [],
allDefaultRelays
);
const publishClient = new NostrClient(combinedRelays);
await publishClient.publishEvent(signedEvent, combinedRelays);
showCreateThreadDialog = false;
newThreadTitle = '';
newThreadContent = '';
await loadDiscussions();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to create discussion thread';
console.error('Error creating thread:', err);
} finally {
creatingThread = false;
}
}
async function createThreadReply() {
if (!userPubkey || !replyingToThread || !replyContent.trim()) return;
creatingReply = true;
error = null;
try {
const userPubkeyHex = await getPublicKeyWithNIP07();
if (!userPubkeyHex) throw new Error('Failed to get user pubkey');
const commentEventTemplate: Omit<NostrEvent, 'sig' | 'id'> = {
kind: KIND.COMMENT,
pubkey: userPubkeyHex,
created_at: Math.floor(Date.now() / 1000),
tags: [
['E', replyingToThread.id], // Root event (the thread)
['K', KIND.THREAD.toString()], // Root kind
['P', replyingToThread.pubkey], // Root pubkey
...(replyingToComment ? [
['e', replyingToComment.id], // Parent event (the comment being replied to)
['k', replyingToComment.kind.toString()], // Parent kind
['p', replyingToComment.pubkey] // Parent pubkey
] : [])
],
content: replyContent.trim()
};
const signedEvent = await signEventWithNIP07(commentEventTemplate);
const userRelays = await getUserRelays(userPubkeyHex, nostrClient);
const allDefaultRelays = [...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS];
const combinedRelays = combineRelays(
userRelays?.outbox || [],
allDefaultRelays
);
const publishClient = new NostrClient(combinedRelays);
await publishClient.publishEvent(signedEvent, combinedRelays);
showReplyDialog = false;
replyContent = '';
replyingToThread = null;
replyingToComment = null;
await loadDiscussions();
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to create reply';
console.error('Error creating reply:', err);
} finally {
creatingReply = false;
}
}
function handleReplyToThread(threadId: string) {
const discussion = discussions.find(d => d.id === threadId);
if (discussion) {
replyingToThread = { id: discussion.id, kind: discussion.kind, pubkey: discussion.pubkey, author: discussion.author };
replyingToComment = null;
showReplyDialog = true;
}
}
function handleReplyToComment(commentId: string) {
// Find the discussion and comment
for (const discussion of discussions) {
if (discussion.comments) {
const findComment = (comments: Comment[]): Comment | undefined => {
for (const comment of comments) {
if (comment.id === commentId) return comment;
if (comment.replies) {
const found = findComment(comment.replies);
if (found) return found;
}
}
return undefined;
};
const comment = findComment(discussion.comments);
if (comment) {
replyingToThread = { id: discussion.id, kind: discussion.kind, pubkey: discussion.pubkey, author: discussion.author };
replyingToComment = { id: comment.id, kind: comment.kind, pubkey: comment.pubkey, author: comment.author };
showReplyDialog = true;
return;
}
}
}
}
</script>
<TabLayout>
{#snippet leftPane()}
<div class="discussions-sidebar">
<div class="discussions-header">
<h2>Discussions</h2>
{#if userPubkey}
<button
onclick={() => showCreateThreadDialog = true}
class="create-discussion-button"
disabled={creatingThread}
title={creatingThread ? 'Creating...' : 'New Discussion Thread'}
>
<img src="/icons/plus.svg" alt="New Discussion" class="icon" />
</button>
{/if}
</div>
{#if loadingDiscussions}
<div class="loading">Loading discussions...</div>
{:else if discussions.length > 0}
<ul class="discussion-list">
{#each discussions as discussion}
{@const hasComments = discussion.comments && discussion.comments.length > 0}
{@const totalReplies = hasComments ? countAllReplies(discussion.comments) : 0}
<li class="discussion-item" class:selected={selectedDiscussion === discussion.id}>
<button
onclick={() => selectedDiscussion = discussion.id}
class="discussion-item-button"
>
<div class="discussion-header">
<span class="discussion-title">{discussion.title}</span>
</div>
<div class="discussion-meta">
{#if discussion.type === 'thread'}
<span class="discussion-type">Thread</span>
{#if hasComments}
<span class="comment-count">{totalReplies} {totalReplies === 1 ? 'reply' : 'replies'}</span>
{/if}
{:else}
<span class="discussion-type">Comments</span>
{/if}
<span>{new Date(discussion.createdAt * 1000).toLocaleDateString()}</span>
<EventCopyButton eventId={discussion.id} kind={discussion.kind} pubkey={discussion.pubkey} />
</div>
</button>
</li>
{/each}
</ul>
{/if}
</div>
{/snippet}
{#snippet rightPanel()}
{#if error}
<div class="error">{error}</div>
{/if}
{#if selectedDiscussion}
{@const discussion = discussions.find(d => d.id === selectedDiscussion)}
{#if discussion}
<DiscussionRenderer
{discussion}
discussionEvent={discussionEvents.get(discussion.id)}
eventCache={discussionEvents}
profileCache={nostrLinkProfiles}
{userPubkey}
onReplyToThread={handleReplyToThread}
onReplyToComment={handleReplyToComment}
/>
{/if}
{:else}
<div class="empty-state">
<p>Select a discussion from the sidebar to view it</p>
</div>
{/if}
{/snippet}
</TabLayout>
<!-- Create Thread Dialog -->
{#if showCreateThreadDialog && userPubkey}
<div
class="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="Create discussion thread"
onclick={() => showCreateThreadDialog = false}
onkeydown={(e) => e.key === 'Escape' && (showCreateThreadDialog = false)}
tabindex="-1"
>
<div
class="modal"
role="dialog"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
tabindex="-1"
>
<h3>Create Discussion Thread</h3>
<label>
Title:
<input type="text" bind:value={newThreadTitle} placeholder="Thread title..." />
</label>
<label>
Content:
<textarea bind:value={newThreadContent} rows="6" placeholder="Thread content..."></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'}
</button>
</div>
</div>
</div>
{/if}
<!-- Reply Dialog -->
{#if showReplyDialog && userPubkey && replyingToThread}
<div
class="modal-overlay"
role="dialog"
aria-modal="true"
aria-label="Reply to discussion"
onclick={() => showReplyDialog = false}
onkeydown={(e) => e.key === 'Escape' && (showReplyDialog = false)}
tabindex="-1"
>
<div
class="modal"
role="dialog"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
tabindex="-1"
>
<h3>{replyingToComment ? 'Reply to Comment' : 'Reply to Thread'}</h3>
<label>
Reply:
<textarea bind:value={replyContent} rows="6" placeholder="Your reply..."></textarea>
</label>
<div class="modal-actions">
<button onclick={() => showReplyDialog = false} class="cancel-button">Cancel</button>
<button
onclick={createThreadReply}
disabled={!replyContent.trim() || creatingReply}
class="save-button"
>
{creatingReply ? 'Posting...' : 'Post Reply'}
</button>
</div>
</div>
</div>
{/if}
<style>
.discussions-sidebar {
height: 100%;
overflow-y: auto;
}
.discussions-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.discussions-header h2 {
margin: 0;
font-size: 1.25rem;
}
.create-discussion-button {
background: none;
border: none;
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
}
.create-discussion-button:hover {
opacity: 0.7;
}
.create-discussion-button .icon {
width: 20px;
height: 20px;
}
.discussion-list {
list-style: none;
padding: 0;
margin: 0;
}
.discussion-item {
margin-bottom: 0.5rem;
}
.discussion-item-button {
width: 100%;
text-align: left;
padding: 0.75rem;
background: var(--item-bg, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
}
.discussion-item-button:hover {
background: var(--item-hover-bg, #f5f5f5);
}
.discussion-item.selected .discussion-item-button {
background: var(--selected-bg, #e3f2fd);
border-color: var(--selected-border, #2196f3);
}
.discussion-header {
margin-bottom: 0.5rem;
}
.discussion-title {
font-weight: 600;
font-size: 1rem;
}
.discussion-meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--text-secondary, #666);
}
.discussion-type {
padding: 0.125rem 0.5rem;
background: var(--type-bg, #e0e0e0);
border-radius: 4px;
font-size: 0.75rem;
}
.comment-count {
font-weight: 500;
}
.empty-state {
padding: 2rem;
text-align: center;
color: var(--text-secondary, #666);
}
.error {
padding: 1rem;
background: var(--error-bg, #ffebee);
color: var(--error-color, #c62828);
border-radius: 4px;
margin-bottom: 1rem;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--modal-bg, #fff);
border-radius: 8px;
padding: 1.5rem;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal h3 {
margin: 0 0 1rem 0;
}
.modal label {
display: block;
margin-bottom: 1rem;
}
.modal label input,
.modal label textarea {
width: 100%;
padding: 0.5rem;
margin-top: 0.25rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.cancel-button,
.save-button {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
.cancel-button {
background: var(--cancel-bg, #e0e0e0);
}
.save-button {
background: var(--primary-color, #2196f3);
color: white;
}
.save-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>