|
|
|
|
@ -0,0 +1,450 @@
@@ -0,0 +1,450 @@
|
|
|
|
|
<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 { 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: 100 }], |
|
|
|
|
reactionRelays, |
|
|
|
|
{ useCache: true, cacheResults: true, onUpdate: handleReactionUpdate, timeout: 5000, priority: 'low' } |
|
|
|
|
); |
|
|
|
|
const reactionsWithUpperE = await nostrClient.fetchEvents( |
|
|
|
|
[{ kinds: [KIND.REACTION], '#E': [event.id], limit: 100 }], |
|
|
|
|
reactionRelays, |
|
|
|
|
{ useCache: true, cacheResults: true, onUpdate: handleReactionUpdate, timeout: 5000, 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: 100 }], |
|
|
|
|
reactionRelays, |
|
|
|
|
{ useCache: true, timeout: 5000, 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> |