Browse Source
Nostr-Signature: 9375bfe35e0574bc722cad243c22fdf374dcc9016f91f358ff9ddf1d0a03bb50 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 10fbbcbc7cab48dfd2340f0c9eceafe558d893789e4838cbe26493e5c339f7a1f015d1cc4af8bfa51d57e9a9da94bb1bb44841305d5ce7cf92db9938985d0459main
24 changed files with 1747 additions and 603 deletions
@ -0,0 +1,218 @@
@@ -0,0 +1,218 @@
|
||||
<script lang="ts"> |
||||
import UserBadge from '$lib/components/UserBadge.svelte'; |
||||
import EventCopyButton from '$lib/components/EventCopyButton.svelte'; |
||||
import { |
||||
processContentWithNostrLinks, |
||||
getReferencedEventFromDiscussion, |
||||
formatDiscussionTime, |
||||
type ProcessedContentPart |
||||
} from '$lib/utils/nostr-links.js'; |
||||
import type { NostrEvent } from '$lib/types/nostr.js'; |
||||
import CommentRendererSelf from './CommentRenderer.svelte'; |
||||
|
||||
export interface Comment { |
||||
id: string; |
||||
content: string; |
||||
author: string; |
||||
createdAt: number; |
||||
kind: number; |
||||
pubkey: string; |
||||
replies?: Comment[]; |
||||
} |
||||
|
||||
interface Props { |
||||
comment: Comment; |
||||
commentEvent?: NostrEvent; // Full event for getting referenced events |
||||
eventCache: Map<string, NostrEvent>; |
||||
profileCache: Map<string, string>; |
||||
userPubkey?: string | null; |
||||
onReply?: (commentId: string) => void; |
||||
nested?: boolean; |
||||
} |
||||
|
||||
let { |
||||
comment, |
||||
commentEvent, |
||||
eventCache, |
||||
profileCache, |
||||
userPubkey, |
||||
onReply, |
||||
nested = false |
||||
}: Props = $props(); |
||||
|
||||
const referencedEvent = $derived(commentEvent |
||||
? getReferencedEventFromDiscussion(commentEvent, eventCache) |
||||
: undefined); |
||||
|
||||
const contentParts = $derived(processContentWithNostrLinks(comment.content, eventCache, profileCache)); |
||||
</script> |
||||
|
||||
<div class="comment-item" class:nested-comment={nested}> |
||||
<div class="comment-meta"> |
||||
<UserBadge pubkey={comment.author} /> |
||||
<span>{new Date(comment.createdAt * 1000).toLocaleString()}</span> |
||||
<EventCopyButton eventId={comment.id} kind={comment.kind} pubkey={comment.pubkey} /> |
||||
{#if userPubkey && onReply} |
||||
<button |
||||
class="create-reply-button" |
||||
onclick={() => onReply(comment.id)} |
||||
title="Reply to comment" |
||||
> |
||||
<img src="/icons/plus.svg" alt="Reply" class="icon" /> |
||||
</button> |
||||
{/if} |
||||
</div> |
||||
<div class="comment-content"> |
||||
{#if referencedEvent} |
||||
<div class="referenced-event"> |
||||
<div class="referenced-event-header"> |
||||
<UserBadge pubkey={referencedEvent.pubkey} disableLink={true} /> |
||||
<span class="referenced-event-time">{formatDiscussionTime(referencedEvent.created_at)}</span> |
||||
</div> |
||||
<div class="referenced-event-content">{referencedEvent.content || '(No content)'}</div> |
||||
</div> |
||||
{/if} |
||||
<div> |
||||
{#each contentParts as part} |
||||
{#if part.type === 'text'} |
||||
<span>{part.value}</span> |
||||
{:else if part.type === 'event' && part.event} |
||||
<div class="nostr-link-event"> |
||||
<div class="nostr-link-event-header"> |
||||
<UserBadge pubkey={part.event.pubkey} disableLink={true} /> |
||||
<span class="nostr-link-event-time">{formatDiscussionTime(part.event.created_at)}</span> |
||||
</div> |
||||
<div class="nostr-link-event-content">{part.event.content || '(No content)'}</div> |
||||
</div> |
||||
{:else if part.type === 'profile' && part.pubkey} |
||||
<UserBadge pubkey={part.pubkey} /> |
||||
{:else} |
||||
<span class="nostr-link-placeholder">{part.value}</span> |
||||
{/if} |
||||
{/each} |
||||
</div> |
||||
</div> |
||||
{#if comment.replies && comment.replies.length > 0} |
||||
<div class="nested-replies"> |
||||
{#each comment.replies as reply} |
||||
<CommentRendererSelf |
||||
comment={reply} |
||||
commentEvent={eventCache.get(reply.id)} |
||||
{eventCache} |
||||
{profileCache} |
||||
{userPubkey} |
||||
{onReply} |
||||
nested={true} |
||||
/> |
||||
{/each} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
|
||||
<style> |
||||
.comment-item { |
||||
margin-bottom: 1rem; |
||||
padding: 0.75rem; |
||||
border-left: 2px solid var(--border-color, #e0e0e0); |
||||
background: var(--comment-bg, #f9f9f9); |
||||
} |
||||
|
||||
.nested-comment { |
||||
margin-left: 1.5rem; |
||||
margin-top: 0.5rem; |
||||
border-left-color: var(--nested-border-color, #ccc); |
||||
background: var(--nested-comment-bg, #f5f5f5); |
||||
} |
||||
|
||||
.comment-meta { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.5rem; |
||||
margin-bottom: 0.5rem; |
||||
font-size: 0.875rem; |
||||
color: var(--text-secondary, #666); |
||||
} |
||||
|
||||
.comment-content { |
||||
margin-top: 0.5rem; |
||||
} |
||||
|
||||
.create-reply-button { |
||||
margin-left: auto; |
||||
background: none; |
||||
border: none; |
||||
cursor: pointer; |
||||
padding: 0.25rem; |
||||
display: flex; |
||||
align-items: center; |
||||
} |
||||
|
||||
.create-reply-button:hover { |
||||
opacity: 0.7; |
||||
} |
||||
|
||||
.create-reply-button .icon { |
||||
width: 16px; |
||||
height: 16px; |
||||
} |
||||
|
||||
.nested-replies { |
||||
margin-top: 0.75rem; |
||||
padding-left: 0.5rem; |
||||
} |
||||
|
||||
.referenced-event { |
||||
margin-bottom: 0.75rem; |
||||
padding: 0.5rem; |
||||
background: var(--referenced-bg, #f0f0f0); |
||||
border-radius: 4px; |
||||
border-left: 2px solid var(--referenced-border, #999); |
||||
} |
||||
|
||||
.referenced-event-header { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.5rem; |
||||
margin-bottom: 0.25rem; |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
.referenced-event-time { |
||||
color: var(--text-secondary, #666); |
||||
} |
||||
|
||||
.referenced-event-content { |
||||
font-size: 0.9rem; |
||||
color: var(--text-secondary, #666); |
||||
} |
||||
|
||||
.nostr-link-event { |
||||
margin: 0.5rem 0; |
||||
padding: 0.5rem; |
||||
background: var(--link-event-bg, #f0f0f0); |
||||
border-radius: 4px; |
||||
border-left: 2px solid var(--link-event-border, #999); |
||||
} |
||||
|
||||
.nostr-link-event-header { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.5rem; |
||||
margin-bottom: 0.25rem; |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
.nostr-link-event-time { |
||||
color: var(--text-secondary, #666); |
||||
} |
||||
|
||||
.nostr-link-event-content { |
||||
font-size: 0.9rem; |
||||
color: var(--text-secondary, #666); |
||||
} |
||||
|
||||
.nostr-link-placeholder { |
||||
color: var(--link-color, #0066cc); |
||||
text-decoration: underline; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,194 @@
@@ -0,0 +1,194 @@
|
||||
<script lang="ts"> |
||||
import CommentRenderer, { type Comment } from './CommentRenderer.svelte'; |
||||
import UserBadge from './UserBadge.svelte'; |
||||
import EventCopyButton from './EventCopyButton.svelte'; |
||||
import type { NostrEvent } from '$lib/types/nostr.js'; |
||||
|
||||
export interface Discussion { |
||||
id: string; |
||||
title: string; |
||||
content?: string; |
||||
author: string; |
||||
createdAt: number; |
||||
kind: number; |
||||
pubkey: string; |
||||
type: 'thread' | 'comments'; |
||||
comments?: Comment[]; |
||||
} |
||||
|
||||
interface Props { |
||||
discussion: Discussion; |
||||
discussionEvent?: NostrEvent; |
||||
eventCache: Map<string, NostrEvent>; |
||||
profileCache: Map<string, string>; |
||||
userPubkey?: string | null; |
||||
onReplyToThread?: (threadId: string) => void; |
||||
onReplyToComment?: (commentId: string) => void; |
||||
} |
||||
|
||||
let { |
||||
discussion, |
||||
discussionEvent, |
||||
eventCache, |
||||
profileCache, |
||||
userPubkey, |
||||
onReplyToThread, |
||||
onReplyToComment |
||||
}: Props = $props(); |
||||
|
||||
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; |
||||
} |
||||
|
||||
const hasComments = $derived(discussion.comments && discussion.comments.length > 0); |
||||
const totalReplies = $derived(hasComments ? countAllReplies(discussion.comments) : 0); |
||||
</script> |
||||
|
||||
<div class="discussion-item"> |
||||
<div class="discussion-header"> |
||||
<h3 class="discussion-title">{discussion.title}</h3> |
||||
<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>Created {new Date(discussion.createdAt * 1000).toLocaleString()}</span> |
||||
<EventCopyButton eventId={discussion.id} kind={discussion.kind} pubkey={discussion.pubkey} /> |
||||
{#if discussion.type === 'thread' && userPubkey && onReplyToThread} |
||||
<button |
||||
class="create-reply-button" |
||||
onclick={() => onReplyToThread(discussion.id)} |
||||
title="Reply to thread" |
||||
> |
||||
<img src="/icons/plus.svg" alt="Reply" class="icon" /> |
||||
</button> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
{#if discussion.content} |
||||
<div class="discussion-body"> |
||||
<p>{discussion.content}</p> |
||||
</div> |
||||
{/if} |
||||
{#if discussion.type === 'thread' && hasComments} |
||||
<div class="comments-section"> |
||||
<h4>Replies ({totalReplies})</h4> |
||||
{#each discussion.comments! as comment} |
||||
<CommentRenderer |
||||
comment={comment} |
||||
commentEvent={eventCache.get(comment.id)} |
||||
{eventCache} |
||||
{profileCache} |
||||
{userPubkey} |
||||
onReply={onReplyToComment} |
||||
/> |
||||
{/each} |
||||
</div> |
||||
{:else if discussion.type === 'comments' && hasComments} |
||||
<div class="comments-section"> |
||||
<h4>Comments ({totalReplies})</h4> |
||||
{#each discussion.comments! as comment} |
||||
<CommentRenderer |
||||
comment={comment} |
||||
commentEvent={eventCache.get(comment.id)} |
||||
{eventCache} |
||||
{profileCache} |
||||
{userPubkey} |
||||
onReply={onReplyToComment} |
||||
/> |
||||
{/each} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
|
||||
<style> |
||||
.discussion-item { |
||||
padding: 1rem; |
||||
margin-bottom: 1.5rem; |
||||
border: 1px solid var(--border-color, #e0e0e0); |
||||
border-radius: 8px; |
||||
background: var(--discussion-bg, #fff); |
||||
} |
||||
|
||||
.discussion-header { |
||||
margin-bottom: 1rem; |
||||
} |
||||
|
||||
.discussion-title { |
||||
margin: 0 0 0.5rem 0; |
||||
font-size: 1.25rem; |
||||
font-weight: 600; |
||||
} |
||||
|
||||
.discussion-meta { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.75rem; |
||||
font-size: 0.875rem; |
||||
color: var(--text-secondary, #666); |
||||
} |
||||
|
||||
.discussion-type { |
||||
padding: 0.25rem 0.5rem; |
||||
background: var(--type-bg, #e0e0e0); |
||||
border-radius: 4px; |
||||
font-weight: 500; |
||||
} |
||||
|
||||
.comment-count { |
||||
font-weight: 500; |
||||
} |
||||
|
||||
.create-reply-button { |
||||
margin-left: auto; |
||||
background: none; |
||||
border: none; |
||||
cursor: pointer; |
||||
padding: 0.25rem; |
||||
display: flex; |
||||
align-items: center; |
||||
} |
||||
|
||||
.create-reply-button:hover { |
||||
opacity: 0.7; |
||||
} |
||||
|
||||
.create-reply-button .icon { |
||||
width: 16px; |
||||
height: 16px; |
||||
} |
||||
|
||||
.discussion-body { |
||||
margin-bottom: 1rem; |
||||
padding: 0.75rem; |
||||
background: var(--body-bg, #f9f9f9); |
||||
border-radius: 4px; |
||||
} |
||||
|
||||
.discussion-body p { |
||||
margin: 0; |
||||
} |
||||
|
||||
.comments-section { |
||||
margin-top: 1rem; |
||||
padding-top: 1rem; |
||||
border-top: 1px solid var(--border-color, #e0e0e0); |
||||
} |
||||
|
||||
.comments-section h4 { |
||||
margin: 0 0 1rem 0; |
||||
font-size: 1rem; |
||||
font-weight: 600; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,281 @@
@@ -0,0 +1,281 @@
|
||||
import { nip19 } from 'nostr-tools'; |
||||
import type { NostrEvent } from '$lib/types/nostr.js'; |
||||
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; |
||||
|
||||
export interface ParsedNostrLink { |
||||
type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'nprofile'; |
||||
value: string; |
||||
start: number; |
||||
end: number; |
||||
} |
||||
|
||||
export interface ProcessedContentPart { |
||||
type: 'text' | 'event' | 'profile' | 'placeholder'; |
||||
value: string; |
||||
event?: NostrEvent; |
||||
pubkey?: string; |
||||
} |
||||
|
||||
/** |
||||
* Parse nostr: links from content string |
||||
*/ |
||||
export function parseNostrLinks(content: string): ParsedNostrLink[] { |
||||
const links: ParsedNostrLink[] = []; |
||||
const nostrLinkRegex = /nostr:(nevent1|naddr1|note1|npub1|nprofile1)[a-zA-Z0-9]+/g; |
||||
let match; |
||||
|
||||
while ((match = nostrLinkRegex.exec(content)) !== null) { |
||||
const fullMatch = match[0]; |
||||
const prefix = match[1]; |
||||
let type: 'nevent' | 'naddr' | 'note1' | 'npub' | 'nprofile'; |
||||
|
||||
if (prefix === 'nevent1') type = 'nevent'; |
||||
else if (prefix === 'naddr1') type = 'naddr'; |
||||
else if (prefix === 'note1') type = 'note1'; |
||||
else if (prefix === 'npub1') type = 'npub'; |
||||
else if (prefix === 'nprofile1') type = 'nprofile'; |
||||
else continue; |
||||
|
||||
links.push({ |
||||
type, |
||||
value: fullMatch, |
||||
start: match.index, |
||||
end: match.index + fullMatch.length |
||||
}); |
||||
} |
||||
|
||||
return links; |
||||
} |
||||
|
||||
/** |
||||
* Load events and profiles from nostr: links in content |
||||
*/ |
||||
export async function loadNostrLinks( |
||||
content: string, |
||||
nostrClient: NostrClient, |
||||
eventCache: Map<string, NostrEvent>, |
||||
profileCache: Map<string, string> |
||||
): Promise<void> { |
||||
const links = parseNostrLinks(content); |
||||
if (links.length === 0) return; |
||||
|
||||
const eventIds: string[] = []; |
||||
const aTags: string[] = []; |
||||
const npubs: string[] = []; |
||||
|
||||
for (const link of links) { |
||||
try { |
||||
if (link.type === 'nevent' || link.type === 'note1') { |
||||
const decoded = nip19.decode(link.value.replace('nostr:', '')); |
||||
if (decoded.type === 'nevent') { |
||||
eventIds.push(decoded.data.id); |
||||
} else if (decoded.type === 'note') { |
||||
eventIds.push(decoded.data as string); |
||||
} |
||||
} else if (link.type === 'naddr') { |
||||
const decoded = nip19.decode(link.value.replace('nostr:', '')); |
||||
if (decoded.type === 'naddr') { |
||||
const aTag = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier}`; |
||||
aTags.push(aTag); |
||||
} |
||||
} else if (link.type === 'npub' || link.type === 'nprofile') { |
||||
const decoded = nip19.decode(link.value.replace('nostr:', '')); |
||||
if (decoded.type === 'npub') { |
||||
npubs.push(link.value); |
||||
profileCache.set(link.value, decoded.data as string); |
||||
} else if (decoded.type === 'nprofile') { |
||||
npubs.push(link.value); |
||||
profileCache.set(link.value, decoded.data.pubkey as string); |
||||
} |
||||
} |
||||
} catch { |
||||
// Invalid nostr link, skip
|
||||
} |
||||
} |
||||
|
||||
// Fetch events
|
||||
if (eventIds.length > 0) { |
||||
try { |
||||
const events = await Promise.race([ |
||||
nostrClient.fetchEvents([{ ids: eventIds, limit: eventIds.length }]), |
||||
new Promise<NostrEvent[]>((resolve) => setTimeout(() => resolve([]), 10000)) |
||||
]); |
||||
|
||||
for (const event of events) { |
||||
eventCache.set(event.id, event); |
||||
} |
||||
} catch { |
||||
// Ignore fetch errors
|
||||
} |
||||
} |
||||
|
||||
// Fetch a-tag events
|
||||
if (aTags.length > 0) { |
||||
for (const aTag of aTags) { |
||||
const parts = aTag.split(':'); |
||||
if (parts.length === 3) { |
||||
try { |
||||
const kind = parseInt(parts[0]); |
||||
const pubkey = parts[1]; |
||||
const dTag = parts[2]; |
||||
const events = await Promise.race([ |
||||
nostrClient.fetchEvents([{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }]), |
||||
new Promise<NostrEvent[]>((resolve) => setTimeout(() => resolve([]), 10000)) |
||||
]); |
||||
|
||||
if (events.length > 0) { |
||||
eventCache.set(events[0].id, events[0]); |
||||
} |
||||
} catch { |
||||
// Ignore fetch errors
|
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Get event from nostr: link |
||||
*/ |
||||
export function getEventFromNostrLink( |
||||
link: string, |
||||
eventCache: Map<string, NostrEvent> |
||||
): NostrEvent | undefined { |
||||
try { |
||||
if (link.startsWith('nostr:nevent1') || link.startsWith('nostr:note1')) { |
||||
const decoded = nip19.decode(link.replace('nostr:', '')); |
||||
if (decoded.type === 'nevent') { |
||||
return eventCache.get(decoded.data.id); |
||||
} else if (decoded.type === 'note') { |
||||
return eventCache.get(decoded.data as string); |
||||
} |
||||
} else if (link.startsWith('nostr:naddr1')) { |
||||
const decoded = nip19.decode(link.replace('nostr:', '')); |
||||
if (decoded.type === 'naddr') { |
||||
return Array.from(eventCache.values()).find(e => { |
||||
const dTag = e.tags.find(t => t[0] === 'd')?.[1]; |
||||
return e.kind === decoded.data.kind &&
|
||||
e.pubkey === decoded.data.pubkey &&
|
||||
dTag === decoded.data.identifier; |
||||
}); |
||||
} |
||||
} |
||||
} catch { |
||||
// Invalid link
|
||||
} |
||||
return undefined; |
||||
} |
||||
|
||||
/** |
||||
* Get pubkey from nostr: npub/profile link |
||||
*/ |
||||
export function getPubkeyFromNostrLink( |
||||
link: string, |
||||
profileCache: Map<string, string> |
||||
): string | undefined { |
||||
return profileCache.get(link); |
||||
} |
||||
|
||||
/** |
||||
* Process content with nostr links into parts for rendering |
||||
*/ |
||||
export function processContentWithNostrLinks( |
||||
content: string, |
||||
eventCache: Map<string, NostrEvent>, |
||||
profileCache: Map<string, string> |
||||
): ProcessedContentPart[] { |
||||
const links = parseNostrLinks(content); |
||||
if (links.length === 0) { |
||||
return [{ type: 'text', value: content }]; |
||||
} |
||||
|
||||
const parts: ProcessedContentPart[] = []; |
||||
let lastIndex = 0; |
||||
|
||||
for (const link of links) { |
||||
// Add text before link
|
||||
if (link.start > lastIndex) { |
||||
const textPart = content.slice(lastIndex, link.start); |
||||
if (textPart) { |
||||
parts.push({ type: 'text', value: textPart }); |
||||
} |
||||
} |
||||
|
||||
// Add link
|
||||
const event = getEventFromNostrLink(link.value, eventCache); |
||||
const pubkey = getPubkeyFromNostrLink(link.value, profileCache); |
||||
if (event) { |
||||
parts.push({ type: 'event', value: link.value, event }); |
||||
} else if (pubkey) { |
||||
parts.push({ type: 'profile', value: link.value, pubkey }); |
||||
} else { |
||||
parts.push({ type: 'placeholder', value: link.value }); |
||||
} |
||||
|
||||
lastIndex = link.end; |
||||
} |
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < content.length) { |
||||
const textPart = content.slice(lastIndex); |
||||
if (textPart) { |
||||
parts.push({ type: 'text', value: textPart }); |
||||
} |
||||
} |
||||
|
||||
return parts; |
||||
} |
||||
|
||||
/** |
||||
* Get referenced event from discussion event (via 'e', 'a', or 'q' tag) |
||||
*/ |
||||
export function getReferencedEventFromDiscussion( |
||||
event: NostrEvent, |
||||
eventCache: Map<string, NostrEvent> |
||||
): NostrEvent | undefined { |
||||
// Check e-tag
|
||||
const eTag = event.tags.find(t => t[0] === 'e' && t[1])?.[1]; |
||||
if (eTag) { |
||||
const referenced = eventCache.get(eTag); |
||||
if (referenced) return referenced; |
||||
} |
||||
|
||||
// Check a-tag
|
||||
const aTag = event.tags.find(t => t[0] === 'a' && t[1])?.[1]; |
||||
if (aTag) { |
||||
const parts = aTag.split(':'); |
||||
if (parts.length === 3) { |
||||
const kind = parseInt(parts[0]); |
||||
const pubkey = parts[1]; |
||||
const dTag = parts[2]; |
||||
return Array.from(eventCache.values()).find(e =>
|
||||
e.kind === kind &&
|
||||
e.pubkey === pubkey &&
|
||||
e.tags.find(t => t[0] === 'd' && t[1] === dTag) |
||||
); |
||||
} |
||||
} |
||||
|
||||
// Check q-tag
|
||||
const qTag = event.tags.find(t => t[0] === 'q' && t[1])?.[1]; |
||||
if (qTag) { |
||||
return eventCache.get(qTag); |
||||
} |
||||
|
||||
return undefined; |
||||
} |
||||
|
||||
/** |
||||
* Format timestamp for discussion display |
||||
*/ |
||||
export function formatDiscussionTime(timestamp: number): string { |
||||
const now = Date.now() / 1000; |
||||
const diff = now - timestamp; |
||||
|
||||
if (diff < 60) return 'just now'; |
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; |
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; |
||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`; |
||||
|
||||
return new Date(timestamp * 1000).toLocaleDateString(); |
||||
} |
||||
@ -0,0 +1,647 @@
@@ -0,0 +1,647 @@
|
||||
<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'; |
||||
import type { Comment } from '$lib/components/CommentRenderer.svelte'; |
||||
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 combinedRelays = combineRelays( |
||||
DEFAULT_NOSTR_RELAYS, |
||||
DEFAULT_NOSTR_SEARCH_RELAYS, |
||||
userRelays?.outbox || [] |
||||
); |
||||
|
||||
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, |
||||
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 combinedRelays = combineRelays( |
||||
DEFAULT_NOSTR_RELAYS, |
||||
DEFAULT_NOSTR_SEARCH_RELAYS, |
||||
userRelays?.outbox || [] |
||||
); |
||||
|
||||
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 combinedRelays = combineRelays( |
||||
DEFAULT_NOSTR_RELAYS, |
||||
DEFAULT_NOSTR_SEARCH_RELAYS, |
||||
userRelays?.outbox || [] |
||||
); |
||||
|
||||
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="document" |
||||
onclick={(e) => e.stopPropagation()} |
||||
> |
||||
<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="document" |
||||
onclick={(e) => e.stopPropagation()} |
||||
> |
||||
<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> |
||||
Loading…
Reference in new issue