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.
 
 
 
 
 

873 lines
29 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 { 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';
import { shouldIncludeClientTag } from '../../services/client-tag-preference.js';
import Icon from '../../components/ui/Icon.svelte';
import { getCachedReactionsForEvents } from '../../services/cache/event-cache.js';
import PublicationStatusModal from '../../components/modals/PublicationStatusModal.svelte';
import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
interface Props {
event: NostrEvent;
preloadedReactions?: NostrEvent[]; // Optional pre-loaded reactions to avoid duplicate fetches
}
let { event, 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);
let isMounted = $state(false);
let processingUpdate = $state(false);
let updateDebounceTimer: ReturnType<typeof setTimeout> | null = null;
let publicationModalOpen = $state(false);
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null);
let hoveredReaction = $state<string | null>(null);
let hoveredReactionElement = $state<HTMLElement | null>(null);
let tooltipPosition = $state<{ top: number; left: number } | null>(null);
onMount(() => {
// Set lastEventId immediately to prevent $effect from running during mount
if (event.id) {
lastEventId = event.id;
}
isMounted = true;
// Load from cache IMMEDIATELY (no waiting for initialization)
if (event.id) {
// Use pre-loaded reactions if available
if (preloadedReactions && preloadedReactions.length > 0) {
for (const r of preloadedReactions) {
allReactionsMap.set(r.id, r);
}
filterDeletedReactions(preloadedReactions).then(filtered => {
processReactions(filtered);
loading = false;
}).catch(() => {
loading = false;
});
} else {
// Load from cache immediately
loadFromCache();
}
} else {
loading = false;
}
// Initialize client in background for relay fetching
nostrClient.initialize().catch(() => {
// Silently fail - cache is already loaded
});
});
// Load reactions from cache immediately (synchronous cache access)
async function loadFromCache() {
if (!event.id) return;
try {
// Load from cache FIRST - instant display
const cachedReactionsMap = await getCachedReactionsForEvents([event.id]);
const cachedReactions = cachedReactionsMap.get(event.id) || [];
if (cachedReactions.length > 0) {
// Process cached reactions immediately
for (const r of cachedReactions) {
allReactionsMap.set(r.id, r);
}
const filtered = await filterDeletedReactions(cachedReactions);
processReactions(filtered);
loading = false; // Show cached results immediately
} else {
// No cache, need to fetch
loading = false; // Don't show loading spinner if we have no cache
}
// Fetch from relays in background (non-blocking)
loadReactions().catch(() => {
// Silently fail - we already have cache
});
} catch (error) {
// Cache load failed, try fetching
loading = false;
loadReactions().catch(() => {
// Silently fail
});
}
}
// 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.clear();
// Use pre-loaded reactions if available
if (preloadedReactions && preloadedReactions.length > 0) {
for (const r of preloadedReactions) {
allReactionsMap.set(r.id, r);
}
filterDeletedReactions(preloadedReactions).then(filtered => {
processReactions(filtered);
loading = false;
}).catch(() => {
loading = false;
});
} else {
// Load from cache immediately, then fetch from relays
loadFromCache();
}
});
// Handle real-time updates - process reactions when new ones arrive
// Debounced to prevent excessive processing
async function handleReactionUpdate(updated: NostrEvent[]) {
// Prevent concurrent processing
if (processingUpdate) {
return;
}
// Add new reactions to the map
let hasNewReactions = false;
for (const r of updated) {
if (!allReactionsMap.has(r.id)) {
allReactionsMap.set(r.id, r);
hasNewReactions = true;
}
}
// 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
const allReactions = Array.from(allReactionsMap.values());
const filtered = await filterDeletedReactions(allReactions);
processReactions(filtered);
} finally {
processingUpdate = false;
}
}, 300); // 300ms debounce
}
async function loadReactions() {
// Skip loading if we already have preloaded reactions
if (preloadedReactions && preloadedReactions.length > 0) {
// Preloaded reactions are already processed in onMount/$effect
return;
}
// 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.clear();
// Use low priority for reactions - they're background data, comments should load first
// Use cache-first and shorter timeout for faster loading
const reactionsWithLowerE = await nostrClient.fetchEvents(
[{ kinds: [KIND.REACTION], '#e': [event.id], limit: config.feedLimit }],
reactionRelays,
{ useCache: 'cache-first', cacheResults: true, onUpdate: handleReactionUpdate, timeout: config.shortTimeout, priority: 'low' }
);
const reactionsWithUpperE = await nostrClient.fetchEvents(
[{ kinds: [KIND.REACTION], '#E': [event.id], limit: config.feedLimit }],
reactionRelays,
{ useCache: 'cache-first', cacheResults: true, onUpdate: handleReactionUpdate, timeout: config.shortTimeout, priority: 'low' }
);
// 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());
// Filter out deleted reactions (kind 5)
const filteredReactions = await filterDeletedReactions(reactionEvents);
processReactions(filteredReactions);
} catch (error) {
console.error('[FeedReactionButtons] Error loading reactions:', error);
} finally {
loading = false;
loadingReactions = false;
}
}
// Cache of checked reaction IDs to avoid repeated deletion checks
const checkedReactionIds = new Set<string>();
async function filterDeletedReactions(reactions: NostrEvent[]): Promise<NostrEvent[]> {
if (reactions.length === 0) return reactions;
// Filter out reactions we've already checked and found to be non-deleted
const uncheckedReactions = reactions.filter(r => !checkedReactionIds.has(r.id));
// If all reactions have been checked, return as-is (they're all non-deleted)
if (uncheckedReactions.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 = uncheckedReactions.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 and mark non-deleted ones as checked
const filtered = reactions.filter(reaction => {
const isDeleted = deletedReactionIds.has(reaction.id);
if (!isDeleted) {
// Cache that this reaction is not deleted
checkedReactionIds.add(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 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;
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;
}
}
// Only log in debug mode to reduce console noise
if (import.meta.env.DEV && false) { // Disable verbose logging
console.debug(`[FeedReactionButtons] Processed reactions summary:`, {
totalReactions: reactionEvents.length,
skippedInvalid,
reactionCounts: Array.from(reactionMap.entries()).map(([content, data]) => ({
content,
count: data.pubkeys.size
}))
});
}
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
}
}
// Don't search broadly here - it triggers background fetching
// Broad search should only happen when emoji picker is opened
}
}
// 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;
}
// 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 relays = relayManager.getReactionPublishRelays();
const results = await signAndPublish(deletionEvent, relays);
publicationResults = results;
publicationModalOpen = true;
// 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;
}
try {
const tags: string[][] = [
['e', event.id],
['p', event.pubkey],
['k', event.kind.toString()]
];
if (sessionManager.getCurrentPubkey() && shouldIncludeClientTag()) {
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
};
// Sign the event first to get the ID
const signedEvent = await sessionManager.signEvent(reactionEvent);
// Publish the signed event using reaction publish relays (filters read-only relays)
const relays = relayManager.getReactionPublishRelays();
const results = await nostrClient.publish(signedEvent, { relays });
publicationResults = results;
publicationModalOpen = true;
// 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 {
// For +, we'll render it as heart icon in the template
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 = '';
};
}
});
const isLoggedIn = $derived(sessionManager.isLoggedIn());
</script>
<div class="Feed-reaction-buttons flex gap-2 items-center flex-wrap">
<!-- Full reaction menu for feed posts -->
{#if isLoggedIn}
<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"
>
<Icon name="heart" size={16} />
</button>
<EmojiPicker
open={showMenu}
onSelect={(emoji) => {
toggleReaction(emoji);
showMenu = false;
}}
onClose={() => { showMenu = false; }}
/>
</div>
{/if}
{#each getAllReactions() as { content, count }}
{@const reactionData = reactions.get(content)}
{@const pubkeys = reactionData ? Array.from(reactionData.pubkeys) : []}
<span
class="reaction-display {userReaction === content ? 'active' : ''}"
title={content === '+' ? 'Liked' : `Reacted with ${content}`}
role="presentation"
onmouseenter={(e) => {
hoveredReaction = content;
hoveredReactionElement = e.currentTarget;
// Calculate tooltip position
const rect = e.currentTarget.getBoundingClientRect();
const tooltipWidth = 300; // max-width from CSS
const tooltipHeight = 200; // max-height from CSS
let left = rect.left;
let top = rect.bottom + 5;
// Adjust if tooltip would go off right edge
if (left + tooltipWidth > window.innerWidth) {
left = window.innerWidth - tooltipWidth - 10;
}
// Adjust if tooltip would go off left edge
if (left < 10) {
left = 10;
}
// Adjust if tooltip would go off bottom edge (show above instead)
if (top + tooltipHeight > window.innerHeight) {
top = rect.top - tooltipHeight - 5;
}
// Adjust if tooltip would go off top edge
if (top < 10) {
top = 10;
}
tooltipPosition = { top, left };
}}
onmouseleave={() => {
hoveredReaction = null;
hoveredReactionElement = null;
tooltipPosition = null;
}}
>
{#if content === '+'}
<Icon name="heart" size={16} />
{: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>
{#if hoveredReaction === content && tooltipPosition && pubkeys.length > 0}
<div
class="reaction-tooltip"
style="top: {tooltipPosition.top}px; left: {tooltipPosition.left}px;"
role="tooltip"
onmouseenter={() => {
// Keep tooltip visible when hovering over it
}}
onmouseleave={() => {
hoveredReaction = null;
hoveredReactionElement = null;
tooltipPosition = null;
}}
>
<div class="reaction-tooltip-content">
{#each pubkeys as pubkey}
<div class="reaction-tooltip-badge">
<ProfileBadge {pubkey} inline={true} />
</div>
{/each}
</div>
</div>
{/if}
{/each}
</div>
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} />
<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;
display: inline-flex;
align-items: center;
justify-content: center;
}
: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;
}
.reaction-display :global(.icon) {
flex-shrink: 0;
}
: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, #52667a);
}
:global(.dark) .reaction-count-text {
color: var(--fog-dark-text-light, #a8b8d0);
}
.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);
}
.reaction-display {
position: relative;
}
.reaction-tooltip {
position: fixed;
z-index: 1000;
pointer-events: auto;
max-width: 300px;
}
.reaction-tooltip-content {
background: var(--fog-surface, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
padding: 0.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
gap: 0.375rem;
max-height: 200px;
overflow-y: auto;
}
:global(.dark) .reaction-tooltip-content {
background: var(--fog-dark-surface, #1f2937);
border-color: var(--fog-dark-border, #374151);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
}
.reaction-tooltip-badge {
display: flex;
align-items: center;
}
</style>