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

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