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.
336 lines
9.7 KiB
336 lines
9.7 KiB
<script lang="ts"> |
|
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; |
|
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; |
|
import MediaAttachments from '../../components/content/MediaAttachments.svelte'; |
|
import ReferencedEventPreview from '../../components/content/ReferencedEventPreview.svelte'; |
|
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte'; |
|
import DiscussionVoteButtons from '../discussions/DiscussionVoteButtons.svelte'; |
|
import EventMenu from '../../components/EventMenu.svelte'; |
|
import CommentForm from './CommentForm.svelte'; |
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
|
import { relayManager } from '../../services/nostr/relay-manager.js'; |
|
import { onMount } from 'svelte'; |
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
import { getKindInfo, KIND } from '../../types/kind-lookup.js'; |
|
import { getEventLink } from '../../services/event-links.js'; |
|
import { goto } from '$app/navigation'; |
|
import IconButton from '../../components/ui/IconButton.svelte'; |
|
import { sessionManager } from '../../services/auth/session-manager.js'; |
|
import { page } from '$app/stores'; |
|
|
|
interface Props { |
|
comment: NostrEvent; |
|
parentEvent?: NostrEvent; |
|
onReply?: (comment: NostrEvent) => void; |
|
rootEventKind?: number; // The kind of the root event (e.g., 11 for threads) |
|
reactions?: NostrEvent[]; // Optional pre-loaded reactions (for performance) |
|
threadId?: string; // The root event ID for the thread |
|
rootEvent?: NostrEvent; // The root event for the thread |
|
onCommentPublished?: () => void; // Callback when a comment is published |
|
} |
|
|
|
let { comment, parentEvent, onReply, rootEventKind, reactions: providedReactions, threadId, rootEvent, onCommentPublished }: Props = $props(); |
|
let expanded = $state(false); |
|
let contentElement: HTMLElement | null = $state(null); |
|
let needsExpansion = $state(false); |
|
let showReplyForm = $state(false); |
|
|
|
// Media kinds that should auto-render media (except on /feed) |
|
const MEDIA_KINDS: number[] = [KIND.PICTURE_NOTE, KIND.VIDEO_NOTE, KIND.SHORT_VIDEO_NOTE, KIND.VOICE_NOTE, KIND.VOICE_REPLY]; |
|
const isMediaKind = $derived(MEDIA_KINDS.includes(comment.kind)); |
|
const isOnFeedPage = $derived($page.url.pathname === '/feed'); |
|
const shouldAutoRenderMedia = $derived(isMediaKind && !isOnFeedPage); |
|
|
|
// DiscussionVoteButtons handles all vote counting internally |
|
|
|
function getRelativeTime(): string { |
|
const now = Math.floor(Date.now() / 1000); |
|
const diff = now - comment.created_at; |
|
const hours = Math.floor(diff / 3600); |
|
const days = Math.floor(diff / 86400); |
|
const minutes = Math.floor(diff / 60); |
|
|
|
if (days > 0) return `${days}d ago`; |
|
if (hours > 0) return `${hours}h ago`; |
|
if (minutes > 0) return `${minutes}m ago`; |
|
return 'just now'; |
|
} |
|
|
|
function getClientName(): string | null { |
|
const clientTag = comment.tags.find((t) => t[0] === 'client'); |
|
return clientTag?.[1] || null; |
|
} |
|
|
|
function handleReply() { |
|
if (threadId) { |
|
// Show reply form directly below this comment |
|
showReplyForm = !showReplyForm; |
|
} else { |
|
// Fallback to parent callback if no threadId |
|
onReply?.(comment); |
|
} |
|
} |
|
|
|
async function handleCommentPublished() { |
|
showReplyForm = false; |
|
if (onCommentPublished) { |
|
onCommentPublished(); |
|
} |
|
} |
|
|
|
$effect(() => { |
|
if (contentElement) { |
|
checkContentHeight(); |
|
// Use ResizeObserver to detect when content changes (e.g., images loading) |
|
const observer = new ResizeObserver(() => { |
|
checkContentHeight(); |
|
}); |
|
observer.observe(contentElement); |
|
return () => observer.disconnect(); |
|
} |
|
}); |
|
|
|
function checkContentHeight() { |
|
if (contentElement) { |
|
// Use requestAnimationFrame to ensure DOM is fully updated |
|
requestAnimationFrame(() => { |
|
if (contentElement) { |
|
needsExpansion = contentElement.scrollHeight > 500; |
|
} |
|
}); |
|
} |
|
} |
|
|
|
function toggleExpanded() { |
|
expanded = !expanded; |
|
} |
|
</script> |
|
|
|
<article |
|
id="comment-{comment.id}" |
|
class="comment" |
|
data-event-id={comment.id} |
|
> |
|
<div class="card-content" class:expanded bind:this={contentElement}> |
|
{#if parentEvent} |
|
<ReferencedEventPreview event={comment} /> |
|
{/if} |
|
|
|
<div class="comment-header flex items-center gap-2 mb-2"> |
|
<ProfileBadge pubkey={comment.pubkey} /> |
|
<span class="text-fog-text-light dark:text-fog-dark-text-light whitespace-nowrap" style="font-size: 0.75em;">{getRelativeTime()}</span> |
|
{#if getClientName()} |
|
<span class="text-fog-text-light dark:text-fog-dark-text-light whitespace-nowrap" style="font-size: 0.75em;">via {getClientName()}</span> |
|
{/if} |
|
<div class="ml-auto flex items-center gap-2 comment-header-actions"> |
|
<IconButton |
|
icon="eye" |
|
label="View" |
|
size={16} |
|
onclick={() => goto(getEventLink(comment))} |
|
/> |
|
{#if sessionManager.isLoggedIn() && onReply} |
|
<IconButton |
|
icon="message-square" |
|
label="Reply" |
|
size={16} |
|
onclick={() => handleReply()} |
|
/> |
|
{/if} |
|
<EventMenu event={comment} showContentActions={true} onReply={handleReply} /> |
|
</div> |
|
</div> |
|
|
|
<div class="comment-content mb-2"> |
|
{#if shouldAutoRenderMedia} |
|
<MediaAttachments event={comment} forceRender={isMediaKind} /> |
|
{/if} |
|
<MarkdownRenderer content={comment.content} event={comment} /> |
|
</div> |
|
</div> |
|
|
|
{#if needsExpansion} |
|
<button |
|
onclick={toggleExpanded} |
|
class="show-more-button text-fog-accent dark:text-fog-dark-accent hover:underline mt-2" |
|
style="font-size: 0.875em;" |
|
> |
|
{expanded ? 'Show less' : 'Show more'} |
|
</button> |
|
{/if} |
|
|
|
<!-- Comment actions (vote buttons, reply) - always visible, outside collapsible content --> |
|
<div class="comment-actions flex gap-2 items-center"> |
|
{#if rootEventKind === KIND.DISCUSSION_THREAD} |
|
<!-- DiscussionVoteButtons includes both vote counts and buttons --> |
|
<DiscussionVoteButtons event={comment} /> |
|
{:else} |
|
<FeedReactionButtons event={comment} /> |
|
{/if} |
|
<button |
|
onclick={handleReply} |
|
class="reply-button text-fog-accent dark:text-fog-dark-accent hover:underline" |
|
> |
|
Reply |
|
</button> |
|
</div> |
|
|
|
<!-- Reply form appears directly below this comment --> |
|
{#if showReplyForm && threadId} |
|
<div class="reply-form-container mt-4"> |
|
<CommentForm |
|
threadId={threadId} |
|
rootEvent={rootEvent} |
|
parentEvent={comment} |
|
onPublished={handleCommentPublished} |
|
onCancel={() => showReplyForm = false} |
|
/> |
|
</div> |
|
{/if} |
|
|
|
<div class="kind-badge"> |
|
<span class="kind-number">{getKindInfo(comment.kind).number}</span> |
|
<span class="kind-description">{getKindInfo(comment.kind).description}</span> |
|
</div> |
|
</article> |
|
|
|
<style> |
|
.comment { |
|
padding: 1rem; |
|
margin-bottom: 1rem; |
|
background: var(--fog-post, #ffffff); |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
border-radius: 0.25rem; |
|
position: relative; |
|
} |
|
|
|
:global(.dark) .comment { |
|
background: var(--fog-dark-post, #1f2937); |
|
border-color: var(--fog-dark-border, #374151); |
|
} |
|
|
|
|
|
.comment-content { |
|
line-height: 1.6; |
|
} |
|
|
|
.comment-actions { |
|
padding-right: 6rem; /* Reserve space for kind badge */ |
|
padding-top: 0.5rem; |
|
padding-bottom: 0.5rem; /* Add bottom padding to prevent overlap with kind badge */ |
|
border-top: 1px solid var(--fog-border, #e5e7eb); |
|
margin-top: 0.5rem; |
|
margin-bottom: 0.5rem; /* Add margin to prevent overlap with kind badge */ |
|
/* Ensure footer is always visible, even when content is collapsed */ |
|
position: relative; |
|
z-index: 1; |
|
overflow: visible; |
|
} |
|
|
|
:global(.dark) .comment-actions { |
|
border-top-color: var(--fog-dark-border, #374151); |
|
} |
|
|
|
.reply-button { |
|
font-size: 0.875rem; |
|
padding: 0.25rem 0.5rem; |
|
background: none; |
|
border: none; |
|
cursor: pointer; |
|
transition: opacity 0.2s; |
|
min-height: 2rem; |
|
display: inline-flex; |
|
align-items: center; |
|
} |
|
|
|
.reply-button:hover { |
|
opacity: 0.8; |
|
} |
|
|
|
.comment-header-actions { |
|
display: flex !important; |
|
visibility: visible !important; |
|
flex-shrink: 0; |
|
} |
|
|
|
@media (max-width: 640px) { |
|
.comment-actions { |
|
padding-right: 4rem; |
|
flex-wrap: wrap; |
|
gap: 0.5rem; |
|
} |
|
|
|
.reply-button { |
|
font-size: 0.875rem; |
|
padding: 0.375rem 0.75rem; |
|
min-height: 2.25rem; |
|
font-weight: 500; |
|
} |
|
|
|
.comment-header { |
|
flex-wrap: wrap; |
|
gap: 0.5rem; |
|
} |
|
|
|
.comment-header .ml-auto { |
|
margin-left: auto; |
|
flex-shrink: 0; |
|
} |
|
|
|
.comment-header-actions { |
|
gap: 0.375rem; |
|
} |
|
} |
|
|
|
.card-content { |
|
max-height: 500px; |
|
overflow: hidden; |
|
transition: max-height 0.3s ease; |
|
/* Ensure footer below is not affected by overflow */ |
|
position: relative; |
|
} |
|
|
|
.card-content.expanded { |
|
max-height: none; |
|
} |
|
|
|
.show-more-button { |
|
width: 100%; |
|
text-align: center; |
|
padding: 0.5rem; |
|
background: transparent; |
|
border: none; |
|
cursor: pointer; |
|
} |
|
|
|
.kind-badge { |
|
position: absolute; |
|
bottom: 0.5rem; |
|
right: 0.5rem; |
|
display: flex; |
|
flex-direction: row; |
|
align-items: center; |
|
gap: 0.25rem; |
|
font-size: 0.625rem; |
|
line-height: 1; |
|
color: var(--fog-text-light, #9ca3af); |
|
} |
|
|
|
:global(.dark) .kind-badge { |
|
color: var(--fog-dark-text-light, #6b7280); |
|
} |
|
|
|
.kind-number { |
|
font-weight: 600; |
|
} |
|
|
|
.kind-description { |
|
font-size: 0.625rem; |
|
opacity: 0.8; |
|
} |
|
|
|
.reply-form-container { |
|
padding-bottom: 2.5rem; /* Add padding to prevent overlap with kind badge */ |
|
position: relative; |
|
} |
|
</style>
|
|
|