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.
 
 
 
 
 

451 lines
16 KiB

<script lang="ts">
import { sessionManager } from '../../services/auth/session-manager.js';
import { signAndPublish } from '../../services/nostr/auth-handler.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { config } from '../../services/nostr/config.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
import VoteCount from '../../components/content/VoteCount.svelte';
interface Props {
event: NostrEvent;
preloadedReactions?: NostrEvent[]; // Optional pre-loaded reactions to avoid duplicate fetches
}
let { event, preloadedReactions }: Props = $props();
let userReaction = $state<string | null>(null);
let userReactionEventId = $state<string | null>(null); // Track the event ID of user's reaction
let loading = $state(true);
let allReactionsMap = $state<Map<string, NostrEvent>>(new Map()); // Persist reactions map for real-time updates
let loadingReactions = $state(false);
let lastEventId = $state<string | null>(null);
let isMounted = $state(false);
let processingUpdate = $state(false);
let updateDebounceTimer: ReturnType<typeof setTimeout> | null = null;
let initialLoadComplete = $state(false); // Track when initial load is done
// Count upvotes and downvotes
let upvotes = $derived.by(() => {
let count = 0;
for (const reaction of allReactionsMap.values()) {
const content = reaction.content.trim();
if (content === '+' || content === '⬆' || content === '↑') {
count++;
}
}
return count;
});
let downvotes = $derived.by(() => {
let count = 0;
for (const reaction of allReactionsMap.values()) {
const content = reaction.content.trim();
if (content === '-' || content === '⬇' || content === '↓') {
count++;
}
}
return count;
});
// Only show votes as calculated after initial load completes
let votesCalculated = $derived(initialLoadComplete && !loading);
onMount(() => {
// Set lastEventId immediately to prevent $effect from running during mount
if (event.id) {
lastEventId = event.id;
}
isMounted = true;
nostrClient.initialize().then(async () => {
if (event.id) {
// Use pre-loaded reactions if available, otherwise fetch
if (preloadedReactions && preloadedReactions.length > 0) {
const filtered = await filterDeletedReactions(preloadedReactions);
// Update the map to only contain non-deleted reactions
// Reassign map to trigger reactivity in Svelte 5
const filteredMap = new Map<string, NostrEvent>();
for (const reaction of filtered) {
filteredMap.set(reaction.id, reaction);
}
allReactionsMap = filteredMap;
processReactions(filtered);
initialLoadComplete = true; // Mark initial load as complete
} else {
loadReactions();
}
}
});
});
// Reload reactions when event changes (but prevent duplicate loads and initial mount)
$effect(() => {
// Only run after mount and when event.id actually changes
if (!isMounted || !event.id || event.id === lastEventId || loadingReactions) {
return;
}
lastEventId = event.id;
// Clear previous reactions map when event changes
allReactionsMap = new Map(); // Reassign to trigger reactivity
initialLoadComplete = false; // Reset when event changes
// Use pre-loaded reactions if available, otherwise fetch
if (preloadedReactions && preloadedReactions.length > 0) {
const newMap = new Map<string, NostrEvent>();
for (const r of preloadedReactions) {
newMap.set(r.id, r);
}
allReactionsMap = newMap; // Reassign to trigger reactivity
filterDeletedReactions(preloadedReactions).then(filtered => {
// Update the map to only contain non-deleted reactions
// Reassign map to trigger reactivity in Svelte 5
const filteredMap = new Map<string, NostrEvent>();
for (const reaction of filtered) {
filteredMap.set(reaction.id, reaction);
}
allReactionsMap = filteredMap;
processReactions(filtered);
initialLoadComplete = true; // Mark initial load as complete
});
} else {
loadReactions();
}
});
// Handle real-time updates - process reactions when new ones arrive
// Debounced to prevent excessive processing
async function handleReactionUpdate(updated: NostrEvent[]) {
// Only process real-time updates after initial load is complete
if (!initialLoadComplete) {
return;
}
// Prevent concurrent processing
if (processingUpdate) {
return;
}
// Add new reactions to the map
let hasNewReactions = false;
const newMap = new Map(allReactionsMap);
for (const r of updated) {
if (!newMap.has(r.id)) {
newMap.set(r.id, r);
hasNewReactions = true;
}
}
if (hasNewReactions) {
allReactionsMap = newMap; // Reassign to trigger reactivity
}
// Only process if we have new reactions
if (!hasNewReactions) {
return;
}
// Clear existing debounce timer
if (updateDebounceTimer) {
clearTimeout(updateDebounceTimer);
}
// Debounce processing to batch multiple rapid updates
updateDebounceTimer = setTimeout(async () => {
if (processingUpdate) return;
processingUpdate = true;
try {
// Process all accumulated reactions and update the map to remove deleted ones
const allReactions = Array.from(allReactionsMap.values());
const filtered = await filterDeletedReactions(allReactions);
// Update the map to only contain non-deleted reactions
// Reassign map to trigger reactivity in Svelte 5
const filteredMap = new Map<string, NostrEvent>();
for (const reaction of filtered) {
filteredMap.set(reaction.id, reaction);
}
allReactionsMap = filteredMap;
// Process reactions to update userReaction state
processReactions(filtered);
} finally {
processingUpdate = false;
}
}, 300); // 300ms debounce
}
async function loadReactions() {
// Prevent concurrent loads for the same event
if (loadingReactions) {
return;
}
loadingReactions = true;
loading = true;
try {
// Use getProfileReadRelays() to include defaultRelays + profileRelays + user inbox + localRelays
// This ensures we get all reactions from the complete relay set, matching DiscussionList behavior
const reactionRelays = relayManager.getProfileReadRelays();
// Clear and rebuild reactions map for this event
allReactionsMap = new Map(); // Reassign to trigger reactivity
// Use low priority for reactions - they're background data, comments should load first
const reactionsWithLowerE = await nostrClient.fetchEvents(
[{ kinds: [KIND.REACTION], '#e': [event.id], limit: config.feedLimit }],
reactionRelays,
{ useCache: true, cacheResults: true, onUpdate: handleReactionUpdate, timeout: config.mediumTimeout, priority: 'low' }
);
const reactionsWithUpperE = await nostrClient.fetchEvents(
[{ kinds: [KIND.REACTION], '#E': [event.id], limit: config.feedLimit }],
reactionRelays,
{ useCache: true, cacheResults: true, onUpdate: handleReactionUpdate, timeout: config.mediumTimeout, priority: 'low' }
);
// Combine and deduplicate by reaction ID
const newMap = new Map<string, NostrEvent>();
for (const r of reactionsWithLowerE) {
newMap.set(r.id, r);
}
for (const r of reactionsWithUpperE) {
newMap.set(r.id, r);
}
allReactionsMap = newMap; // Reassign to trigger reactivity
const reactionEvents = Array.from(allReactionsMap.values());
// Filter out deleted reactions (kind 5)
const filteredReactions = await filterDeletedReactions(reactionEvents);
// Update the map to only contain non-deleted reactions
// Reassign map to trigger reactivity in Svelte 5
const filteredMap = new Map<string, NostrEvent>();
for (const reaction of filteredReactions) {
filteredMap.set(reaction.id, reaction);
}
allReactionsMap = filteredMap;
processReactions(filteredReactions);
initialLoadComplete = true; // Mark initial load as complete after processing
} catch (error) {
console.error('[DiscussionVoteButtons] Error loading reactions:', error);
initialLoadComplete = true; // Mark as complete even on error to avoid stuck loading state
} finally {
loading = false;
loadingReactions = false;
}
}
async function filterDeletedReactions(reactions: NostrEvent[]): Promise<NostrEvent[]> {
if (reactions.length === 0) return reactions;
// Optimize: Instead of fetching all deletion events for all users,
// fetch deletion events that reference the specific reaction IDs we have
// This is much more efficient and limits memory usage
const reactionRelays = relayManager.getProfileReadRelays();
const reactionIds = reactions.map(r => r.id);
// Limit to first 100 reactions to avoid massive queries
const limitedReactionIds = reactionIds.slice(0, 100);
// Fetch deletion events that reference these specific reaction IDs
// This is much more efficient than fetching all deletion events from all users
// Use low priority for deletion events - background data
const deletionEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.EVENT_DELETION], '#e': limitedReactionIds, limit: config.feedLimit }],
reactionRelays,
{ useCache: true, timeout: config.mediumTimeout, priority: 'low' }
);
// Build a set of deleted reaction event IDs (more efficient - just a Set)
const deletedReactionIds = new Set<string>();
for (const deletionEvent of deletionEvents) {
// Kind 5 events have 'e' tags pointing to deleted events
for (const tag of deletionEvent.tags) {
if (tag[0] === 'e' && tag[1]) {
deletedReactionIds.add(tag[1]);
}
}
}
// Filter out deleted reactions - much simpler now
const filtered = reactions.filter(reaction => {
const isDeleted = deletedReactionIds.has(reaction.id);
return !isDeleted;
});
return filtered;
}
async function processReactions(reactionEvents: NostrEvent[]) {
// Prevent duplicate processing - check if we're already processing the same set
if (processingUpdate) {
return;
}
const currentUser = sessionManager.getCurrentPubkey();
for (const reactionEvent of reactionEvents) {
let content = reactionEvent.content.trim();
// Normalize reactions: only + and - allowed
// Backward compatibility: ⬆/↑ = +, ⬇/↓ = -
if (content === '⬆' || content === '↑') {
content = '+';
} else if (content === '⬇' || content === '↓') {
content = '-';
} else if (content !== '+' && content !== '-') {
// Skip invalid reactions for voting
continue;
}
if (currentUser && reactionEvent.pubkey === currentUser) {
userReaction = content;
userReactionEventId = reactionEvent.id;
}
}
}
async function toggleReaction(content: string) {
if (!sessionManager.isLoggedIn()) {
alert('Please log in to vote');
return;
}
// Only allow + and - (upvote/downvote)
if (content !== '+' && content !== '-') {
return;
}
// If clicking the same reaction, delete it
if (userReaction === content) {
// Remove reaction by publishing a kind 5 deletion event
if (userReactionEventId) {
const reactionIdToDelete = userReactionEventId; // Store before clearing
try {
const deletionEvent: Omit<NostrEvent, 'id' | 'sig'> = {
kind: KIND.EVENT_DELETION,
pubkey: sessionManager.getCurrentPubkey()!,
created_at: Math.floor(Date.now() / 1000),
tags: [['e', reactionIdToDelete]],
content: ''
};
const config = nostrClient.getConfig();
await signAndPublish(deletionEvent, [...config.defaultRelays]);
// Remove from map immediately so counts update
allReactionsMap.delete(reactionIdToDelete);
// Update local state immediately
userReaction = null;
userReactionEventId = null;
} catch (error) {
console.error('Error deleting reaction:', error);
alert('Error deleting reaction');
}
} else {
// Fallback: just update UI if we don't have the event ID
userReaction = null;
userReactionEventId = null;
}
return;
}
// If user has the opposite vote, delete it first
if (userReaction && userReaction !== content) {
// Delete the existing vote first
if (userReactionEventId) {
const oldReactionId = userReactionEventId; // Store before clearing
try {
const deletionEvent: Omit<NostrEvent, 'id' | 'sig'> = {
kind: KIND.EVENT_DELETION,
pubkey: sessionManager.getCurrentPubkey()!,
created_at: Math.floor(Date.now() / 1000),
tags: [['e', oldReactionId]],
content: ''
};
const config = nostrClient.getConfig();
await signAndPublish(deletionEvent, [...config.defaultRelays]);
// Remove from map immediately so counts update
// Reassign map to trigger reactivity in Svelte 5
const newMap = new Map(allReactionsMap);
newMap.delete(oldReactionId);
allReactionsMap = newMap;
} catch (error) {
console.error('Error deleting old reaction:', error);
}
}
// Clear the old reaction state
userReaction = null;
userReactionEventId = null;
}
try {
const tags: string[][] = [
['e', event.id],
['p', event.pubkey],
['k', event.kind.toString()]
];
const reactionEvent: Omit<NostrEvent, 'id' | 'sig'> = {
kind: KIND.REACTION,
pubkey: sessionManager.getCurrentPubkey()!,
created_at: Math.floor(Date.now() / 1000),
tags,
content
};
const config = nostrClient.getConfig();
// Sign the event first to get the ID
const signedEvent = await sessionManager.signEvent(reactionEvent);
// Publish the signed event
await nostrClient.publish(signedEvent, { relays: [...config.defaultRelays] });
// Update local state immediately for instant UI feedback
userReaction = content;
userReactionEventId = signedEvent.id;
// Add the new reaction to the map so counts update immediately
// Reassign map to trigger reactivity in Svelte 5
const newMap = new Map(allReactionsMap);
newMap.set(signedEvent.id, signedEvent);
allReactionsMap = newMap;
} catch (error) {
console.error('Error publishing reaction:', error);
}
}
const isLoggedIn = $derived(sessionManager.isLoggedIn());
// Get user's current vote (normalize to + or -)
let userVote = $derived<string | null>(userReaction === '+' || userReaction === '-' ? userReaction : null);
</script>
<div class="discussion-vote-buttons flex gap-2 items-center">
<!-- Vote counts with clickable emojis -->
<VoteCount
upvotes={upvotes}
downvotes={downvotes}
votesCalculated={votesCalculated}
size="xs"
userVote={userVote as '+' | '-' | null}
onVote={toggleReaction}
isLoggedIn={isLoggedIn}
/>
</div>
<style>
.discussion-vote-buttons {
margin-top: 0.5rem;
}
</style>