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.
395 lines
12 KiB
395 lines
12 KiB
<script lang="ts"> |
|
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; |
|
import VoteCount from '../../components/content/VoteCount.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 { stripMarkdown } from '../../services/text-utils.js'; |
|
|
|
interface Props { |
|
thread: NostrEvent; |
|
commentCount?: number; // Pre-loaded comment count from batch fetch |
|
upvotes?: number; // Pre-calculated upvote count from batch fetch |
|
downvotes?: number; // Pre-calculated downvote count from batch fetch |
|
votesCalculated?: boolean; // Whether vote counts are ready to display |
|
} |
|
|
|
let { thread, commentCount: providedCommentCount = 0, upvotes: providedUpvotes = 0, downvotes: providedDownvotes = 0, votesCalculated: providedVotesCalculated = false }: Props = $props(); |
|
|
|
let commentCount = $state(0); |
|
|
|
// Update comment count when provided value changes |
|
$effect(() => { |
|
commentCount = providedCommentCount; |
|
}); |
|
let zapTotal = $state(0); |
|
let zapCount = $state(0); |
|
let latestResponseTime = $state<number | null>(null); |
|
let loadingStats = $state(true); |
|
let expanded = $state(false); |
|
let contentElement: HTMLElement | null = $state(null); |
|
let needsExpansion = $state(false); |
|
|
|
onMount(async () => { |
|
await loadStats(); |
|
}); |
|
|
|
$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; |
|
} |
|
|
|
async function loadStats() { |
|
loadingStats = true; |
|
const timeout = 30000; // 30 seconds |
|
|
|
try { |
|
const config = nostrClient.getConfig(); |
|
|
|
// Create a timeout promise |
|
const timeoutPromise = new Promise<never>((_, reject) => { |
|
setTimeout(() => reject(new Error('Stats loading timeout')), timeout); |
|
}); |
|
|
|
// Race between loading and timeout |
|
await Promise.race([ |
|
(async () => { |
|
// Vote counting is handled by DiscussionVoteButtons component - not needed here |
|
|
|
// Comment count is pre-loaded from batch fetch, skip individual loading |
|
// Only load comments if not provided (fallback for backwards compatibility) |
|
let commentEvents: NostrEvent[] = []; |
|
if (providedCommentCount === 0) { |
|
const commentRelays = relayManager.getCommentReadRelays(); |
|
commentEvents = await nostrClient.fetchEvents( |
|
[{ kinds: [KIND.COMMENT], '#E': [thread.id], '#K': ['11'] }], |
|
commentRelays, |
|
{ useCache: true } |
|
); |
|
commentCount = commentEvents.length; |
|
} |
|
|
|
// Load zap receipts (kind 9735) |
|
const zapRelays = relayManager.getZapReceiptReadRelays(); |
|
const zapReceipts = await nostrClient.fetchEvents( |
|
[{ kinds: [KIND.ZAP_RECEIPT], '#e': [thread.id] }], |
|
zapRelays, |
|
{ useCache: true } |
|
); |
|
|
|
// Calculate zap totals |
|
const threshold = config.zapThreshold; |
|
zapCount = 0; |
|
zapTotal = 0; |
|
for (const receipt of zapReceipts) { |
|
const amountTag = receipt.tags.find((t) => t[0] === 'amount'); |
|
if (amountTag && amountTag[1]) { |
|
const amount = parseInt(amountTag[1], 10); |
|
if (!isNaN(amount) && amount >= threshold) { |
|
zapTotal += amount; |
|
zapCount++; |
|
} |
|
} |
|
} |
|
|
|
// Find latest response time (most recent comment or zap) |
|
// Note: Vote counting is handled by DiscussionVoteButtons, so we don't load reactions here |
|
let latestTime = thread.created_at; |
|
if (commentEvents.length > 0) { |
|
const latestComment = commentEvents.sort((a: NostrEvent, b: NostrEvent) => b.created_at - a.created_at)[0]; |
|
latestTime = Math.max(latestTime, latestComment.created_at); |
|
} |
|
if (zapReceipts.length > 0) { |
|
const latestZap = zapReceipts.sort((a, b) => b.created_at - a.created_at)[0]; |
|
latestTime = Math.max(latestTime, latestZap.created_at); |
|
} |
|
latestResponseTime = latestTime > thread.created_at ? latestTime : null; |
|
})(), |
|
timeoutPromise |
|
]); |
|
} catch (error) { |
|
console.error('Error loading thread stats:', error); |
|
// On timeout or error, show zero stats instead of loading forever |
|
commentCount = 0; |
|
zapTotal = 0; |
|
zapCount = 0; |
|
latestResponseTime = null; |
|
} finally { |
|
loadingStats = false; |
|
} |
|
} |
|
|
|
function getTitle(): string { |
|
const titleTag = thread.tags.find((t) => t[0] === 'title'); |
|
return titleTag?.[1] || 'Untitled'; |
|
} |
|
|
|
function getTopics(): string[] { |
|
return thread.tags.filter((t) => t[0] === 't').map((t) => t[1]).slice(0, 3); |
|
} |
|
|
|
function getPreview(): string { |
|
// First 250 chars, plaintext (no markdown/images) |
|
const plaintext = stripMarkdown(thread.content); |
|
return plaintext.slice(0, 250) + (plaintext.length > 250 ? '...' : ''); |
|
} |
|
|
|
function getRelativeTime(): string { |
|
const now = Math.floor(Date.now() / 1000); |
|
const diff = now - thread.created_at; |
|
const hours = Math.floor(diff / 3600); |
|
const days = Math.floor(diff / 86400); |
|
|
|
if (days > 0) return `${days}d ago`; |
|
if (hours > 0) return `${hours}h ago`; |
|
return 'just now'; |
|
} |
|
|
|
function getLatestResponseTime(): string { |
|
if (!latestResponseTime) return ''; |
|
const now = Math.floor(Date.now() / 1000); |
|
const diff = now - latestResponseTime; |
|
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 = thread.tags.find((t) => t[0] === 'client'); |
|
return clientTag?.[1] || null; |
|
} |
|
</script> |
|
|
|
<article class="thread-card bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-4 mb-4 shadow-sm dark:shadow-lg"> |
|
<a href="/event/{thread.id}" class="card-link"> |
|
<div class="card-content" class:expanded bind:this={contentElement}> |
|
<div class="flex justify-between items-start mb-2"> |
|
<h3 class="text-lg font-semibold"> |
|
{getTitle()} |
|
</h3> |
|
<div class="flex items-center gap-2"> |
|
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span> |
|
<!-- Menu hidden in preview - not clickable in card preview --> |
|
</div> |
|
</div> |
|
|
|
<div class="mb-2 flex items-center gap-2"> |
|
<div class="interactive-element"> |
|
<ProfileBadge pubkey={thread.pubkey} /> |
|
</div> |
|
{#if getClientName()} |
|
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">via {getClientName()}</span> |
|
{/if} |
|
</div> |
|
|
|
<p class="text-sm mb-2">{getPreview()}</p> |
|
|
|
{#if getTopics().length > 0} |
|
<div class="flex gap-2 topic-tags"> |
|
{#each getTopics() as topic} |
|
<span class="text-xs bg-fog-highlight dark:bg-fog-dark-highlight text-fog-text dark:text-fog-dark-text px-2 py-1 rounded">{topic}</span> |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
</a> |
|
|
|
{#if needsExpansion} |
|
<button |
|
onclick={toggleExpanded} |
|
class="show-more-button text-sm text-fog-accent dark:text-fog-dark-accent hover:underline mt-2" |
|
> |
|
{expanded ? 'Show less' : 'Show more'} |
|
</button> |
|
{/if} |
|
|
|
<!-- Card footer (stats) - always visible, outside collapsible content --> |
|
<div class="flex items-center justify-between text-xs text-fog-text dark:text-fog-dark-text thread-stats"> |
|
<div class="flex items-center gap-4 flex-wrap"> |
|
{#if providedVotesCalculated} |
|
<VoteCount upvotes={providedUpvotes} downvotes={providedDownvotes} votesCalculated={true} size="xs" /> |
|
{:else} |
|
<span class="text-fog-text-light dark:text-fog-dark-text-light">Loading votes...</span> |
|
{/if} |
|
{#if loadingStats} |
|
<span class="text-fog-text-light dark:text-fog-dark-text-light">Loading stats...</span> |
|
{:else} |
|
<span class="font-medium vote-comment-spacing">{commentCount} {commentCount === 1 ? 'comment' : 'comments'}</span> |
|
{#if latestResponseTime} |
|
<span class="text-fog-text-light dark:text-fog-dark-text-light">Last: {getLatestResponseTime()}</span> |
|
{/if} |
|
{#if zapCount > 0} |
|
<span class="font-medium">⚡ {zapTotal.toLocaleString()} sats ({zapCount})</span> |
|
{/if} |
|
{/if} |
|
</div> |
|
</div> |
|
|
|
<div class="kind-badge"> |
|
<span class="kind-number">{getKindInfo(thread.kind).number}</span> |
|
<span class="kind-description">{getKindInfo(thread.kind).description}</span> |
|
</div> |
|
</article> |
|
|
|
<style> |
|
.thread-card { |
|
max-width: var(--content-width); |
|
position: relative; |
|
} |
|
|
|
.card-link { |
|
display: block; |
|
color: inherit; |
|
text-decoration: none; |
|
cursor: pointer; |
|
} |
|
|
|
.card-link:hover { |
|
background: var(--fog-highlight, #f3f4f6); |
|
} |
|
|
|
:global(.dark) .card-link:hover { |
|
background: var(--fog-dark-highlight, #475569); |
|
} |
|
|
|
.interactive-element { |
|
position: relative; |
|
z-index: 10; |
|
pointer-events: auto; |
|
} |
|
|
|
.card-link { |
|
pointer-events: auto; |
|
} |
|
|
|
.card-link > * { |
|
pointer-events: none; |
|
} |
|
|
|
.card-link .interactive-element { |
|
pointer-events: auto; |
|
} |
|
|
|
.thread-card a { |
|
color: inherit; |
|
text-decoration: none; |
|
} |
|
|
|
.thread-card a:hover { |
|
text-decoration: none; |
|
} |
|
|
|
.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); |
|
white-space: nowrap; |
|
} |
|
|
|
.topic-tags { |
|
margin-bottom: 1rem; /* Increased space between topic tags and count row */ |
|
} |
|
|
|
@media (max-width: 640px) { |
|
.topic-tags { |
|
margin-bottom: 1.25rem; /* Even more space on narrow screens */ |
|
} |
|
|
|
.kind-badge { |
|
position: static; |
|
margin-top: 0.25rem; /* Decreased space between count row and kind badge */ |
|
justify-content: flex-end; |
|
} |
|
|
|
.thread-stats { |
|
margin-bottom: 0.25rem; /* Decreased space between count row and kind badge */ |
|
} |
|
} |
|
|
|
: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; |
|
} |
|
|
|
.thread-stats { |
|
padding-right: 0; |
|
margin-bottom: 1.5rem; /* Space for kind badge below */ |
|
padding-top: 0.5rem; |
|
border-top: 1px solid var(--fog-border, #e5e7eb); |
|
margin-top: 0.5rem; |
|
/* Ensure footer is always visible, even when content is collapsed */ |
|
position: relative; |
|
z-index: 1; |
|
overflow: visible; |
|
} |
|
|
|
:global(.dark) .thread-stats { |
|
border-top-color: var(--fog-dark-border, #374151); |
|
} |
|
|
|
</style>
|
|
|