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.
 
 
 
 
 

1206 lines
38 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 { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import emojiData from 'unicode-emoji-json/data-ordered-emoji.json';
import emojiNames from 'unicode-emoji-json/data-by-emoji.json';
import { resolveCustomEmojis, fetchEmojiSet, resolveEmojiShortcode } from '../../services/nostr/nip30-emoji.js';
interface Props {
event: NostrEvent;
forceUpvoteDownvote?: boolean; // Force upvote/downvote mode (for kind 1111 replies to kind 11 threads)
}
let { event, forceUpvoteDownvote = false }: 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 emojiSearchQuery = $state('');
let emojiSearchInput: HTMLInputElement | null = $state(null);
let allReactionsMap = $state<Map<string, NostrEvent>>(new Map()); // Persist reactions map for real-time updates
let heartCount = $derived(getReactionCount('+'));
const reactionMenu = $derived.by(() => {
const emojis: string[] = ['+'];
for (let i = 0; i < emojiData.length; i++) {
const emoji = emojiData[i];
if (typeof emoji === 'string' && emoji.trim()) {
emojis.push(emoji);
}
}
return emojis;
});
const filteredReactionMenu = $derived.by(() => {
if (!emojiSearchQuery.trim()) {
return reactionMenu;
}
const query = emojiSearchQuery.toLowerCase().trim();
return reactionMenu.filter(emoji => {
// For custom emojis (shortcodes), search the shortcode itself
if (emoji.startsWith(':') && emoji.endsWith(':')) {
return emoji.toLowerCase().includes(query);
}
// For regular emojis, search by name
const emojiInfo = (emojiNames as Record<string, { name?: string; slug?: string }>)[emoji];
if (emojiInfo) {
const name = emojiInfo.name?.toLowerCase() || '';
const slug = emojiInfo.slug?.toLowerCase() || '';
if (name.includes(query) || slug.includes(query)) {
return true;
}
}
// Fallback: search the emoji character itself (though this rarely matches)
return emoji.toLowerCase().includes(query);
});
});
onMount(() => {
nostrClient.initialize().then(() => {
loadReactions();
});
});
// Reload reactions when event changes
$effect(() => {
if (event.id) {
// Clear previous reactions map when event changes
allReactionsMap.clear();
loadReactions();
}
});
// Handle real-time updates - process reactions when new ones arrive
async function handleReactionUpdate(updated: NostrEvent[]) {
console.log(`[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() {
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.log(`[FeedReactionButtons] Loading reactions for event ${event.id.substring(0, 16)}... (kind ${event.kind})`);
console.log(`[FeedReactionButtons] Using relays:`, reactionRelays);
// Clear and rebuild reactions map for this event
allReactionsMap.clear();
const reactionsWithLowerE = await nostrClient.fetchEvents(
[{ kinds: [7], '#e': [event.id] }],
reactionRelays,
{ useCache: true, cacheResults: true, onUpdate: handleReactionUpdate }
);
const reactionsWithUpperE = await nostrClient.fetchEvents(
[{ kinds: [7], '#E': [event.id] }],
reactionRelays,
{ useCache: true, cacheResults: true, onUpdate: handleReactionUpdate }
);
console.log(`[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.log(`[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.log(`[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;
}
}
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: [5], authors: Array.from(new Set(reactions.map(r => r.pubkey))) }],
reactionRelays,
{ useCache: true }
);
console.log(`[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.log(`[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.log(`[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.log(`[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 === 11 || 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.log(`[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 === 11 || 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: 5,
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 === 11 || forceUpvoteDownvote) && userReaction && userReaction !== content) {
// Delete the existing vote first
if (userReactionEventId) {
try {
const deletionEvent: Omit<NostrEvent, 'id' | 'sig'> = {
kind: 5,
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: 7,
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[] = [];
for (const content of reactions.keys()) {
if (content.startsWith(':') && content.endsWith(':') && !reactionMenu.includes(content)) {
customEmojis.push(content);
}
}
return customEmojis.sort();
}
const filteredCustomEmojis = $derived.by(() => {
const customEmojis = getCustomEmojis();
if (!emojiSearchQuery.trim()) {
return customEmojis;
}
const query = emojiSearchQuery.toLowerCase().trim();
return customEmojis.filter(emoji => emoji.toLowerCase().includes(query));
});
function closeMenuOnOutsideClick(e: MouseEvent) {
const target = e.target as HTMLElement;
if (menuButton &&
!menuButton.contains(target) &&
!target.closest('.reaction-menu')) {
showMenu = false;
emojiSearchQuery = '';
}
}
function handleHeartClick(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (showMenu) {
showMenu = false;
emojiSearchQuery = '';
} else {
showMenu = true;
emojiSearchQuery = '';
// Focus the search input after menu opens
requestAnimationFrame(() => {
if (emojiSearchInput) {
emojiSearchInput.focus();
}
});
}
}
function handleSearchInput(e: Event) {
const target = e.target as HTMLInputElement;
emojiSearchQuery = target.value;
}
$effect(() => {
if (showMenu) {
// Prevent body scroll when drawer is open
document.body.style.overflow = 'hidden';
// Focus the search input when menu opens
requestAnimationFrame(() => {
if (emojiSearchInput) {
emojiSearchInput.focus();
}
});
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 === 11 || 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>
{#if showMenu}
<div
class="drawer-backdrop"
onclick={() => { showMenu = false; emojiSearchQuery = ''; }}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
showMenu = false;
emojiSearchQuery = '';
}
}}
role="button"
tabindex="0"
aria-label="Close emoji menu"
></div>
<div class="reaction-menu drawer-left">
<div class="drawer-header">
<h3 class="drawer-title">Choose Reaction</h3>
<button
onclick={() => { showMenu = false; emojiSearchQuery = ''; }}
class="drawer-close"
aria-label="Close emoji menu"
title="Close"
>
×
</button>
</div>
<div class="emoji-search-container">
<input
bind:this={emojiSearchInput}
type="text"
placeholder="Search emojis..."
value={emojiSearchQuery}
oninput={handleSearchInput}
class="emoji-search-input"
aria-label="Search emojis"
/>
</div>
<div class="reaction-menu-content">
<div class="reaction-menu-grid">
{#each filteredReactionMenu as reaction}
{@const count = getReactionCount(reaction)}
<button
onclick={() => {
toggleReaction(reaction);
showMenu = false;
}}
class="reaction-menu-item {userReaction === reaction ? 'active' : ''}"
title={reaction === '+' ? 'Like' : `React with ${getReactionDisplay(reaction)}`}
>
{#if reaction.startsWith(':') && reaction.endsWith(':')}
<!-- Text emoji - try to display as image if URL available -->
{#if hasEmojiUrl(reaction)}
{@const url = getCustomEmojiUrl(reaction)}
{#if url}
<img src={url} alt={reaction} class="custom-emoji-img" />
{:else}
<span class="text-emoji">{formatTextEmoji(reaction)}</span>
{/if}
{:else}
<span class="text-emoji">{formatTextEmoji(reaction)}</span>
{/if}
{:else}
{getReactionDisplay(reaction)}
{/if}
{#if count > 0}
<span class="reaction-count">{count}</span>
{/if}
</button>
{/each}
</div>
{#if filteredCustomEmojis.length > 0}
<div class="custom-emojis-section">
<div class="custom-emojis-label">Custom</div>
<div class="reaction-menu-grid">
{#each filteredCustomEmojis as emoji}
{@const count = getReactionCount(emoji)}
<button
onclick={() => {
toggleReaction(emoji);
showMenu = false;
}}
class="reaction-menu-item {userReaction === emoji ? 'active' : ''}"
title={`React with ${emoji}`}
>
{#if hasEmojiUrl(emoji)}
{@const url = getCustomEmojiUrl(emoji)}
{#if url}
<img src={url} alt={emoji} class="custom-emoji-img" />
{:else}
<span class="text-emoji">{formatTextEmoji(emoji)}</span>
{/if}
{:else}
<span class="text-emoji">{formatTextEmoji(emoji)}</span>
{/if}
{#if count > 0}
<span class="reaction-count">{count}</span>
{/if}
</button>
{/each}
</div>
</div>
{/if}
</div>
</div>
{/if}
</div>
{#if event.kind !== 11}
{#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;
}
.drawer-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.reaction-menu {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(400px, 80vw);
max-width: 400px;
background: var(--fog-post, #ffffff);
border-right: 2px solid var(--fog-border, #cbd5e1);
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2);
padding: 0;
z-index: 1000;
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideInLeft 0.3s ease-out;
transform: translateX(0);
}
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .drawer-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.drawer-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .drawer-title {
color: var(--fog-dark-text, #f9fafb);
}
.drawer-close {
background: transparent;
border: none;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--fog-text-light, #9ca3af);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition: all 0.2s;
}
.drawer-close:hover {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
}
:global(.dark) .drawer-close {
color: var(--fog-dark-text-light, #6b7280);
}
:global(.dark) .drawer-close:hover {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
@keyframes slideInLeft {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
.reaction-menu-content {
overflow-y: auto;
overflow-x: hidden;
flex: 1;
margin-top: 0.5rem;
}
:global(.dark) .reaction-menu {
background: var(--fog-dark-post, #1f2937);
border-right-color: var(--fog-dark-border, #475569);
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.5);
}
.reaction-menu-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 0.25rem;
}
.reaction-menu-item {
position: relative;
padding: 0.5rem;
border: 1px solid transparent;
border-radius: 0.25rem;
background: transparent;
cursor: pointer;
transition: all 0.2s;
font-size: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
min-height: 2.5rem;
filter: none !important;
}
.reaction-menu-item * {
filter: none !important;
}
.reaction-menu-item:hover {
background: var(--fog-highlight, #f3f4f6);
border-color: var(--fog-border, #e5e7eb);
}
:global(.dark) .reaction-menu-item:hover {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #374151);
}
.reaction-menu-item.active {
background: var(--fog-accent, #64748b);
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .reaction-menu-item.active {
background: var(--fog-dark-accent, #64748b);
border-color: var(--fog-dark-accent, #64748b);
}
.reaction-count {
position: absolute;
bottom: 0.125rem;
right: 0.125rem;
font-size: 0.625rem;
font-weight: 600;
background: var(--fog-accent, #64748b);
color: white;
border-radius: 50%;
width: 1rem;
height: 1rem;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
:global(.dark) .reaction-count {
background: var(--fog-dark-accent, #64748b);
}
.custom-emojis-section {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .custom-emojis-section {
border-top-color: var(--fog-dark-border, #475569);
}
.custom-emojis-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--fog-text-light, #6b7280);
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
:global(.dark) .custom-emojis-label {
color: var(--fog-dark-text-light, #9ca3af);
}
.custom-emoji-img {
width: 1.25rem;
height: 1.25rem;
object-fit: contain;
display: inline-block;
vertical-align: middle;
}
.emoji-search-container {
margin: 0;
padding: 1rem;
padding-top: 0.75rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
display: block;
width: 100%;
}
:global(.dark) .emoji-search-container {
border-bottom-color: var(--fog-dark-border, #475569);
}
.emoji-search-input {
width: 100%;
padding: 0.625rem;
border: 1.5px solid var(--fog-border, #cbd5e1);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
display: block;
box-sizing: border-box;
}
.emoji-search-input:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.15);
}
:global(.dark) .emoji-search-input {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .emoji-search-input:focus {
border-color: var(--fog-dark-accent, #64748b);
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.3);
}
.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 */
}
@media (max-width: 768px) {
.reaction-menu-grid {
grid-template-columns: repeat(8, 1fr);
}
.reaction-menu-item {
min-height: 2.25rem;
font-size: 1.125rem;
}
}
</style>