From f47a894902e638dae7d4e3c49c78817a437721c8 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 3 Feb 2026 22:44:44 +0100 Subject: [PATCH] universal emoji picker --- src/app.css | 28 +- src/lib/components/content/EmojiDrawer.svelte | 339 +++++++++++++ src/lib/components/content/EmojiPicker.svelte | 272 +++++++++++ src/lib/components/content/GifPicker.svelte | 351 ++++++++++++++ .../content/MarkdownRenderer.svelte | 7 +- src/lib/modules/comments/CommentForm.svelte | 117 ++++- .../reactions/FeedReactionButtons.svelte | 452 +----------------- src/lib/services/nostr/gif-service.ts | 159 ++++++ src/lib/services/nostr/nip30-emoji.ts | 18 +- src/lib/services/text-utils.ts | 94 +++- 10 files changed, 1339 insertions(+), 498 deletions(-) create mode 100644 src/lib/components/content/EmojiDrawer.svelte create mode 100644 src/lib/components/content/EmojiPicker.svelte create mode 100644 src/lib/components/content/GifPicker.svelte create mode 100644 src/lib/services/nostr/gif-service.ts diff --git a/src/app.css b/src/app.css index 0f8fd93..eee524c 100644 --- a/src/app.css +++ b/src/app.css @@ -115,23 +115,13 @@ img[src*="profile" i] { filter: grayscale(100%) sepia(12%) hue-rotate(200deg) saturate(35%) !important; } -/* Emoji images - grayscale like profile pics */ -/* Only apply filter to actual image elements, not text emojis */ -.emoji, -[class*="emoji"], +/* Emoji images - no grayscale filter, display in full color */ +/* Only apply to actual image elements, not text emojis */ img[alt*="emoji" i], img[src*="emoji" i], img.emoji-inline { - filter: grayscale(100%) sepia(15%) hue-rotate(200deg) saturate(60%) brightness(0.95); display: inline-block; -} - -.dark .emoji, -.dark [class*="emoji"], -.dark img[alt*="emoji" i], -.dark img[src*="emoji" i], -.dark img.emoji-inline { - filter: grayscale(100%) sepia(20%) hue-rotate(200deg) saturate(70%) brightness(0.9); + /* No grayscale filter - emojis should be in full color */ } /* Ensure normal Unicode emojis (text characters) are displayed correctly */ @@ -141,17 +131,7 @@ body, .markdown-content, .post-content { /* Normal emojis are text, not images, so no filter should apply */ } -/* Apply grayscale filter to reaction buttons containing emojis */ -/* But exclude emoji menu items - they should be full color */ -.reaction-btn:not(.reaction-menu-item), -.Feed-reaction-buttons button:not(.reaction-menu-item) { - filter: grayscale(100%) sepia(15%) hue-rotate(200deg) saturate(60%) brightness(0.95); -} - -.dark .reaction-btn:not(.reaction-menu-item), -.dark .Feed-reaction-buttons button:not(.reaction-menu-item) { - filter: grayscale(100%) sepia(20%) hue-rotate(200deg) saturate(70%) brightness(0.9); -} +/* Reaction buttons - no grayscale filter, emojis should be in full color */ /* Content images should be prominent - no grayscale filters */ .markdown-content img, diff --git a/src/lib/components/content/EmojiDrawer.svelte b/src/lib/components/content/EmojiDrawer.svelte new file mode 100644 index 0000000..3c7b7b1 --- /dev/null +++ b/src/lib/components/content/EmojiDrawer.svelte @@ -0,0 +1,339 @@ + + +{#if open} +
+ +{/if} + + diff --git a/src/lib/components/content/EmojiPicker.svelte b/src/lib/components/content/EmojiPicker.svelte new file mode 100644 index 0000000..c3fdb46 --- /dev/null +++ b/src/lib/components/content/EmojiPicker.svelte @@ -0,0 +1,272 @@ + + + + {#snippet children()} +
+ {#if emojis.length === 0 && (!searchQuery.trim() || customEmojis.length === 0)} +
No emojis found
+ {:else} + {#if emojis.length > 0} +
+ {#each emojis as emoji} + + {/each} +
+ {/if} + + {#if customEmojis.length > 0} +
+
Custom
+
+ {#each customEmojis as emoji} + + {/each} +
+
+ {/if} + + {#if loadingCustomEmojis && !searchQuery.trim()} +
Loading custom emojis...
+ {/if} + {/if} +
+ {/snippet} +
+ + diff --git a/src/lib/components/content/GifPicker.svelte b/src/lib/components/content/GifPicker.svelte new file mode 100644 index 0000000..d7d50ec --- /dev/null +++ b/src/lib/components/content/GifPicker.svelte @@ -0,0 +1,351 @@ + + +{#if open} +
+ +{/if} + + diff --git a/src/lib/components/content/MarkdownRenderer.svelte b/src/lib/components/content/MarkdownRenderer.svelte index 9988687..9995329 100644 --- a/src/lib/components/content/MarkdownRenderer.svelte +++ b/src/lib/components/content/MarkdownRenderer.svelte @@ -441,12 +441,7 @@ display: inline-block; margin: 0; vertical-align: middle; - /* Inline emojis should have grayscale filter like other emojis */ - filter: grayscale(100%) sepia(15%) hue-rotate(200deg) saturate(60%) brightness(0.95); - } - - :global(.dark .markdown-content img.emoji-inline) { - filter: grayscale(100%) sepia(20%) hue-rotate(200deg) saturate(70%) brightness(0.9); + /* Emojis should be in full color, no grayscale filter */ } :global(.markdown-content video) { diff --git a/src/lib/modules/comments/CommentForm.svelte b/src/lib/modules/comments/CommentForm.svelte index aa5df56..292e51b 100644 --- a/src/lib/modules/comments/CommentForm.svelte +++ b/src/lib/modules/comments/CommentForm.svelte @@ -5,6 +5,9 @@ import { relayManager } from '../../services/nostr/relay-manager.js'; import { fetchRelayLists } from '../../services/user-data.js'; import PublicationStatusModal from '../../components/modals/PublicationStatusModal.svelte'; + import GifPicker from '../../components/content/GifPicker.svelte'; + import EmojiPicker from '../../components/content/EmojiPicker.svelte'; + import { insertTextAtCursor } from '../../services/text-utils.js'; import type { NostrEvent } from '../../types/nostr.js'; interface Props { @@ -22,6 +25,15 @@ let includeClientTag = $state(true); let showStatusModal = $state(false); let publicationResults: { success: string[]; failed: Array<{ relay: string; error: string }> } | null = $state(null); + let showGifPicker = $state(false); + let showEmojiPicker = $state(false); + let textareaRef: HTMLTextAreaElement | null = $state(null); + + // Only show GIF/emoji buttons for non-kind-11 events (kind 1 replies and kind 1111 comments) + const showGifButton = $derived.by(() => { + const replyKind = getReplyKind(); + return replyKind === 1 || replyKind === 1111; + }); /** * Determine what kind of reply to create @@ -149,16 +161,57 @@ publishing = false; } } + + function handleGifSelect(gifUrl: string) { + if (!textareaRef) return; + // Insert GIF URL as plain text + insertTextAtCursor(textareaRef, gifUrl); + showGifPicker = false; + } + + function handleEmojiSelect(emoji: string) { + if (!textareaRef) return; + insertTextAtCursor(textareaRef, emoji); + showEmojiPicker = false; + }
- +
+ + + {#if showGifButton} +
+ + +
+ {/if} +
+ + {#if showGifButton} + showGifPicker = false} /> + showEmojiPicker = false} /> + {/if}
diff --git a/src/lib/services/nostr/gif-service.ts b/src/lib/services/nostr/gif-service.ts new file mode 100644 index 0000000..1e9ae9d --- /dev/null +++ b/src/lib/services/nostr/gif-service.ts @@ -0,0 +1,159 @@ +/** + * Service to fetch GIFs from Nostr NIP94 events + * NIP94 events (kind 94) contain file attachment metadata + */ + +import { nostrClient } from './nostr-client.js'; +import { relayManager } from './relay-manager.js'; +import type { NostrEvent } from '../../types/nostr.js'; + +export interface GifMetadata { + url: string; + fallbackUrl?: string; + sha256?: string; + mimeType?: string; + width?: number; + height?: number; + eventId: string; + pubkey: string; + createdAt: number; +} + +/** + * Parse NIP94 event to extract GIF metadata + */ +function parseNip94Event(event: NostrEvent): GifMetadata | null { + // NIP94 events can have different tag structures + // Try to find URL in various tag formats: url, file, or in content + let url: string | undefined; + + // First try 'url' tag + const urlTag = event.tags.find(t => t[0] === 'url' && t[1]); + if (urlTag && urlTag[1]) { + url = urlTag[1]; + } else { + // Try 'file' tag (NIP-94 format) + const fileTag = event.tags.find(t => t[0] === 'file' && t[1]); + if (fileTag && fileTag[1]) { + url = fileTag[1]; + } else { + // Try to extract URL from content (might be in markdown or plain text) + const urlMatch = event.content.match(/https?:\/\/[^\s<>"']+\.gif(\?[^\s<>"']*)?/i); + if (urlMatch) { + url = urlMatch[0]; + } + } + } + + if (!url) { + console.debug('[gif-service] No URL found in event:', event.id); + return null; + } + + // Check if it's a GIF by MIME type, file extension, or URL pattern + const mimeTag = event.tags.find(t => t[0] === 'm' && t[1]); + const mimeType = mimeTag?.[1] || ''; + const urlLower = url.toLowerCase(); + + // More flexible GIF detection + const isGif = + mimeType === 'image/gif' || + urlLower.endsWith('.gif') || + urlLower.includes('.gif?') || + urlLower.includes('/gif') || + (mimeType.startsWith('image/') && event.content.toLowerCase().includes('gif')); + + if (!isGif) { + console.debug('[gif-service] Not a GIF:', { url, mimeType, eventId: event.id }); + return null; + } + + // Extract optional metadata + const sha256Tag = event.tags.find(t => t[0] === 'x' && t[1]); + const dimTag = event.tags.find(t => t[0] === 'dim' && t[1]); + const fallbackTag = event.tags.find(t => t[0] === 'fallback' && t[1]); + + let width: number | undefined; + let height: number | undefined; + if (dimTag && dimTag[1]) { + // Format: "widthxheight" or "widthxheightxfps" for videos + const dims = dimTag[1].split('x'); + if (dims.length >= 2) { + width = parseInt(dims[0], 10); + height = parseInt(dims[1], 10); + } + } + + return { + url, + fallbackUrl: fallbackTag?.[1], + sha256: sha256Tag?.[1], + mimeType: mimeType || 'image/gif', + width, + height, + eventId: event.id, + pubkey: event.pubkey, + createdAt: event.created_at + }; +} + +/** + * Fetch GIFs from Nostr NIP94 events + * @param searchQuery Optional search query to filter GIFs (searches in content/tags) + * @param limit Maximum number of GIFs to return + */ +export async function fetchGifs(searchQuery?: string, limit: number = 50): Promise { + try { + // Use profile read relays to get GIFs + const relays = relayManager.getProfileReadRelays(); + + // Fetch kind 94 events (NIP94 file attachments) + const filters = [{ + kinds: [94], + limit: limit * 2 // Fetch more to filter for GIFs + }]; + + const events = await nostrClient.fetchEvents(filters, relays, { + useCache: true, + cacheResults: true + }); + + // Parse and filter for GIFs + const gifs: GifMetadata[] = []; + console.log(`[gif-service] Processing ${events.length} kind 94 events`); + + for (const event of events) { + const gif = parseNip94Event(event); + if (gif) { + // If search query provided, filter by content or tags + if (searchQuery) { + const query = searchQuery.toLowerCase(); + const content = event.content.toLowerCase(); + const tags = event.tags.flat().join(' ').toLowerCase(); + + if (content.includes(query) || tags.includes(query)) { + gifs.push(gif); + } + } else { + gifs.push(gif); + } + } + } + + console.log(`[gif-service] Found ${gifs.length} GIFs from ${events.length} events`); + + // Sort by creation date (newest first) and limit + gifs.sort((a, b) => b.createdAt - a.createdAt); + return gifs.slice(0, limit); + } catch (error) { + console.error('[gif-service] Error fetching GIFs:', error); + return []; + } +} + +/** + * Search GIFs by query + */ +export async function searchGifs(query: string, limit: number = 50): Promise { + return fetchGifs(query, limit); +} diff --git a/src/lib/services/nostr/nip30-emoji.ts b/src/lib/services/nostr/nip30-emoji.ts index f66e32c..5f9e857 100644 --- a/src/lib/services/nostr/nip30-emoji.ts +++ b/src/lib/services/nostr/nip30-emoji.ts @@ -31,6 +31,15 @@ const emojiSetCache = new Map(); // Global shortcode -> URL cache (built from all emoji packs) const shortcodeCache = new Map(); +/** + * Get all available custom emoji shortcodes and their URLs + */ +export function getAllCustomEmojis(): Array<{ shortcode: string; url: string }> { + return Array.from(shortcodeCache.entries()) + .map(([shortcode, url]) => ({ shortcode, url })) + .sort((a, b) => a.shortcode.localeCompare(b.shortcode)); +} + // Track if we've loaded all emoji packs let allEmojiPacksLoaded = false; let loadingEmojiPacks = false; @@ -144,15 +153,16 @@ export async function loadAllEmojiPacks(): Promise { loadingEmojiPacks = true; try { - const relays = relayManager.getFeedReadRelays(); + // Use profile relays to get emoji packs from more sources + const relays = relayManager.getProfileReadRelays(); console.log('[nip30-emoji] Loading all emoji packs/sets...'); // Fetch all emoji sets (10030) and emoji packs (30030) - // Use a high limit to get all available packs + // Use a high limit to get all available packs - increase limit to get more const events = await nostrClient.fetchEvents( - [{ kinds: [10030, 30030], limit: 500 }], // Get all emoji packs/sets + [{ kinds: [10030, 30030], limit: 1000 }], // Increased limit to get more emoji packs/sets relays, - { useCache: true, cacheResults: true, timeout: 10000 } + { useCache: true, cacheResults: true, timeout: 15000 } ); console.log(`[nip30-emoji] Found ${events.length} emoji pack/set events`); diff --git a/src/lib/services/text-utils.ts b/src/lib/services/text-utils.ts index 7fcf632..dfb5e59 100644 --- a/src/lib/services/text-utils.ts +++ b/src/lib/services/text-utils.ts @@ -1,39 +1,81 @@ /** - * Text utility functions + * Utility functions for text manipulation */ /** - * Strip markdown formatting from text, returning plain text + * Insert text at the current cursor position in a textarea + * @param textarea The textarea element + * @param text The text to insert */ -export function stripMarkdown(text: string): string { - // Remove code blocks (```code```) +export function insertTextAtCursor(textarea: HTMLTextAreaElement, text: string): void { + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const currentValue = textarea.value; + + // Insert text at cursor position + const newValue = currentValue.substring(0, start) + text + currentValue.substring(end); + + // Update textarea value + textarea.value = newValue; + + // Set cursor position after inserted text + const newCursorPos = start + text.length; + textarea.setSelectionRange(newCursorPos, newCursorPos); + + // Trigger input event so Svelte bindings update + textarea.dispatchEvent(new Event('input', { bubbles: true })); + + // Focus the textarea + textarea.focus(); +} + +/** + * Strip markdown formatting from text to get plain text + * @param markdown The markdown text to strip + * @returns Plain text without markdown formatting + */ +export function stripMarkdown(markdown: string): string { + if (!markdown) return ''; + + let text = markdown; + + // Remove code blocks text = text.replace(/```[\s\S]*?```/g, ''); - // Remove inline code (`code`) text = text.replace(/`[^`]*`/g, ''); - // Remove images (![alt](url)) + + // Remove images text = text.replace(/!\[([^\]]*)\]\([^\)]*\)/g, ''); - // Remove links ([text](url)) - keep the link text - text = text.replace(/\[([^\]]*)\]\([^\)]*\)/g, '$1'); - // Remove headers (# ## ###) - text = text.replace(/^#{1,6}\s+/gm, ''); - // Remove bold (**text** or __text__) - text = text.replace(/\*\*([^*]+)\*\*/g, '$1'); + + // Remove links but keep text + text = text.replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1'); + + // Remove headers + text = text.replace(/^#{1,6}\s+(.+)$/gm, '$1'); + + // Remove bold/italic + text = text.replace(/\*\*([^\*]+)\*\*/g, '$1'); + text = text.replace(/\*([^\*]+)\*/g, '$1'); text = text.replace(/__([^_]+)__/g, '$1'); - // Remove italic (*text* or _text_) - text = text.replace(/\*([^*]+)\*/g, '$1'); text = text.replace(/_([^_]+)_/g, '$1'); - // Remove strikethrough (~~text~~) + + // Remove strikethrough text = text.replace(/~~([^~]+)~~/g, '$1'); - // Remove blockquotes (> text) - text = text.replace(/^>\s+/gm, ''); - // Remove list markers (- * + or 1. 2. etc) - text = text.replace(/^[\s]*[-*+]\s+/gm, ''); - text = text.replace(/^\d+\.\s+/gm, ''); - // Remove horizontal rules (--- or ***) - text = text.replace(/^[-*]{3,}$/gm, ''); - // Replace newlines with spaces - text = text.replace(/\n/g, ' '); - // Remove extra whitespace - text = text.replace(/\s+/g, ' ').trim(); + + // Remove blockquotes + text = text.replace(/^>\s+(.+)$/gm, '$1'); + + // Remove horizontal rules + text = text.replace(/^---$/gm, ''); + text = text.replace(/^___$/gm, ''); + text = text.replace(/^\*\*\*$/gm, ''); + + // Remove list markers + text = text.replace(/^[\*\-\+]\s+(.+)$/gm, '$1'); + text = text.replace(/^\d+\.\s+(.+)$/gm, '$1'); + + // Clean up extra whitespace + text = text.replace(/\n{3,}/g, '\n\n'); + text = text.trim(); + return text; }