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

<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>