Browse Source
Nostr-Signature: 9375bfe35e0574bc722cad243c22fdf374dcc9016f91f358ff9ddf1d0a03bb50 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 10fbbcbc7cab48dfd2340f0c9eceafe558d893789e4838cbe26493e5c339f7a1f015d1cc4af8bfa51d57e9a9da94bb1bb44841305d5ce7cf92db9938985d0459main
24 changed files with 1747 additions and 603 deletions
@ -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 @@ |
|||||||
|
<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 @@ |
|||||||
|
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 @@ |
|||||||
|
<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