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.
 
 
 
 
 

583 lines
16 KiB

<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>