|
|
<script lang="ts"> |
|
|
import emojiData from 'unicode-emoji-json/data-ordered-emoji.json'; |
|
|
import emojiNames from 'unicode-emoji-json/data-by-emoji.json'; |
|
|
import EmojiDrawer from './EmojiDrawer.svelte'; |
|
|
import { loadAllEmojiPacks, getAllCustomEmojis } from '../../services/nostr/nip30-emoji.js'; |
|
|
import { signAndPublish } from '../../services/nostr/auth-handler.js'; |
|
|
import { relayManager } from '../../services/nostr/relay-manager.js'; |
|
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
|
|
import { sessionManager } from '../../services/auth/session-manager.js'; |
|
|
import { KIND } from '../../types/kind-lookup.js'; |
|
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
|
|
|
|
interface Props { |
|
|
open: boolean; |
|
|
onSelect: (emoji: string) => void; |
|
|
onClose: () => void; |
|
|
} |
|
|
|
|
|
let { open, onSelect, onClose }: Props = $props(); |
|
|
|
|
|
let emojis = $state<string[]>([]); |
|
|
let customEmojis = $state<Array<{ shortcode: string; url: string }>>([]); |
|
|
let searchQuery = $state(''); |
|
|
let loadingCustomEmojis = $state(false); |
|
|
let uploading = $state(false); |
|
|
let uploadError: string | null = $state(null); |
|
|
let fileInput: HTMLInputElement | null = $state(null); |
|
|
let shortcodeInput: HTMLInputElement | null = $state(null); |
|
|
let showUploadForm = $state(false); |
|
|
|
|
|
// Check if user is logged in |
|
|
let isLoggedIn = $derived(sessionManager.isLoggedIn()); |
|
|
|
|
|
// Common emojis to show first |
|
|
const commonEmojis = ['❤️', '🫂','😀', '😂', '😍', '🥰', '😎', '🤔', '👍', '🔥', '✨', '🎉', '💯', '👏', '🙏', '😊', '😢', '😮', '😴', '🤗', '😋']; |
|
|
|
|
|
// Debounce search |
|
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null; |
|
|
|
|
|
function loadEmojis() { |
|
|
const allEmojis: string[] = []; |
|
|
|
|
|
// Add common emojis first |
|
|
allEmojis.push(...commonEmojis); |
|
|
|
|
|
// Add all other emojis |
|
|
for (let i = 0; i < emojiData.length; i++) { |
|
|
const emoji = emojiData[i]; |
|
|
if (typeof emoji === 'string' && emoji.trim() && !commonEmojis.includes(emoji)) { |
|
|
allEmojis.push(emoji); |
|
|
} |
|
|
} |
|
|
|
|
|
emojis = allEmojis; |
|
|
} |
|
|
|
|
|
async function loadCustomEmojis() { |
|
|
loadingCustomEmojis = true; |
|
|
try { |
|
|
await loadAllEmojiPacks(); |
|
|
const allEmojis = getAllCustomEmojis(); |
|
|
console.debug(`[EmojiPicker] Loaded ${allEmojis.length} custom emojis`); |
|
|
customEmojis = allEmojis; |
|
|
} catch (error) { |
|
|
console.error('Error loading custom emojis:', error); |
|
|
customEmojis = []; |
|
|
} finally { |
|
|
loadingCustomEmojis = false; |
|
|
} |
|
|
} |
|
|
|
|
|
function handleSearchChange(query: string) { |
|
|
searchQuery = query; |
|
|
|
|
|
// Filter emojis |
|
|
if (searchTimeout) { |
|
|
clearTimeout(searchTimeout); |
|
|
} |
|
|
searchTimeout = setTimeout(async () => { |
|
|
if (!searchQuery.trim()) { |
|
|
loadEmojis(); |
|
|
loadCustomEmojis(); |
|
|
return; |
|
|
} |
|
|
|
|
|
const queryLower = searchQuery.toLowerCase().trim(); |
|
|
|
|
|
// Filter Unicode emojis |
|
|
const allEmojis: string[] = []; |
|
|
allEmojis.push(...commonEmojis); |
|
|
for (let i = 0; i < emojiData.length; i++) { |
|
|
const emoji = emojiData[i]; |
|
|
if (typeof emoji === 'string' && emoji.trim() && !commonEmojis.includes(emoji)) { |
|
|
allEmojis.push(emoji); |
|
|
} |
|
|
} |
|
|
|
|
|
// Filter by emoji name |
|
|
const filtered = allEmojis.filter(emoji => { |
|
|
// Search by emoji name using emojiNames lookup |
|
|
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(queryLower) || slug.includes(queryLower)) { |
|
|
return true; |
|
|
} |
|
|
} |
|
|
|
|
|
// Fallback: search the emoji character itself (though this rarely matches) |
|
|
return emoji.toLowerCase().includes(queryLower); |
|
|
}); |
|
|
|
|
|
emojis = filtered; |
|
|
|
|
|
// Filter custom emojis by shortcode |
|
|
// Ensure custom emojis are loaded, then filter |
|
|
if (customEmojis.length === 0) { |
|
|
await loadCustomEmojis(); |
|
|
} |
|
|
|
|
|
// Filter by shortcode |
|
|
const allCustomEmojis = getAllCustomEmojis(); |
|
|
customEmojis = allCustomEmojis.filter(emoji => |
|
|
emoji.shortcode.toLowerCase().includes(queryLower) |
|
|
); |
|
|
}, 200); |
|
|
} |
|
|
|
|
|
function handleCustomEmojiSelect(shortcode: string) { |
|
|
onSelect(`:${shortcode}:`); |
|
|
onClose(); |
|
|
} |
|
|
|
|
|
// Convert file to data URL |
|
|
function fileToDataURL(file: File): Promise<string> { |
|
|
return new Promise((resolve, reject) => { |
|
|
const reader = new FileReader(); |
|
|
reader.onload = () => resolve(reader.result as string); |
|
|
reader.onerror = reject; |
|
|
reader.readAsDataURL(file); |
|
|
}); |
|
|
} |
|
|
|
|
|
// Handle emoji file upload |
|
|
async function handleEmojiUpload(e: Event) { |
|
|
const target = e.target as HTMLInputElement; |
|
|
const files = target.files; |
|
|
if (!files || files.length === 0) return; |
|
|
|
|
|
const session = sessionManager.getSession(); |
|
|
if (!session) { |
|
|
uploadError = 'Please log in to upload emojis'; |
|
|
return; |
|
|
} |
|
|
|
|
|
uploading = true; |
|
|
uploadError = null; |
|
|
|
|
|
try { |
|
|
const relays = relayManager.getPublishRelays( |
|
|
[...relayManager.getThreadReadRelays(), ...relayManager.getFeedReadRelays()], |
|
|
true |
|
|
); |
|
|
|
|
|
// Fetch existing emoji set to merge with new emojis |
|
|
const existingRelays = relayManager.getProfileReadRelays(); |
|
|
const existingEvents = await nostrClient.fetchEvents( |
|
|
[{ kinds: [KIND.EMOJI_SET], authors: [session.pubkey], limit: 1 }], |
|
|
existingRelays, |
|
|
{ useCache: true, cacheResults: true } |
|
|
); |
|
|
|
|
|
// Collect existing emoji tags |
|
|
const existingEmojiTags: string[][] = []; |
|
|
if (existingEvents.length > 0) { |
|
|
for (const tag of existingEvents[0].tags) { |
|
|
if (tag[0] === 'emoji' && tag[1] && tag[2]) { |
|
|
existingEmojiTags.push(tag); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
// Process each selected file |
|
|
const newEmojiTags: string[][] = []; |
|
|
const fileArray = Array.from(files); |
|
|
|
|
|
for (let i = 0; i < fileArray.length; i++) { |
|
|
const file = fileArray[i]; |
|
|
|
|
|
// Verify it's an image |
|
|
if (!file.type.startsWith('image/')) { |
|
|
uploadError = `${file.name} is not an image file`; |
|
|
continue; |
|
|
} |
|
|
|
|
|
// Get shortcode: use input if single file, otherwise use filename |
|
|
let shortcode = ''; |
|
|
if (fileArray.length === 1 && shortcodeInput && shortcodeInput.value.trim()) { |
|
|
shortcode = shortcodeInput.value.trim().toLowerCase().replace(/[^a-z0-9_+-]/g, '_'); |
|
|
} else { |
|
|
// Use filename without extension for each file |
|
|
shortcode = file.name.replace(/\.[^/.]+$/, '').toLowerCase().replace(/[^a-z0-9_+-]/g, '_'); |
|
|
} |
|
|
|
|
|
if (!shortcode) { |
|
|
uploadError = `Please provide a shortcode for ${file.name}`; |
|
|
continue; |
|
|
} |
|
|
|
|
|
// Check if shortcode already exists |
|
|
if (existingEmojiTags.some(tag => tag[1] === shortcode) || |
|
|
newEmojiTags.some(tag => tag[1] === shortcode)) { |
|
|
// Append number if duplicate |
|
|
let counter = 1; |
|
|
let uniqueShortcode = `${shortcode}_${counter}`; |
|
|
while (existingEmojiTags.some(tag => tag[1] === uniqueShortcode) || |
|
|
newEmojiTags.some(tag => tag[1] === uniqueShortcode)) { |
|
|
counter++; |
|
|
uniqueShortcode = `${shortcode}_${counter}`; |
|
|
} |
|
|
shortcode = uniqueShortcode; |
|
|
} |
|
|
|
|
|
// Convert to data URL |
|
|
const dataUrl = await fileToDataURL(file); |
|
|
|
|
|
// Add emoji tag |
|
|
newEmojiTags.push(['emoji', shortcode, dataUrl]); |
|
|
} |
|
|
|
|
|
if (newEmojiTags.length === 0) { |
|
|
uploadError = 'No valid emojis to upload'; |
|
|
return; |
|
|
} |
|
|
|
|
|
// Merge existing and new emoji tags |
|
|
const allEmojiTags = [...existingEmojiTags, ...newEmojiTags]; |
|
|
|
|
|
// Create or update kind 10030 emoji set event |
|
|
const event: Omit<NostrEvent, 'sig' | 'id'> = { |
|
|
kind: KIND.EMOJI_SET, |
|
|
pubkey: session.pubkey, |
|
|
created_at: existingEvents.length > 0 |
|
|
? existingEvents[0].created_at |
|
|
: Math.floor(Date.now() / 1000), |
|
|
tags: allEmojiTags, |
|
|
content: '' |
|
|
}; |
|
|
|
|
|
// Publish the event |
|
|
await signAndPublish(event, relays); |
|
|
|
|
|
// Reload custom emojis |
|
|
await loadCustomEmojis(); |
|
|
|
|
|
// Reset form |
|
|
if (fileInput) fileInput.value = ''; |
|
|
if (shortcodeInput) shortcodeInput.value = ''; |
|
|
showUploadForm = false; |
|
|
} catch (error) { |
|
|
console.error('Error uploading emoji:', error); |
|
|
uploadError = error instanceof Error ? error.message : 'Failed to upload emoji'; |
|
|
} finally { |
|
|
uploading = false; |
|
|
} |
|
|
} |
|
|
|
|
|
function triggerEmojiUpload() { |
|
|
if (showUploadForm) { |
|
|
fileInput?.click(); |
|
|
} else { |
|
|
showUploadForm = true; |
|
|
} |
|
|
} |
|
|
|
|
|
// Load emojis when panel opens |
|
|
$effect(() => { |
|
|
if (open) { |
|
|
loadEmojis(); |
|
|
loadCustomEmojis(); |
|
|
searchQuery = ''; |
|
|
} |
|
|
}); |
|
|
</script> |
|
|
|
|
|
<EmojiDrawer |
|
|
{open} |
|
|
title="Choose Emoji" |
|
|
items={emojis} |
|
|
onSelect={onSelect} |
|
|
{onClose} |
|
|
onSearchChange={handleSearchChange} |
|
|
> |
|
|
{#snippet children()} |
|
|
<div class="emoji-picker-wrapper"> |
|
|
<div class="emoji-picker-content"> |
|
|
{#if emojis.length === 0 && (!searchQuery.trim() || customEmojis.length === 0)} |
|
|
<div class="emoji-empty">No emojis found</div> |
|
|
{:else} |
|
|
{#if emojis.length > 0} |
|
|
<div class="emoji-grid"> |
|
|
{#each emojis as emoji} |
|
|
<button |
|
|
onclick={() => onSelect(emoji)} |
|
|
class="emoji-item" |
|
|
title="Click to insert emoji" |
|
|
> |
|
|
{emoji} |
|
|
</button> |
|
|
{/each} |
|
|
</div> |
|
|
{/if} |
|
|
|
|
|
{#if customEmojis.length > 0} |
|
|
<div class="custom-emojis-section"> |
|
|
<div class="custom-emojis-label">Custom</div> |
|
|
<div class="emoji-grid custom-emoji-grid"> |
|
|
{#each customEmojis as emoji} |
|
|
<button |
|
|
onclick={() => handleCustomEmojiSelect(emoji.shortcode)} |
|
|
class="emoji-item custom-emoji-item" |
|
|
title=":{emoji.shortcode}:" |
|
|
> |
|
|
<img src={emoji.url} alt={emoji.shortcode} class="custom-emoji-img" /> |
|
|
</button> |
|
|
{/each} |
|
|
</div> |
|
|
</div> |
|
|
{/if} |
|
|
|
|
|
{#if loadingCustomEmojis && !searchQuery.trim()} |
|
|
<div class="emoji-empty">Loading custom emojis...</div> |
|
|
{/if} |
|
|
{/if} |
|
|
</div> |
|
|
|
|
|
<!-- Bottom option --> |
|
|
{#if isLoggedIn} |
|
|
<div class="emoji-picker-footer"> |
|
|
{#if showUploadForm} |
|
|
<div class="emoji-upload-form"> |
|
|
<input |
|
|
bind:this={shortcodeInput} |
|
|
type="text" |
|
|
placeholder="Shortcode (e.g., myemoji)" |
|
|
class="emoji-shortcode-input" |
|
|
/> |
|
|
<button |
|
|
onclick={() => fileInput?.click()} |
|
|
class="emoji-footer-button" |
|
|
disabled={uploading} |
|
|
> |
|
|
{uploading ? 'Uploading...' : 'Select Image Files'} |
|
|
</button> |
|
|
<button |
|
|
onclick={() => { showUploadForm = false; uploadError = null; }} |
|
|
class="emoji-footer-button-close" |
|
|
> |
|
|
Cancel |
|
|
</button> |
|
|
<input |
|
|
bind:this={fileInput} |
|
|
type="file" |
|
|
accept="image/*" |
|
|
multiple |
|
|
onchange={handleEmojiUpload} |
|
|
style="display: none;" |
|
|
/> |
|
|
{#if uploadError} |
|
|
<div class="emoji-upload-error">{uploadError}</div> |
|
|
{/if} |
|
|
</div> |
|
|
{:else} |
|
|
<button |
|
|
onclick={triggerEmojiUpload} |
|
|
class="emoji-footer-button" |
|
|
> |
|
|
Add your own Emojis |
|
|
</button> |
|
|
{/if} |
|
|
</div> |
|
|
{/if} |
|
|
</div> |
|
|
{/snippet} |
|
|
</EmojiDrawer> |
|
|
|
|
|
<style> |
|
|
.emoji-picker-wrapper { |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
height: 100%; |
|
|
min-height: 0; |
|
|
} |
|
|
|
|
|
.emoji-picker-content { |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
gap: 0.75rem; |
|
|
flex: 1; |
|
|
overflow-y: auto; |
|
|
min-height: 0; |
|
|
} |
|
|
|
|
|
.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-grid { |
|
|
display: grid; |
|
|
grid-template-columns: repeat(auto-fill, minmax(3rem, 1fr)); |
|
|
gap: 0.5rem; |
|
|
} |
|
|
|
|
|
.custom-emoji-item { |
|
|
padding: 0.5rem; |
|
|
min-height: 3rem; |
|
|
} |
|
|
|
|
|
.custom-emoji-img { |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
object-fit: contain; |
|
|
filter: none !important; |
|
|
} |
|
|
|
|
|
.emoji-empty { |
|
|
text-align: center; |
|
|
padding: 2rem; |
|
|
color: var(--fog-text-light, #9ca3af); |
|
|
font-size: 0.875rem; |
|
|
} |
|
|
|
|
|
:global(.dark) .emoji-empty { |
|
|
color: var(--fog-dark-text-light, #6b7280); |
|
|
} |
|
|
|
|
|
.emoji-grid { |
|
|
display: grid; |
|
|
grid-template-columns: repeat(auto-fill, minmax(2.5rem, 1fr)); |
|
|
gap: 0.25rem; |
|
|
} |
|
|
|
|
|
.emoji-item { |
|
|
padding: 0.5rem; |
|
|
border: 1px solid transparent; |
|
|
border-radius: 0.25rem; |
|
|
background: transparent; |
|
|
cursor: pointer; |
|
|
transition: all 0.2s; |
|
|
font-size: 1.5rem; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
min-height: 2.5rem; |
|
|
filter: none !important; |
|
|
} |
|
|
|
|
|
.emoji-item:hover { |
|
|
background: var(--fog-highlight, #f3f4f6); |
|
|
border-color: var(--fog-border, #e5e7eb); |
|
|
} |
|
|
|
|
|
:global(.dark) .emoji-item:hover { |
|
|
background: var(--fog-dark-highlight, #374151); |
|
|
border-color: var(--fog-dark-border, #374151); |
|
|
} |
|
|
|
|
|
.emoji-picker-footer { |
|
|
padding: 1rem; |
|
|
border-top: 1px solid var(--fog-border, #e5e7eb); |
|
|
flex-shrink: 0; |
|
|
} |
|
|
|
|
|
:global(.dark) .emoji-picker-footer { |
|
|
border-top-color: var(--fog-dark-border, #374151); |
|
|
} |
|
|
|
|
|
.emoji-upload-form { |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
gap: 0.75rem; |
|
|
} |
|
|
|
|
|
.emoji-shortcode-input { |
|
|
padding: 0.5rem 0.75rem; |
|
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
|
border-radius: 0.375rem; |
|
|
background: var(--fog-surface, #f8fafc); |
|
|
color: var(--fog-text, #1f2937); |
|
|
font-size: 0.875rem; |
|
|
} |
|
|
|
|
|
.emoji-shortcode-input:focus { |
|
|
outline: none; |
|
|
border-color: var(--fog-accent, #64748b); |
|
|
background: var(--fog-post, #ffffff); |
|
|
} |
|
|
|
|
|
:global(.dark) .emoji-shortcode-input { |
|
|
background: var(--fog-dark-surface, #1e293b); |
|
|
border-color: var(--fog-dark-border, #374151); |
|
|
color: var(--fog-dark-text, #f9fafb); |
|
|
} |
|
|
|
|
|
:global(.dark) .emoji-shortcode-input:focus { |
|
|
background: var(--fog-dark-post, #1f2937); |
|
|
border-color: var(--fog-dark-accent, #64748b); |
|
|
} |
|
|
|
|
|
.emoji-footer-button { |
|
|
padding: 0.5rem 1rem; |
|
|
background: var(--fog-accent, #64748b); |
|
|
color: white; |
|
|
border: none; |
|
|
border-radius: 0.375rem; |
|
|
cursor: pointer; |
|
|
font-size: 0.875rem; |
|
|
transition: opacity 0.2s; |
|
|
} |
|
|
|
|
|
.emoji-footer-button:hover:not(:disabled) { |
|
|
opacity: 0.9; |
|
|
} |
|
|
|
|
|
.emoji-footer-button:disabled { |
|
|
opacity: 0.6; |
|
|
cursor: not-allowed; |
|
|
} |
|
|
|
|
|
.emoji-footer-button-close { |
|
|
padding: 0.5rem 1rem; |
|
|
background: var(--fog-highlight, #f3f4f6); |
|
|
color: var(--fog-text, #1f2937); |
|
|
border: none; |
|
|
border-radius: 0.375rem; |
|
|
cursor: pointer; |
|
|
font-size: 0.875rem; |
|
|
transition: all 0.2s; |
|
|
} |
|
|
|
|
|
.emoji-footer-button-close:hover { |
|
|
background: var(--fog-border, #e5e7eb); |
|
|
} |
|
|
|
|
|
:global(.dark) .emoji-footer-button-close { |
|
|
background: var(--fog-dark-highlight, #374151); |
|
|
color: var(--fog-dark-text, #f9fafb); |
|
|
} |
|
|
|
|
|
:global(.dark) .emoji-footer-button-close:hover { |
|
|
background: var(--fog-dark-border, #475569); |
|
|
} |
|
|
|
|
|
.emoji-upload-error { |
|
|
color: var(--fog-error, #dc2626); |
|
|
font-size: 0.75rem; |
|
|
text-align: center; |
|
|
} |
|
|
|
|
|
:global(.dark) .emoji-upload-error { |
|
|
color: var(--fog-dark-error, #ef4444); |
|
|
} |
|
|
</style>
|
|
|
|