|
|
<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 { resolveCustomEmojis, fetchEmojiSet, resolveEmojiShortcode } from '../../services/nostr/nip30-emoji.js'; |
|
|
import EmojiPicker from '../../components/content/EmojiPicker.svelte'; |
|
|
import emojiData from 'unicode-emoji-json/data-ordered-emoji.json'; |
|
|
|
|
|
interface Props { |
|
|
event: NostrEvent; |
|
|
forceUpvoteDownvote?: boolean; // Force upvote/downvote mode (for kind 1111 replies to kind 11 threads) |
|
|
preloadedReactions?: NostrEvent[]; // Optional pre-loaded reactions to avoid duplicate fetches |
|
|
} |
|
|
|
|
|
let { event, forceUpvoteDownvote = false, preloadedReactions }: Props = $props(); |
|
|
|
|
|
let reactions = $state<Map<string, { content: string; pubkeys: Set<string>; eventIds: Map<string, string> }>>(new Map()); |
|
|
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 showMenu = $state(false); |
|
|
let menuButton: HTMLButtonElement | null = $state(null); |
|
|
let customEmojiUrls = $state<Map<string, string>>(new Map()); |
|
|
let allReactionsMap = $state<Map<string, NostrEvent>>(new Map()); // Persist reactions map for real-time updates |
|
|
|
|
|
let heartCount = $derived(getReactionCount('+')); |
|
|
|
|
|
let loadingReactions = $state(false); |
|
|
let lastEventId = $state<string | null>(null); |
|
|
|
|
|
onMount(() => { |
|
|
nostrClient.initialize().then(async () => { |
|
|
if (event.id) { |
|
|
// Use pre-loaded reactions if available, otherwise fetch |
|
|
if (preloadedReactions && preloadedReactions.length > 0) { |
|
|
for (const r of preloadedReactions) { |
|
|
allReactionsMap.set(r.id, r); |
|
|
} |
|
|
const filtered = await filterDeletedReactions(preloadedReactions); |
|
|
processReactions(filtered); |
|
|
} else { |
|
|
loadReactions(); |
|
|
} |
|
|
} |
|
|
}); |
|
|
}); |
|
|
|
|
|
// Reload reactions when event changes (but prevent duplicate loads) |
|
|
$effect(() => { |
|
|
if (event.id && event.id !== lastEventId && !loadingReactions) { |
|
|
lastEventId = event.id; |
|
|
// Clear previous reactions map when event changes |
|
|
allReactionsMap.clear(); |
|
|
|
|
|
// Use pre-loaded reactions if available, otherwise fetch |
|
|
if (preloadedReactions && preloadedReactions.length > 0) { |
|
|
for (const r of preloadedReactions) { |
|
|
allReactionsMap.set(r.id, r); |
|
|
} |
|
|
filterDeletedReactions(preloadedReactions).then(filtered => { |
|
|
processReactions(filtered); |
|
|
}); |
|
|
} else { |
|
|
loadReactions(); |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
// Handle real-time updates - process reactions when new ones arrive |
|
|
async function handleReactionUpdate(updated: NostrEvent[]) { |
|
|
console.debug(`[FeedReactionButtons] Received reaction update for event ${event.id.substring(0, 16)}...:`, { |
|
|
count: updated.length, |
|
|
events: updated.map(r => ({ |
|
|
id: r.id.substring(0, 16) + '...', |
|
|
pubkey: r.pubkey.substring(0, 16) + '...', |
|
|
content: r.content, |
|
|
fullEvent: r |
|
|
})) |
|
|
}); |
|
|
|
|
|
// Add new reactions to the map |
|
|
for (const r of updated) { |
|
|
allReactionsMap.set(r.id, r); |
|
|
} |
|
|
|
|
|
// Process all accumulated reactions |
|
|
const allReactions = Array.from(allReactionsMap.values()); |
|
|
const filtered = await filterDeletedReactions(allReactions); |
|
|
processReactions(filtered); |
|
|
} |
|
|
|
|
|
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 ThreadList behavior |
|
|
const reactionRelays = relayManager.getProfileReadRelays(); |
|
|
console.debug(`[FeedReactionButtons] Loading reactions for event ${event.id.substring(0, 16)}... (kind ${event.kind})`); |
|
|
console.debug(`[FeedReactionButtons] Using relays:`, reactionRelays); |
|
|
|
|
|
// Clear and rebuild reactions map for this event |
|
|
allReactionsMap.clear(); |
|
|
|
|
|
const reactionsWithLowerE = await nostrClient.fetchEvents( |
|
|
[{ kinds: [KIND.REACTION], '#e': [event.id] }], |
|
|
reactionRelays, |
|
|
{ useCache: true, cacheResults: true, onUpdate: handleReactionUpdate } |
|
|
); |
|
|
const reactionsWithUpperE = await nostrClient.fetchEvents( |
|
|
[{ kinds: [KIND.REACTION], '#E': [event.id] }], |
|
|
reactionRelays, |
|
|
{ useCache: true, cacheResults: true, onUpdate: handleReactionUpdate } |
|
|
); |
|
|
|
|
|
console.debug(`[FeedReactionButtons] Reactions fetched:`, { |
|
|
eventId: event.id.substring(0, 16) + '...', |
|
|
kind: event.kind, |
|
|
withLowerE: reactionsWithLowerE.length, |
|
|
withUpperE: reactionsWithUpperE.length, |
|
|
lowerE_events: reactionsWithLowerE.map(r => ({ |
|
|
id: r.id.substring(0, 16) + '...', |
|
|
pubkey: r.pubkey.substring(0, 16) + '...', |
|
|
content: r.content, |
|
|
tags: r.tags.filter(t => t[0] === 'e' || t[0] === 'E'), |
|
|
fullEvent: r |
|
|
})), |
|
|
upperE_events: reactionsWithUpperE.map(r => ({ |
|
|
id: r.id.substring(0, 16) + '...', |
|
|
pubkey: r.pubkey.substring(0, 16) + '...', |
|
|
content: r.content, |
|
|
tags: r.tags.filter(t => t[0] === 'e' || t[0] === 'E'), |
|
|
fullEvent: r |
|
|
})) |
|
|
}); |
|
|
|
|
|
// Combine and deduplicate by reaction ID |
|
|
for (const r of reactionsWithLowerE) { |
|
|
allReactionsMap.set(r.id, r); |
|
|
} |
|
|
for (const r of reactionsWithUpperE) { |
|
|
allReactionsMap.set(r.id, r); |
|
|
} |
|
|
const reactionEvents = Array.from(allReactionsMap.values()); |
|
|
|
|
|
console.debug(`[FeedReactionButtons] All reactions (deduplicated):`, { |
|
|
total: reactionEvents.length, |
|
|
events: reactionEvents.map(r => ({ |
|
|
id: r.id.substring(0, 16) + '...', |
|
|
pubkey: r.pubkey.substring(0, 16) + '...', |
|
|
content: r.content, |
|
|
tags: r.tags.filter(t => t[0] === 'e' || t[0] === 'E'), |
|
|
created_at: new Date(r.created_at * 1000).toISOString(), |
|
|
fullEvent: r |
|
|
})) |
|
|
}); |
|
|
|
|
|
// Filter out deleted reactions (kind 5) |
|
|
const filteredReactions = await filterDeletedReactions(reactionEvents); |
|
|
console.debug(`[FeedReactionButtons] After filtering deleted reactions:`, { |
|
|
before: reactionEvents.length, |
|
|
after: filteredReactions.length, |
|
|
filtered: reactionEvents.length - filteredReactions.length, |
|
|
events: filteredReactions.map(r => ({ |
|
|
id: r.id.substring(0, 16) + '...', |
|
|
pubkey: r.pubkey.substring(0, 16) + '...', |
|
|
content: r.content, |
|
|
fullEvent: r |
|
|
})) |
|
|
}); |
|
|
|
|
|
processReactions(filteredReactions); |
|
|
} catch (error) { |
|
|
console.error('[FeedReactionButtons] Error loading reactions:', error); |
|
|
} finally { |
|
|
loading = false; |
|
|
loadingReactions = false; |
|
|
} |
|
|
} |
|
|
|
|
|
async function filterDeletedReactions(reactions: NostrEvent[]): Promise<NostrEvent[]> { |
|
|
if (reactions.length === 0) return reactions; |
|
|
|
|
|
// Fetch deletion events (kind 5) to filter out deleted reactions |
|
|
const reactionRelays = relayManager.getProfileReadRelays(); |
|
|
const deletionEvents = await nostrClient.fetchEvents( |
|
|
[{ kinds: [KIND.EVENT_DELETION], authors: Array.from(new Set(reactions.map(r => r.pubkey))) }], |
|
|
reactionRelays, |
|
|
{ useCache: true } |
|
|
); |
|
|
|
|
|
console.debug(`[FeedReactionButtons] Deletion events fetched:`, { |
|
|
count: deletionEvents.length, |
|
|
events: deletionEvents.map(d => ({ |
|
|
id: d.id.substring(0, 16) + '...', |
|
|
pubkey: d.pubkey.substring(0, 16) + '...', |
|
|
deletedEventIds: d.tags.filter(t => t[0] === 'e').map(t => t[1]?.substring(0, 16) + '...'), |
|
|
fullEvent: d |
|
|
})) |
|
|
}); |
|
|
|
|
|
// Build a set of deleted reaction event IDs (keyed by pubkey) |
|
|
const deletedReactionIdsByPubkey = new Map<string, Set<string>>(); |
|
|
for (const deletionEvent of deletionEvents) { |
|
|
const pubkey = deletionEvent.pubkey; |
|
|
if (!deletedReactionIdsByPubkey.has(pubkey)) { |
|
|
deletedReactionIdsByPubkey.set(pubkey, new Set()); |
|
|
} |
|
|
// Kind 5 events have 'e' tags pointing to deleted events |
|
|
for (const tag of deletionEvent.tags) { |
|
|
if (tag[0] === 'e' && tag[1]) { |
|
|
deletedReactionIdsByPubkey.get(pubkey)!.add(tag[1]); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
console.debug(`[FeedReactionButtons] Deleted reaction IDs by pubkey:`, |
|
|
Array.from(deletedReactionIdsByPubkey.entries()).map(([pubkey, ids]) => ({ |
|
|
pubkey: pubkey.substring(0, 16) + '...', |
|
|
deletedIds: Array.from(ids).map(id => id.substring(0, 16) + '...') |
|
|
})) |
|
|
); |
|
|
|
|
|
// Filter out deleted reactions |
|
|
const filtered = reactions.filter(reaction => { |
|
|
const deletedIds = deletedReactionIdsByPubkey.get(reaction.pubkey); |
|
|
const isDeleted = deletedIds && deletedIds.has(reaction.id); |
|
|
if (isDeleted) { |
|
|
console.debug(`[FeedReactionButtons] Filtering out deleted reaction:`, { |
|
|
id: reaction.id.substring(0, 16) + '...', |
|
|
pubkey: reaction.pubkey.substring(0, 16) + '...', |
|
|
content: reaction.content, |
|
|
fullEvent: reaction |
|
|
}); |
|
|
} |
|
|
return !isDeleted; |
|
|
}); |
|
|
|
|
|
return filtered; |
|
|
} |
|
|
|
|
|
async function processReactions(reactionEvents: NostrEvent[]) { |
|
|
console.debug(`[FeedReactionButtons] Processing ${reactionEvents.length} reactions for event ${event.id.substring(0, 16)}... (kind ${event.kind})`); |
|
|
const reactionMap = new Map<string, { content: string; pubkeys: Set<string>; eventIds: Map<string, string> }>(); |
|
|
const currentUser = sessionManager.getCurrentPubkey(); |
|
|
let skippedInvalid = 0; |
|
|
|
|
|
for (const reactionEvent of reactionEvents) { |
|
|
let content = reactionEvent.content.trim(); |
|
|
const originalContent = content; |
|
|
|
|
|
// For kind 11 events (or kind 1111 replies to kind 11), normalize reactions: only + and - allowed |
|
|
// Backward compatibility: ⬆️/↑ = +, ⬇️/↓ = - |
|
|
if (event.kind === KIND.DISCUSSION_THREAD || forceUpvoteDownvote) { |
|
|
if (content === '⬆️' || content === '↑') { |
|
|
content = '+'; |
|
|
} else if (content === '⬇️' || content === '↓') { |
|
|
content = '-'; |
|
|
} else if (content !== '+' && content !== '-') { |
|
|
skippedInvalid++; |
|
|
console.log(`[FeedReactionButtons] Skipping invalid reaction for kind 11:`, { |
|
|
originalContent, |
|
|
reactionId: reactionEvent.id.substring(0, 16) + '...', |
|
|
pubkey: reactionEvent.pubkey.substring(0, 16) + '...', |
|
|
fullEvent: reactionEvent |
|
|
}); |
|
|
continue; // Skip invalid reactions for threads |
|
|
} |
|
|
} |
|
|
|
|
|
if (!reactionMap.has(content)) { |
|
|
reactionMap.set(content, { content, pubkeys: new Set(), eventIds: new Map() }); |
|
|
} |
|
|
reactionMap.get(content)!.pubkeys.add(reactionEvent.pubkey); |
|
|
reactionMap.get(content)!.eventIds.set(reactionEvent.pubkey, reactionEvent.id); |
|
|
|
|
|
if (currentUser && reactionEvent.pubkey === currentUser) { |
|
|
userReaction = content; |
|
|
userReactionEventId = reactionEvent.id; |
|
|
} |
|
|
} |
|
|
|
|
|
console.debug(`[FeedReactionButtons] Processed reactions summary:`, { |
|
|
totalReactions: reactionEvents.length, |
|
|
skippedInvalid, |
|
|
reactionCounts: Array.from(reactionMap.entries()).map(([content, data]) => ({ |
|
|
content, |
|
|
count: data.pubkeys.size, |
|
|
pubkeys: Array.from(data.pubkeys).map(p => p.substring(0, 16) + '...'), |
|
|
eventIds: Array.from(data.eventIds.entries()).map(([pubkey, eventId]) => ({ |
|
|
pubkey: pubkey.substring(0, 16) + '...', |
|
|
eventId: eventId.substring(0, 16) + '...' |
|
|
})) |
|
|
})), |
|
|
userReaction, |
|
|
allReactionEvents: reactionEvents.map(r => ({ |
|
|
id: r.id.substring(0, 16) + '...', |
|
|
pubkey: r.pubkey.substring(0, 16) + '...', |
|
|
content: r.content, |
|
|
fullEvent: r |
|
|
})) |
|
|
}); |
|
|
|
|
|
reactions = reactionMap; |
|
|
|
|
|
// Resolve custom emojis - include event author's pubkey as they may have defined emojis |
|
|
// First resolve with original function (reactors' emojis), then enhance with event author |
|
|
const emojiUrls = await resolveCustomEmojis(reactionMap); |
|
|
|
|
|
// Collect all pubkeys to check: event author, reactors, and p tags from the event |
|
|
const pubkeysToCheck = new Set<string>(); |
|
|
pubkeysToCheck.add(event.pubkey); // Event author |
|
|
for (const { pubkeys } of reactionMap.values()) { |
|
|
for (const pubkey of pubkeys) { |
|
|
pubkeysToCheck.add(pubkey); |
|
|
} |
|
|
} |
|
|
// Also check p tags from the event (mentioned users might have emoji sets) |
|
|
for (const tag of event.tags) { |
|
|
if (tag[0] === 'p' && tag[1]) { |
|
|
pubkeysToCheck.add(tag[1]); |
|
|
} |
|
|
} |
|
|
|
|
|
// Check all collected pubkeys for unresolved text emojis |
|
|
const unresolvedTextEmojis = Array.from(reactionMap.keys()).filter( |
|
|
c => c.startsWith(':') && c.endsWith(':') && !emojiUrls.has(c) |
|
|
); |
|
|
|
|
|
if (unresolvedTextEmojis.length > 0) { |
|
|
// Fetch emoji sets for all pubkeys in parallel |
|
|
const emojiSetPromises = Array.from(pubkeysToCheck).map(pubkey => |
|
|
fetchEmojiSet(pubkey).catch(() => null) |
|
|
); |
|
|
const emojiSets = await Promise.all(emojiSetPromises); |
|
|
|
|
|
// Try to resolve each unresolved emoji |
|
|
for (const content of unresolvedTextEmojis) { |
|
|
if (emojiUrls.has(content)) continue; // Already resolved |
|
|
|
|
|
const shortcode = content.slice(1, -1); // Remove colons |
|
|
|
|
|
// First check each emoji set from collected pubkeys |
|
|
let found = false; |
|
|
for (const emojiSet of emojiSets) { |
|
|
if (emojiSet?.emojis.has(shortcode)) { |
|
|
const emojiDef = emojiSet.emojis.get(shortcode)!; |
|
|
emojiUrls.set(content, emojiDef.url); |
|
|
found = true; |
|
|
break; // Found it, move to next emoji |
|
|
} |
|
|
} |
|
|
|
|
|
// If not found in specific pubkeys, search broadly |
|
|
if (!found) { |
|
|
const url = await resolveEmojiShortcode(shortcode, [], true); |
|
|
if (url) { |
|
|
emojiUrls.set(content, url); |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
// Update state - create new Map to ensure reactivity |
|
|
customEmojiUrls = new Map(emojiUrls); |
|
|
} |
|
|
|
|
|
async function toggleReaction(content: string) { |
|
|
if (!sessionManager.isLoggedIn()) { |
|
|
alert('Please log in to react'); |
|
|
return; |
|
|
} |
|
|
|
|
|
// For kind 11 events (or kind 1111 replies to kind 11), only allow + and - (upvote/downvote) |
|
|
if ((event.kind === KIND.DISCUSSION_THREAD || forceUpvoteDownvote) && content !== '+' && content !== '-') { |
|
|
return; |
|
|
} |
|
|
|
|
|
// If clicking the same reaction, delete it |
|
|
if (userReaction === content) { |
|
|
// Remove reaction by publishing a kind 5 deletion event |
|
|
if (userReactionEventId) { |
|
|
try { |
|
|
const deletionEvent: Omit<NostrEvent, 'id' | 'sig'> = { |
|
|
kind: KIND.EVENT_DELETION, |
|
|
pubkey: sessionManager.getCurrentPubkey()!, |
|
|
created_at: Math.floor(Date.now() / 1000), |
|
|
tags: [['e', userReactionEventId]], |
|
|
content: '' |
|
|
}; |
|
|
|
|
|
const config = nostrClient.getConfig(); |
|
|
await signAndPublish(deletionEvent, [...config.defaultRelays]); |
|
|
|
|
|
// Update local state |
|
|
userReaction = null; |
|
|
userReactionEventId = null; |
|
|
const reaction = reactions.get(content); |
|
|
if (reaction) { |
|
|
const currentUser = sessionManager.getCurrentPubkey(); |
|
|
if (currentUser) { |
|
|
reaction.pubkeys.delete(currentUser); |
|
|
reaction.eventIds.delete(currentUser); |
|
|
if (reaction.pubkeys.size === 0) { |
|
|
reactions.delete(content); |
|
|
} |
|
|
} |
|
|
} |
|
|
} 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; |
|
|
const reaction = reactions.get(content); |
|
|
if (reaction) { |
|
|
const currentUser = sessionManager.getCurrentPubkey(); |
|
|
if (currentUser) { |
|
|
reaction.pubkeys.delete(currentUser); |
|
|
reaction.eventIds.delete(currentUser); |
|
|
if (reaction.pubkeys.size === 0) { |
|
|
reactions.delete(content); |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
return; |
|
|
} |
|
|
|
|
|
// For kind 11 (or kind 1111 replies to kind 11): if user has the opposite vote, delete it first |
|
|
if ((event.kind === KIND.DISCUSSION_THREAD || forceUpvoteDownvote) && userReaction && userReaction !== content) { |
|
|
// Delete the existing vote first |
|
|
if (userReactionEventId) { |
|
|
try { |
|
|
const deletionEvent: Omit<NostrEvent, 'id' | 'sig'> = { |
|
|
kind: KIND.EVENT_DELETION, |
|
|
pubkey: sessionManager.getCurrentPubkey()!, |
|
|
created_at: Math.floor(Date.now() / 1000), |
|
|
tags: [['e', userReactionEventId]], |
|
|
content: '' |
|
|
}; |
|
|
|
|
|
const config = nostrClient.getConfig(); |
|
|
await signAndPublish(deletionEvent, [...config.defaultRelays]); |
|
|
|
|
|
// Update local state for the old reaction |
|
|
const oldReaction = reactions.get(userReaction); |
|
|
if (oldReaction) { |
|
|
const currentUser = sessionManager.getCurrentPubkey(); |
|
|
if (currentUser) { |
|
|
oldReaction.pubkeys.delete(currentUser); |
|
|
oldReaction.eventIds.delete(currentUser); |
|
|
if (oldReaction.pubkeys.size === 0) { |
|
|
reactions.delete(userReaction); |
|
|
} |
|
|
} |
|
|
} |
|
|
} 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()] |
|
|
]; |
|
|
|
|
|
if (sessionManager.getCurrentPubkey() && includeClientTag) { |
|
|
tags.push(['client', 'Aitherboard']); |
|
|
} |
|
|
|
|
|
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 with the new reaction |
|
|
userReaction = content; |
|
|
userReactionEventId = signedEvent.id; |
|
|
const currentPubkey = sessionManager.getCurrentPubkey()!; |
|
|
if (!reactions.has(content)) { |
|
|
reactions.set(content, { content, pubkeys: new Set([currentPubkey]), eventIds: new Map([[currentPubkey, signedEvent.id]]) }); |
|
|
} else { |
|
|
reactions.get(content)!.pubkeys.add(currentPubkey); |
|
|
reactions.get(content)!.eventIds.set(currentPubkey, signedEvent.id); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Error publishing reaction:', error); |
|
|
} |
|
|
} |
|
|
|
|
|
function getReactionDisplay(content: string): string { |
|
|
if (content === '+') return '❤️'; |
|
|
if (content.startsWith(':') && content.endsWith(':')) { |
|
|
const url = customEmojiUrls.get(content); |
|
|
if (url) { |
|
|
return content; // Custom emoji with URL - will be rendered as image |
|
|
} |
|
|
// Text emoji without URL - return the shortcode for display |
|
|
return content; |
|
|
} |
|
|
return content; |
|
|
} |
|
|
|
|
|
function formatTextEmoji(content: string): string { |
|
|
// Format text emojis like ":clap:" to "Clap" for display (fallback when no image URL) |
|
|
if (content.startsWith(':') && content.endsWith(':')) { |
|
|
const shortcode = content.slice(1, -1); // Remove colons |
|
|
// Capitalize first letter |
|
|
return shortcode.charAt(0).toUpperCase() + shortcode.slice(1); |
|
|
} |
|
|
return content; |
|
|
} |
|
|
|
|
|
// Make this reactive so it updates when customEmojiUrls changes |
|
|
function hasEmojiUrl(content: string): boolean { |
|
|
// Check if this text emoji has a resolved URL |
|
|
// Access customEmojiUrls to make this reactive |
|
|
const urls = customEmojiUrls; |
|
|
if (content.startsWith(':') && content.endsWith(':')) { |
|
|
return urls.has(content); |
|
|
} |
|
|
return false; |
|
|
} |
|
|
|
|
|
function isCustomEmoji(content: string): boolean { |
|
|
return content.startsWith(':') && content.endsWith(':') && customEmojiUrls.has(content); |
|
|
} |
|
|
|
|
|
function getCustomEmojiUrl(content: string): string | null { |
|
|
return customEmojiUrls.get(content) || null; |
|
|
} |
|
|
|
|
|
function getReactionCount(content: string): number { |
|
|
return reactions.get(content)?.pubkeys.size || 0; |
|
|
} |
|
|
|
|
|
function getAllReactions(): Array<{ content: string; count: number }> { |
|
|
const allReactions: Array<{ content: string; count: number }> = []; |
|
|
for (const [content, data] of reactions.entries()) { |
|
|
if (data.pubkeys.size > 0) { |
|
|
allReactions.push({ content, count: data.pubkeys.size }); |
|
|
} |
|
|
} |
|
|
return allReactions.sort((a, b) => { |
|
|
if (b.count !== a.count) return b.count - a.count; |
|
|
return a.content.localeCompare(b.content); |
|
|
}); |
|
|
} |
|
|
|
|
|
function getCustomEmojis(): string[] { |
|
|
const customEmojis: string[] = []; |
|
|
const allUnicodeEmojis = new Set<string>(); |
|
|
// Build set of all Unicode emojis |
|
|
for (let i = 0; i < emojiData.length; i++) { |
|
|
const emoji = emojiData[i]; |
|
|
if (typeof emoji === 'string' && emoji.trim()) { |
|
|
allUnicodeEmojis.add(emoji); |
|
|
} |
|
|
} |
|
|
// Get custom emojis from reactions (those that aren't Unicode emojis) |
|
|
for (const content of reactions.keys()) { |
|
|
if (content.startsWith(':') && content.endsWith(':') && !allUnicodeEmojis.has(content)) { |
|
|
customEmojis.push(content); |
|
|
} |
|
|
} |
|
|
return customEmojis.sort(); |
|
|
} |
|
|
|
|
|
function closeMenuOnOutsideClick(e: MouseEvent) { |
|
|
const target = e.target as HTMLElement; |
|
|
if (menuButton && |
|
|
!menuButton.contains(target) && |
|
|
!target.closest('.emoji-drawer')) { |
|
|
showMenu = false; |
|
|
} |
|
|
} |
|
|
|
|
|
function handleHeartClick(e: MouseEvent) { |
|
|
e.preventDefault(); |
|
|
e.stopPropagation(); |
|
|
|
|
|
if (showMenu) { |
|
|
showMenu = false; |
|
|
} else { |
|
|
showMenu = true; |
|
|
} |
|
|
} |
|
|
|
|
|
$effect(() => { |
|
|
if (showMenu) { |
|
|
// Prevent body scroll when drawer is open |
|
|
document.body.style.overflow = 'hidden'; |
|
|
|
|
|
|
|
|
const timeoutId = setTimeout(() => { |
|
|
document.addEventListener('click', closeMenuOnOutsideClick, true); |
|
|
}, 0); |
|
|
|
|
|
return () => { |
|
|
clearTimeout(timeoutId); |
|
|
document.removeEventListener('click', closeMenuOnOutsideClick, true); |
|
|
document.body.style.overflow = ''; |
|
|
}; |
|
|
} |
|
|
}); |
|
|
|
|
|
let includeClientTag = $state(true); |
|
|
</script> |
|
|
|
|
|
<div class="Feed-reaction-buttons flex gap-2 items-center flex-wrap"> |
|
|
{#if event.kind === KIND.DISCUSSION_THREAD || forceUpvoteDownvote} |
|
|
<!-- Kind 11 (Thread) or Kind 1111 (Reply to Thread): Only upvote and downvote buttons --> |
|
|
<button |
|
|
onclick={() => toggleReaction('+')} |
|
|
class="reaction-btn vote-btn {userReaction === '+' ? 'active' : ''}" |
|
|
title="Upvote" |
|
|
aria-label="Upvote" |
|
|
> |
|
|
⬆️ <span class="vote-count {getReactionCount('+') > 0 ? 'has-votes' : ''}">{getReactionCount('+')}</span> |
|
|
</button> |
|
|
<button |
|
|
onclick={() => toggleReaction('-')} |
|
|
class="reaction-btn vote-btn {userReaction === '-' ? 'active' : ''}" |
|
|
title="Downvote" |
|
|
aria-label="Downvote" |
|
|
> |
|
|
⬇️ <span class="vote-count {getReactionCount('-') > 0 ? 'has-votes' : ''}">{getReactionCount('-')}</span> |
|
|
</button> |
|
|
{:else} |
|
|
<!-- Kind 1 (Feed): Full reaction menu --> |
|
|
<div class="reaction-wrapper"> |
|
|
<button |
|
|
bind:this={menuButton} |
|
|
onclick={handleHeartClick} |
|
|
class="reaction-btn heart-btn {userReaction === '+' ? 'active' : ''}" |
|
|
title="Like or choose reaction" |
|
|
aria-label="Like or choose reaction" |
|
|
> |
|
|
❤️ |
|
|
</button> |
|
|
|
|
|
<EmojiPicker |
|
|
open={showMenu} |
|
|
onSelect={(emoji) => { |
|
|
toggleReaction(emoji); |
|
|
showMenu = false; |
|
|
}} |
|
|
onClose={() => { showMenu = false; }} |
|
|
/> |
|
|
</div> |
|
|
|
|
|
{#if event.kind !== KIND.DISCUSSION_THREAD} |
|
|
{#each getAllReactions() as { content, count }} |
|
|
<span |
|
|
class="reaction-display {userReaction === content ? 'active' : ''}" |
|
|
title={content === '+' ? 'Liked' : `Reacted with ${content}`} |
|
|
> |
|
|
{#if content === '+'} |
|
|
❤️ |
|
|
{:else if content.startsWith(':') && content.endsWith(':')} |
|
|
<!-- Text emoji - try to display as image if URL available --> |
|
|
{#if hasEmojiUrl(content)} |
|
|
{@const url = getCustomEmojiUrl(content)} |
|
|
{#if url} |
|
|
<img src={url} alt={content} class="custom-emoji-img" /> |
|
|
{:else} |
|
|
<span class="text-emoji">{formatTextEmoji(content)}</span> |
|
|
{/if} |
|
|
{:else} |
|
|
<span class="text-emoji">{formatTextEmoji(content)}</span> |
|
|
{/if} |
|
|
{:else} |
|
|
{content} |
|
|
{/if} |
|
|
<span class="reaction-count-text">{count}</span> |
|
|
</span> |
|
|
{/each} |
|
|
{/if} |
|
|
{/if} |
|
|
</div> |
|
|
|
|
|
<style> |
|
|
.Feed-reaction-buttons { |
|
|
margin-top: 0.5rem; |
|
|
} |
|
|
|
|
|
.reaction-btn { |
|
|
padding: 0.25rem 0.75rem; |
|
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
|
border-radius: 0.25rem; |
|
|
background: var(--fog-post, #ffffff); |
|
|
color: var(--fog-text, #1f2937); |
|
|
cursor: pointer; |
|
|
transition: all 0.2s; |
|
|
font-size: 0.875rem; |
|
|
} |
|
|
|
|
|
:global(.dark) .reaction-btn { |
|
|
background: var(--fog-dark-post, #1f2937); |
|
|
border-color: var(--fog-dark-border, #374151); |
|
|
color: var(--fog-dark-text, #f9fafb); |
|
|
} |
|
|
|
|
|
.reaction-btn:hover { |
|
|
background: var(--fog-highlight, #f3f4f6); |
|
|
border-color: var(--fog-accent, #64748b); |
|
|
} |
|
|
|
|
|
:global(.dark) .reaction-btn:hover { |
|
|
background: var(--fog-dark-highlight, #374151); |
|
|
} |
|
|
|
|
|
.reaction-btn.active { |
|
|
background: var(--fog-accent, #64748b); |
|
|
color: var(--fog-text, #475569); |
|
|
border-color: var(--fog-accent, #64748b); |
|
|
} |
|
|
|
|
|
:global(.dark) .reaction-btn.active { |
|
|
background: var(--fog-dark-accent, #64748b); |
|
|
color: var(--fog-dark-text, #cbd5e1); |
|
|
border-color: var(--fog-dark-accent, #64748b); |
|
|
} |
|
|
|
|
|
.reaction-wrapper { |
|
|
position: relative; |
|
|
} |
|
|
|
|
|
|
|
|
.custom-emoji-img { |
|
|
width: 1.25rem; |
|
|
height: 1.25rem; |
|
|
object-fit: contain; |
|
|
display: inline-block; |
|
|
vertical-align: middle; |
|
|
} |
|
|
|
|
|
.reaction-display { |
|
|
display: inline-flex; |
|
|
align-items: center; |
|
|
gap: 0.25rem; |
|
|
padding: 0.25rem 0.5rem; |
|
|
font-size: 0.875rem; |
|
|
color: var(--fog-text, #1f2937); |
|
|
user-select: none; |
|
|
} |
|
|
|
|
|
:global(.dark) .reaction-display { |
|
|
color: var(--fog-dark-text, #f9fafb); |
|
|
} |
|
|
|
|
|
.reaction-display.active { |
|
|
opacity: 0.8; |
|
|
} |
|
|
|
|
|
.reaction-count-text { |
|
|
font-size: 0.8125rem; |
|
|
font-weight: 500; |
|
|
color: var(--fog-text-light, #6b7280); |
|
|
} |
|
|
|
|
|
:global(.dark) .reaction-count-text { |
|
|
color: var(--fog-dark-text-light, #9ca3af); |
|
|
} |
|
|
|
|
|
.text-emoji { |
|
|
font-size: 0.875rem; |
|
|
font-weight: 500; |
|
|
color: var(--fog-text, #1f2937); |
|
|
text-transform: capitalize; |
|
|
} |
|
|
|
|
|
:global(.dark) .text-emoji { |
|
|
color: var(--fog-dark-text, #f9fafb); |
|
|
} |
|
|
|
|
|
.vote-btn { |
|
|
display: inline-flex; |
|
|
align-items: center; |
|
|
gap: 0.25rem; |
|
|
} |
|
|
|
|
|
.vote-count { |
|
|
transition: color 0.2s; |
|
|
color: #9ca3af !important; /* gray-400 for zeros */ |
|
|
} |
|
|
|
|
|
.vote-count.has-votes { |
|
|
color: #3b82f6 !important; /* blue-500 - brighter blue like checkbox */ |
|
|
font-weight: 600; /* semi-bold */ |
|
|
} |
|
|
|
|
|
:global(.dark) .vote-count { |
|
|
color: #6b7280 !important; /* gray-500 for zeros in dark mode */ |
|
|
} |
|
|
|
|
|
:global(.dark) .vote-count.has-votes { |
|
|
color: #60a5fa !important; /* blue-400 for dark mode - brighter than before */ |
|
|
font-weight: 600; /* semi-bold */ |
|
|
} |
|
|
|
|
|
</style>
|
|
|
|