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.
 
 
 
 
 

809 lines
22 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 { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import emojiData from 'unicode-emoji-json/data-ordered-emoji.json';
import { resolveCustomEmojis } from '../../services/nostr/nip30-emoji.js';
interface Props {
event: NostrEvent; // Feed event
}
let { event }: Props = $props();
let reactions = $state<Map<string, { content: string; pubkeys: Set<string> }>>(new Map());
let userReaction = $state<string | null>(null);
let loading = $state(true);
let showMenu = $state(false);
let menuButton: HTMLButtonElement | null = $state(null);
let menuPosition = $state<'above' | 'below'>('above'); // Track menu position
let customEmojiUrls = $state<Map<string, string>>(new Map()); // Map of :shortcode: -> image URL
let emojiSearchQuery = $state('');
let isMobile = $state(false);
// Derived value for heart count
let heartCount = $derived(getReactionCount('+'));
// Get emoji list from unicode-emoji-json library
// The library provides an array of emoji strings in order
const reactionMenu = $derived.by(() => {
const emojis: string[] = ['+']; // Heart (default) always first
// Add ALL emojis from the library - the menu will scroll
// The data-ordered-emoji.json is already an array of emoji strings
for (let i = 0; i < emojiData.length; i++) {
const emoji = emojiData[i];
if (typeof emoji === 'string' && emoji.trim()) {
emojis.push(emoji);
}
}
return emojis;
});
// Filter emojis based on search query
const filteredReactionMenu = $derived.by(() => {
if (!emojiSearchQuery.trim()) {
return reactionMenu;
}
const query = emojiSearchQuery.toLowerCase().trim();
return reactionMenu.filter(emoji => {
// Search by emoji character itself
if (emoji.toLowerCase().includes(query)) {
return true;
}
// For custom emojis, search by shortcode
if (emoji.startsWith(':') && emoji.endsWith(':')) {
return emoji.toLowerCase().includes(query);
}
// Try to match emoji unicode name (basic approach)
// This is a simple search - could be enhanced with proper emoji name data
return false;
});
});
// Custom emoji reactions (like :turtlehappy_sm:)
// These will be added dynamically from actual reactions received
onMount(() => {
nostrClient.initialize().then(() => {
loadReactions();
});
// Check if mobile on mount and resize
checkMobile();
window.addEventListener('resize', checkMobile);
return () => {
window.removeEventListener('resize', checkMobile);
};
});
function checkMobile() {
isMobile = window.innerWidth < 768; // Match Tailwind's md breakpoint
}
async function loadReactions() {
loading = true;
try {
const config = nostrClient.getConfig();
const filters = [
{
kinds: [7],
'#e': [event.id]
}
];
const reactionEvents = await nostrClient.fetchEvents(
filters,
[...config.defaultRelays],
{ useCache: true, cacheResults: true, onUpdate: (updated) => {
processReactions(updated);
}}
);
processReactions(reactionEvents);
} catch (error) {
console.error('Error loading reactions:', error);
} finally {
loading = false;
}
}
async function processReactions(reactionEvents: NostrEvent[]) {
const reactionMap = new Map<string, { content: string; pubkeys: Set<string> }>();
const currentUser = sessionManager.getCurrentPubkey();
for (const reactionEvent of reactionEvents) {
const content = reactionEvent.content.trim();
if (!reactionMap.has(content)) {
reactionMap.set(content, { content, pubkeys: new Set() });
}
reactionMap.get(content)!.pubkeys.add(reactionEvent.pubkey);
if (currentUser && reactionEvent.pubkey === currentUser) {
userReaction = content;
}
}
reactions = reactionMap;
// Resolve custom emojis (NIP-30) to image URLs
const emojiUrls = await resolveCustomEmojis(reactionMap);
customEmojiUrls = emojiUrls;
}
async function toggleReaction(content: string) {
if (!sessionManager.isLoggedIn()) {
alert('Please log in to react');
return;
}
if (userReaction === content) {
// Remove reaction
userReaction = null;
const reaction = reactions.get(content);
if (reaction) {
const currentUser = sessionManager.getCurrentPubkey();
if (currentUser) {
reaction.pubkeys.delete(currentUser);
if (reaction.pubkeys.size === 0) {
reactions.delete(content);
}
}
}
return;
}
try {
const tags: string[][] = [
['e', event.id],
['p', event.pubkey],
['k', '1']
];
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();
await signAndPublish(reactionEvent, [...config.defaultRelays]);
userReaction = content;
const currentPubkey = sessionManager.getCurrentPubkey()!;
if (!reactions.has(content)) {
reactions.set(content, { content, pubkeys: new Set([currentPubkey]) });
} else {
reactions.get(content)!.pubkeys.add(currentPubkey);
}
} catch (error) {
console.error('Error publishing reaction:', error);
alert('Error publishing reaction');
}
}
function getReactionDisplay(content: string): string {
if (content === '+') return '❤';
// Check if this is a custom emoji with a resolved URL
if (content.startsWith(':') && content.endsWith(':')) {
const url = customEmojiUrls.get(content);
if (url) {
// Return a placeholder that will be replaced with img tag in template
return content; // We'll render as img in template
}
}
return content;
}
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 }> {
// Get all reactions that have counts > 0, sorted by count (descending)
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 });
}
}
// Sort by count descending, then by content
return allReactions.sort((a, b) => {
if (b.count !== a.count) return b.count - a.count;
return a.content.localeCompare(b.content);
});
}
function getCustomEmojis(): string[] {
// Extract custom emoji reactions (format: :name:)
const customEmojis: string[] = [];
for (const content of reactions.keys()) {
if (content.startsWith(':') && content.endsWith(':') && !reactionMenu.includes(content)) {
customEmojis.push(content);
}
}
return customEmojis.sort();
}
// Filter custom emojis based on search query
const filteredCustomEmojis = $derived.by(() => {
const customEmojis = getCustomEmojis();
if (!emojiSearchQuery.trim()) {
return customEmojis;
}
const query = emojiSearchQuery.toLowerCase().trim();
return customEmojis.filter(emoji => {
// Search by shortcode (e.g., :turtlehappy_sm:)
return 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 = ''; // Clear search when closing
}
}
function handleHeartClick() {
if (showMenu) {
// If menu is open, clicking heart again should just like/unlike
toggleReaction('+');
showMenu = false;
emojiSearchQuery = ''; // Clear search when closing
} else {
// On mobile, always use bottom drawer
if (isMobile) {
menuPosition = 'below';
} else {
// Check if there's enough space above the button
if (menuButton) {
const rect = menuButton.getBoundingClientRect();
const spaceAbove = rect.top;
const spaceBelow = window.innerHeight - rect.bottom;
// Position below if there's more space below or if space above is less than 300px
menuPosition = spaceBelow > spaceAbove || spaceAbove < 300 ? 'below' : 'above';
}
}
// If menu is closed, open it
showMenu = true;
emojiSearchQuery = ''; // Clear search when opening
// Focus search input after menu opens (using setTimeout to ensure DOM is ready)
if (!isMobile) {
setTimeout(() => {
const searchInput = document.querySelector('.emoji-search-input') as HTMLInputElement;
if (searchInput) {
searchInput.focus();
}
}, 0);
}
}
}
function handleSearchInput(e: Event) {
const target = e.target as HTMLInputElement;
emojiSearchQuery = target.value;
}
// Action to focus input when menu opens
function focusOnMount(node: HTMLInputElement, shouldFocus: boolean) {
if (shouldFocus) {
setTimeout(() => node.focus(), 0);
}
return {
update(newShouldFocus: boolean) {
if (newShouldFocus) {
setTimeout(() => node.focus(), 0);
}
}
};
}
$effect(() => {
if (showMenu) {
document.addEventListener('click', closeMenuOnOutsideClick);
return () => document.removeEventListener('click', closeMenuOnOutsideClick);
}
});
let includeClientTag = $state(true);
</script>
<div class="Feed-reaction-buttons flex gap-2 items-center flex-wrap">
<!-- Heart button - always visible, opens 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"
>
{heartCount > 0 ? heartCount : ''}
</button>
<!-- Reaction menu dropdown -->
{#if showMenu}
{#if isMobile}
<!-- Mobile backdrop -->
<div
class="mobile-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>
{/if}
<div class="reaction-menu" class:menu-below={menuPosition === 'below'} class:mobile-drawer={isMobile}>
<!-- Search box - always visible at top -->
<div class="emoji-search-container">
<input
type="text"
placeholder="Search emojis..."
value={emojiSearchQuery}
oninput={handleSearchInput}
class="emoji-search-input"
aria-label="Search emojis"
use:focusOnMount={!isMobile}
/>
</div>
<!-- Scrollable content area -->
<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 isCustomEmoji(reaction)}
{@const url = getCustomEmojiUrl(reaction)}
{#if url}
<img src={url} alt={reaction} class="custom-emoji-img" />
{:else}
{reaction}
{/if}
{:else}
{getReactionDisplay(reaction)}
{/if}
{#if count > 0}
<span class="reaction-count">{count}</span>
{/if}
</button>
{/each}
</div>
<!-- Custom emojis section -->
{#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 isCustomEmoji(emoji)}
{@const url = getCustomEmojiUrl(emoji)}
{#if url}
<img src={url} alt={emoji} class="custom-emoji-img" />
{:else}
{emoji}
{/if}
{:else}
{emoji}
{/if}
{#if count > 0}
<span class="reaction-count">{count}</span>
{/if}
</button>
{/each}
</div>
</div>
{/if}
</div>
</div>
{/if}
</div>
<!-- Display all other reactions that have counts > 0 (except + which is shown as heart) -->
{#each getAllReactions() as { content, count }}
{#if content !== '+'}
<button
onclick={() => toggleReaction(content)}
class="reaction-btn {userReaction === content ? 'active' : ''}"
title={`React with ${content}`}
aria-label={`React with ${content}`}
>
{#if isCustomEmoji(content)}
{@const url = getCustomEmojiUrl(content)}
{#if url}
<img src={url} alt={content} class="custom-emoji-img" />
{:else}
{content}
{/if}
{:else}
{content}
{/if}
{count}
</button>
{/if}
{/each}
</div>
<style>
.Feed-reaction-buttons {
margin-top: 0.5rem;
}
@media (max-width: 768px) {
.Feed-reaction-buttons {
gap: 0.375rem; /* Smaller gap on mobile */
}
.reaction-btn {
padding: 0.25rem 0.5rem; /* Smaller padding on mobile */
font-size: 0.8125rem; /* Slightly smaller text */
}
}
.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;
}
.heart-btn {
/* Heart button styling */
}
.reaction-menu {
position: absolute;
bottom: 100%;
left: 0;
margin-bottom: 0.5rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
padding: 0.5rem;
z-index: 1000;
min-width: 200px;
max-width: 300px;
max-height: min(60vh, 400px);
display: flex;
flex-direction: column;
overflow: hidden;
/* Ensure scrollbar is always visible */
scrollbar-width: thin;
scrollbar-color: var(--fog-border, #e5e7eb) var(--fog-post, #ffffff);
}
.reaction-menu-content {
overflow-y: auto;
overflow-x: hidden;
flex: 1;
}
/* Mobile drawer styles */
.reaction-menu.mobile-drawer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
top: auto;
margin: 0;
border-radius: 1rem 1rem 0 0;
max-width: 100%;
max-height: 70vh;
min-width: auto;
width: 100%;
box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.1), 0 -2px 4px -1px rgba(0, 0, 0, 0.06);
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
/* Backdrop for mobile drawer */
.mobile-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.mobile-drawer {
z-index: 1000;
}
.reaction-menu.menu-below {
bottom: auto;
top: 100%;
margin-bottom: 0;
margin-top: 0.5rem;
}
.reaction-menu::-webkit-scrollbar {
width: 8px;
}
.reaction-menu::-webkit-scrollbar-track {
background: var(--fog-post, #ffffff);
border-radius: 0.5rem;
}
.reaction-menu::-webkit-scrollbar-thumb {
background: var(--fog-border, #e5e7eb);
border-radius: 4px;
}
.reaction-menu::-webkit-scrollbar-thumb:hover {
background: var(--fog-accent, #64748b);
}
:global(.dark) .reaction-menu::-webkit-scrollbar-track {
background: var(--fog-dark-post, #1f2937);
}
:global(.dark) .reaction-menu::-webkit-scrollbar-thumb {
background: var(--fog-dark-border, #374151);
}
:global(.dark) .reaction-menu::-webkit-scrollbar-thumb:hover {
background: var(--fog-dark-accent, #64748b);
}
:global(.dark) .reaction-menu {
background: var(--fog-dark-post, #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-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;
}
.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, #374151);
}
.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-bottom: 0.5rem;
padding-bottom: 0.5rem;
padding-top: 0;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
display: block;
width: 100%;
}
:global(.dark) .emoji-search-container {
border-bottom-color: var(--fog-dark-border, #374151);
}
.emoji-search-input {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
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.1);
}
:global(.dark) .emoji-search-input {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
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.2);
}
/* Adjust grid for mobile */
@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>