Browse Source

universal emoji picker

master
Silberengel 1 month ago
parent
commit
f47a894902
  1. 28
      src/app.css
  2. 339
      src/lib/components/content/EmojiDrawer.svelte
  3. 272
      src/lib/components/content/EmojiPicker.svelte
  4. 351
      src/lib/components/content/GifPicker.svelte
  5. 7
      src/lib/components/content/MarkdownRenderer.svelte
  6. 103
      src/lib/modules/comments/CommentForm.svelte
  7. 446
      src/lib/modules/reactions/FeedReactionButtons.svelte
  8. 159
      src/lib/services/nostr/gif-service.ts
  9. 18
      src/lib/services/nostr/nip30-emoji.ts
  10. 94
      src/lib/services/text-utils.ts

28
src/app.css

@ -115,23 +115,13 @@ img[src*="profile" i] { @@ -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 { @@ -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,

339
src/lib/components/content/EmojiDrawer.svelte

@ -0,0 +1,339 @@ @@ -0,0 +1,339 @@
<script lang="ts">
interface Props {
open: boolean;
title: string;
items: string[];
onSelect: (item: string) => void;
onClose: () => void;
searchPlaceholder?: string;
emptyMessage?: string;
searchQuery?: string;
onSearchChange?: (query: string) => void;
children?: import('svelte').Snippet;
}
let {
open,
title,
items,
onSelect,
onClose,
searchPlaceholder = "Search emojis...",
emptyMessage = "No emojis found",
searchQuery: externalSearchQuery,
onSearchChange,
children
}: Props = $props();
let searchInput: HTMLInputElement | null = $state(null);
let internalSearchQuery = $state('');
// Use external search query if provided, otherwise use internal
const currentSearchQuery = $derived(externalSearchQuery ?? internalSearchQuery);
function handleSearchInput(e: Event) {
const target = e.target as HTMLInputElement;
const value = target.value;
if (onSearchChange) {
onSearchChange(value);
} else if (externalSearchQuery !== undefined) {
// If external search query is provided, parent should handle updates
// For now, just use internal state
internalSearchQuery = value;
} else {
internalSearchQuery = value;
}
}
function handleItemSelect(item: string) {
onSelect(item);
onClose();
}
// Handle keyboard navigation
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
// Focus search input when drawer opens
$effect(() => {
if (open) {
setTimeout(() => {
if (searchInput) {
searchInput.focus();
}
}, 100);
} else {
// Reset search when closed
if (onSearchChange) {
onSearchChange('');
} else {
internalSearchQuery = '';
}
}
});
</script>
{#if open}
<div
class="drawer-backdrop"
onclick={onClose}
onkeydown={handleKeyDown}
role="button"
tabindex="0"
aria-label="Close {title.toLowerCase()}"
></div>
<div
class="emoji-drawer drawer-left"
onkeydown={handleKeyDown}
role="dialog"
aria-modal="true"
aria-label={title}
tabindex="-1"
>
<div class="drawer-header">
<h3 class="drawer-title">{title}</h3>
<button
onclick={onClose}
class="drawer-close"
aria-label="Close {title.toLowerCase()}"
title="Close"
>
×
</button>
</div>
<div class="emoji-search-container">
<input
bind:this={searchInput}
type="text"
placeholder={searchPlaceholder}
value={currentSearchQuery}
oninput={handleSearchInput}
class="emoji-search-input"
aria-label="Search emojis"
/>
</div>
<div class="emoji-drawer-content">
{#if children}
{@render children()}
{:else if items.length === 0}
<div class="emoji-empty">{emptyMessage}</div>
{:else}
<div class="emoji-grid">
{#each items as item}
<button
onclick={() => handleItemSelect(item)}
class="emoji-item"
title="Click to select"
>
{item}
</button>
{/each}
</div>
{/if}
</div>
</div>
{/if}
<style>
.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;
}
}
.emoji-drawer {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(400px, 80vw);
max-width: 400px;
background: var(--fog-post, #ffffff);
border-right: 2px solid var(--fog-border, #cbd5e1);
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2);
padding: 0;
z-index: 1000;
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideInLeft 0.3s ease-out;
transform: translateX(0);
/* Override global grayscale filter for entire drawer */
filter: none !important;
}
/* Override grayscale filters for all children in the drawer */
.emoji-drawer * {
filter: none !important;
}
:global(.dark) .emoji-drawer {
background: var(--fog-dark-post, #1f2937);
border-right-color: var(--fog-dark-border, #475569);
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.5);
}
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .drawer-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.drawer-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .drawer-title {
color: var(--fog-dark-text, #f9fafb);
}
.drawer-close {
background: transparent;
border: none;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--fog-text-light, #9ca3af);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition: all 0.2s;
}
.drawer-close:hover {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
}
:global(.dark) .drawer-close {
color: var(--fog-dark-text-light, #6b7280);
}
:global(.dark) .drawer-close:hover {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
@keyframes slideInLeft {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
.emoji-search-container {
padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .emoji-search-container {
border-bottom-color: var(--fog-dark-border, #374151);
}
.emoji-search-input {
width: 100%;
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;
transition: all 0.2s;
}
.emoji-search-input:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
background: var(--fog-post, #ffffff);
}
:global(.dark) .emoji-search-input {
background: var(--fog-dark-surface, #1e293b);
border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .emoji-search-input:focus {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-accent, #64748b);
}
.emoji-drawer-content {
overflow-y: auto;
overflow-x: hidden;
flex: 1;
padding: 1rem;
}
.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;
/* Override global grayscale filter for emojis in drawer */
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);
}
</style>

272
src/lib/components/content/EmojiPicker.svelte

@ -0,0 +1,272 @@ @@ -0,0 +1,272 @@
<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';
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);
// 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.log(`[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();
}
// 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-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>
{/snippet}
</EmojiDrawer>
<style>
.emoji-picker-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.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);
}
</style>

351
src/lib/components/content/GifPicker.svelte

@ -0,0 +1,351 @@ @@ -0,0 +1,351 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fetchGifs, searchGifs, type GifMetadata } from '../../services/nostr/gif-service.js';
interface Props {
open: boolean;
onSelect: (gifUrl: string) => void;
onClose: () => void;
}
let { open, onSelect, onClose }: Props = $props();
let gifs = $state<GifMetadata[]>([]);
let loading = $state(false);
let searchQuery = $state('');
let searchInput: HTMLInputElement | null = $state(null);
let selectedGif: GifMetadata | null = $state(null);
// Debounce search
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
async function loadGifs(query?: string) {
loading = true;
try {
if (query && query.trim()) {
gifs = await searchGifs(query.trim(), 50);
} else {
gifs = await fetchGifs(undefined, 50);
}
} catch (error) {
console.error('[GifPicker] Error loading GIFs:', error);
gifs = [];
} finally {
loading = false;
}
}
function handleSearchInput(e: Event) {
const target = e.target as HTMLInputElement;
searchQuery = target.value;
// Debounce search
if (searchTimeout) {
clearTimeout(searchTimeout);
}
searchTimeout = setTimeout(() => {
loadGifs(searchQuery);
}, 300);
}
function handleGifSelect(gif: GifMetadata) {
selectedGif = gif;
// Use fallback URL if available, otherwise use main URL
const url = gif.fallbackUrl || gif.url;
onSelect(url);
onClose();
}
// Load GIFs when panel opens
$effect(() => {
if (open) {
loadGifs();
// Focus search input after a short delay
setTimeout(() => {
if (searchInput) {
searchInput.focus();
}
}, 100);
} else {
// Reset when closed
searchQuery = '';
selectedGif = null;
}
});
// Handle keyboard navigation
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
</script>
{#if open}
<div
class="drawer-backdrop"
onclick={onClose}
onkeydown={handleKeyDown}
role="button"
tabindex="0"
aria-label="Close GIF picker"
></div>
<div class="gif-picker drawer-left" onkeydown={handleKeyDown} role="dialog" aria-label="GIF picker" tabindex="-1">
<div class="drawer-header">
<h3 class="drawer-title">Choose GIF</h3>
<button
onclick={onClose}
class="drawer-close"
aria-label="Close GIF picker"
title="Close"
>
×
</button>
</div>
<div class="gif-search-container">
<input
bind:this={searchInput}
type="text"
placeholder="Search GIFs..."
value={searchQuery}
oninput={handleSearchInput}
class="gif-search-input"
aria-label="Search GIFs"
/>
</div>
<div class="gif-picker-content">
{#if loading}
<div class="gif-loading">Loading GIFs...</div>
{:else if gifs.length === 0}
<div class="gif-empty">
{#if searchQuery}
No GIFs found for "{searchQuery}"
{:else}
No GIFs available
{/if}
</div>
{:else}
<div class="gif-grid">
{#each gifs as gif}
<button
onclick={() => handleGifSelect(gif)}
class="gif-item {selectedGif?.eventId === gif.eventId ? 'selected' : ''}"
title="Click to insert GIF"
>
<img
src={gif.url}
alt="GIF"
loading="lazy"
class="gif-thumbnail"
/>
</button>
{/each}
</div>
{/if}
</div>
</div>
{/if}
<style>
.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;
}
}
.gif-picker {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(500px, 85vw);
max-width: 500px;
background: var(--fog-post, #ffffff);
border-right: 2px solid var(--fog-border, #cbd5e1);
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2);
padding: 0;
z-index: 1000;
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideInLeft 0.3s ease-out;
transform: translateX(0);
}
:global(.dark) .gif-picker {
background: var(--fog-dark-post, #1f2937);
border-right-color: var(--fog-dark-border, #475569);
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.5);
}
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .drawer-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.drawer-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .drawer-title {
color: var(--fog-dark-text, #f9fafb);
}
.drawer-close {
background: transparent;
border: none;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--fog-text-light, #9ca3af);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition: all 0.2s;
}
.drawer-close:hover {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
}
:global(.dark) .drawer-close {
color: var(--fog-dark-text-light, #6b7280);
}
:global(.dark) .drawer-close:hover {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
@keyframes slideInLeft {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
.gif-search-container {
padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .gif-search-container {
border-bottom-color: var(--fog-dark-border, #374151);
}
.gif-search-input {
width: 100%;
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;
transition: all 0.2s;
}
.gif-search-input:focus {
outline: none;
border-color: var(--fog-accent, #64748b);
background: var(--fog-post, #ffffff);
}
:global(.dark) .gif-search-input {
background: var(--fog-dark-surface, #1e293b);
border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .gif-search-input:focus {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-accent, #64748b);
}
.gif-picker-content {
overflow-y: auto;
overflow-x: hidden;
flex: 1;
padding: 1rem;
}
.gif-loading,
.gif-empty {
text-align: center;
padding: 2rem;
color: var(--fog-text-light, #9ca3af);
font-size: 0.875rem;
}
:global(.dark) .gif-loading,
:global(.dark) .gif-empty {
color: var(--fog-dark-text-light, #6b7280);
}
.gif-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.5rem;
}
.gif-item {
position: relative;
padding: 0;
border: 2px solid transparent;
border-radius: 0.375rem;
background: transparent;
cursor: pointer;
transition: all 0.2s;
overflow: hidden;
aspect-ratio: 1;
}
.gif-item:hover {
border-color: var(--fog-accent, #64748b);
transform: scale(1.05);
}
.gif-item.selected {
border-color: var(--fog-accent, #64748b);
box-shadow: 0 0 0 2px var(--fog-accent, #64748b);
}
:global(.dark) .gif-item:hover,
:global(.dark) .gif-item.selected {
border-color: var(--fog-dark-accent, #64748b);
}
.gif-thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
</style>

7
src/lib/components/content/MarkdownRenderer.svelte

@ -441,12 +441,7 @@ @@ -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) {

103
src/lib/modules/comments/CommentForm.svelte

@ -5,6 +5,9 @@ @@ -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 @@ @@ -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,10 +161,25 @@ @@ -149,10 +161,25 @@
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;
}
</script>
<div class="comment-form">
<div class="textarea-wrapper">
<textarea
bind:this={textareaRef}
bind:value={content}
placeholder={parentEvent ? 'Write a reply...' : 'Write a comment...'}
class="w-full p-3 border border-fog-border dark:border-fog-dark-border rounded bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text"
@ -160,6 +187,32 @@ @@ -160,6 +187,32 @@
disabled={publishing}
></textarea>
{#if showGifButton}
<div class="textarea-buttons">
<button
type="button"
onclick={() => { showGifPicker = !showGifPicker; showEmojiPicker = false; }}
class="toolbar-button"
title="Insert GIF"
aria-label="Insert GIF"
disabled={publishing}
>
GIF
</button>
<button
type="button"
onclick={() => { showEmojiPicker = !showEmojiPicker; showGifPicker = false; }}
class="toolbar-button"
title="Insert emoji"
aria-label="Insert emoji"
disabled={publishing}
>
😀
</button>
</div>
{/if}
</div>
<div class="flex items-center justify-between mt-2">
<label class="flex items-center gap-2 text-sm text-fog-text-light dark:text-fog-dark-text-light opacity-70">
<input
@ -191,6 +244,11 @@ @@ -191,6 +244,11 @@
</div>
<PublicationStatusModal bind:open={showStatusModal} bind:results={publicationResults} />
{#if showGifButton}
<GifPicker open={showGifPicker} onSelect={handleGifSelect} onClose={() => showGifPicker = false} />
<EmojiPicker open={showEmojiPicker} onSelect={handleEmojiSelect} onClose={() => showEmojiPicker = false} />
{/if}
</div>
<style>
@ -198,6 +256,10 @@ @@ -198,6 +256,10 @@
margin-top: 1rem;
}
.textarea-wrapper {
position: relative;
}
textarea {
resize: vertical;
min-height: 100px;
@ -208,6 +270,47 @@ @@ -208,6 +270,47 @@
border-color: var(--fog-accent, #64748b);
}
.textarea-buttons {
position: absolute;
bottom: 0.5rem;
left: 0.5rem;
display: flex;
gap: 0.25rem;
z-index: 10;
}
.toolbar-button {
padding: 0.25rem 0.5rem;
font-size: 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;
}
.toolbar-button:hover:not(:disabled) {
background: var(--fog-highlight, #f3f4f6);
border-color: var(--fog-accent, #64748b);
}
.toolbar-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
:global(.dark) .toolbar-button {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .toolbar-button:hover:not(:disabled) {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-accent, #64748b);
}
.client-tag-checkbox {
opacity: 0.7;
cursor: pointer;

446
src/lib/modules/reactions/FeedReactionButtons.svelte

@ -5,9 +5,9 @@ @@ -5,9 +5,9 @@
import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import emojiData from 'unicode-emoji-json/data-ordered-emoji.json';
import emojiNames from 'unicode-emoji-json/data-by-emoji.json';
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';
interface Props {
event: NostrEvent;
@ -24,49 +24,10 @@ @@ -24,49 +24,10 @@
let showMenu = $state(false);
let menuButton: HTMLButtonElement | null = $state(null);
let customEmojiUrls = $state<Map<string, string>>(new Map());
let emojiSearchQuery = $state('');
let emojiSearchInput: HTMLInputElement | null = $state(null);
let allReactionsMap = $state<Map<string, NostrEvent>>(new Map()); // Persist reactions map for real-time updates
let heartCount = $derived(getReactionCount('+'));
const reactionMenu = $derived.by(() => {
const emojis: string[] = ['+'];
for (let i = 0; i < emojiData.length; i++) {
const emoji = emojiData[i];
if (typeof emoji === 'string' && emoji.trim()) {
emojis.push(emoji);
}
}
return emojis;
});
const filteredReactionMenu = $derived.by(() => {
if (!emojiSearchQuery.trim()) {
return reactionMenu;
}
const query = emojiSearchQuery.toLowerCase().trim();
return reactionMenu.filter(emoji => {
// For custom emojis (shortcodes), search the shortcode itself
if (emoji.startsWith(':') && emoji.endsWith(':')) {
return emoji.toLowerCase().includes(query);
}
// For regular emojis, search by name
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(query) || slug.includes(query)) {
return true;
}
}
// Fallback: search the emoji character itself (though this rarely matches)
return emoji.toLowerCase().includes(query);
});
});
let loadingReactions = $state(false);
let lastEventId = $state<string | null>(null);
@ -615,30 +576,29 @@ @@ -615,30 +576,29 @@
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(':') && !reactionMenu.includes(content)) {
if (content.startsWith(':') && content.endsWith(':') && !allUnicodeEmojis.has(content)) {
customEmojis.push(content);
}
}
return customEmojis.sort();
}
const filteredCustomEmojis = $derived.by(() => {
const customEmojis = getCustomEmojis();
if (!emojiSearchQuery.trim()) {
return customEmojis;
}
const query = emojiSearchQuery.toLowerCase().trim();
return customEmojis.filter(emoji => emoji.toLowerCase().includes(query));
});
function closeMenuOnOutsideClick(e: MouseEvent) {
const target = e.target as HTMLElement;
if (menuButton &&
!menuButton.contains(target) &&
!target.closest('.reaction-menu')) {
!target.closest('.emoji-drawer')) {
showMenu = false;
emojiSearchQuery = '';
}
}
@ -648,22 +608,9 @@ @@ -648,22 +608,9 @@
if (showMenu) {
showMenu = false;
emojiSearchQuery = '';
} else {
showMenu = true;
emojiSearchQuery = '';
// Focus the search input after menu opens
requestAnimationFrame(() => {
if (emojiSearchInput) {
emojiSearchInput.focus();
}
});
}
}
function handleSearchInput(e: Event) {
const target = e.target as HTMLInputElement;
emojiSearchQuery = target.value;
}
$effect(() => {
@ -671,12 +618,6 @@ @@ -671,12 +618,6 @@
// Prevent body scroll when drawer is open
document.body.style.overflow = 'hidden';
// Focus the search input when menu opens
requestAnimationFrame(() => {
if (emojiSearchInput) {
emojiSearchInput.focus();
}
});
const timeoutId = setTimeout(() => {
document.addEventListener('click', closeMenuOnOutsideClick, true);
@ -725,114 +666,14 @@ @@ -725,114 +666,14 @@
</button>
{#if showMenu}
<div
class="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>
<div class="reaction-menu drawer-left">
<div class="drawer-header">
<h3 class="drawer-title">Choose Reaction</h3>
<button
onclick={() => { showMenu = false; emojiSearchQuery = ''; }}
class="drawer-close"
aria-label="Close emoji menu"
title="Close"
>
×
</button>
</div>
<div class="emoji-search-container">
<input
bind:this={emojiSearchInput}
type="text"
placeholder="Search emojis..."
value={emojiSearchQuery}
oninput={handleSearchInput}
class="emoji-search-input"
aria-label="Search emojis"
/>
</div>
<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 reaction.startsWith(':') && reaction.endsWith(':')}
<!-- Text emoji - try to display as image if URL available -->
{#if hasEmojiUrl(reaction)}
{@const url = getCustomEmojiUrl(reaction)}
{#if url}
<img src={url} alt={reaction} class="custom-emoji-img" />
{:else}
<span class="text-emoji">{formatTextEmoji(reaction)}</span>
{/if}
{:else}
<span class="text-emoji">{formatTextEmoji(reaction)}</span>
{/if}
{:else}
{getReactionDisplay(reaction)}
{/if}
{#if count > 0}
<span class="reaction-count">{count}</span>
{/if}
</button>
{/each}
</div>
{#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={() => {
<EmojiPicker
open={showMenu}
onSelect={(emoji) => {
toggleReaction(emoji);
showMenu = false;
}}
class="reaction-menu-item {userReaction === emoji ? 'active' : ''}"
title={`React with ${emoji}`}
>
{#if hasEmojiUrl(emoji)}
{@const url = getCustomEmojiUrl(emoji)}
{#if url}
<img src={url} alt={emoji} class="custom-emoji-img" />
{:else}
<span class="text-emoji">{formatTextEmoji(emoji)}</span>
{/if}
{:else}
<span class="text-emoji">{formatTextEmoji(emoji)}</span>
{/if}
{#if count > 0}
<span class="reaction-count">{count}</span>
{/if}
</button>
{/each}
</div>
</div>
{/if}
</div>
</div>
{/if}
onClose={() => { showMenu = false; }}
/>
</div>
{#if event.kind !== 11}
@ -912,205 +753,6 @@ @@ -912,205 +753,6 @@
position: relative;
}
.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 {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(400px, 80vw);
max-width: 400px;
background: var(--fog-post, #ffffff);
border-right: 2px solid var(--fog-border, #cbd5e1);
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2);
padding: 0;
z-index: 1000;
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideInLeft 0.3s ease-out;
transform: translateX(0);
}
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .drawer-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.drawer-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .drawer-title {
color: var(--fog-dark-text, #f9fafb);
}
.drawer-close {
background: transparent;
border: none;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
color: var(--fog-text-light, #9ca3af);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
transition: all 0.2s;
}
.drawer-close:hover {
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
}
:global(.dark) .drawer-close {
color: var(--fog-dark-text-light, #6b7280);
}
:global(.dark) .drawer-close:hover {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
}
@keyframes slideInLeft {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
.reaction-menu-content {
overflow-y: auto;
overflow-x: hidden;
flex: 1;
margin-top: 0.5rem;
}
:global(.dark) .reaction-menu {
background: var(--fog-dark-post, #1f2937);
border-right-color: var(--fog-dark-border, #475569);
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.5);
}
.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;
filter: none !important;
}
.reaction-menu-item * {
filter: none !important;
}
.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, #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-img {
width: 1.25rem;
@ -1120,48 +762,6 @@ @@ -1120,48 +762,6 @@
vertical-align: middle;
}
.emoji-search-container {
margin: 0;
padding: 1rem;
padding-top: 0.75rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
display: block;
width: 100%;
}
:global(.dark) .emoji-search-container {
border-bottom-color: var(--fog-dark-border, #475569);
}
.emoji-search-input {
width: 100%;
padding: 0.625rem;
border: 1.5px solid var(--fog-border, #cbd5e1);
border-radius: 0.375rem;
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.15);
}
:global(.dark) .emoji-search-input {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #475569);
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.3);
}
.reaction-display {
display: inline-flex;
align-items: center;
@ -1226,14 +826,4 @@ @@ -1226,14 +826,4 @@
font-weight: 600; /* semi-bold */
}
@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>

159
src/lib/services/nostr/gif-service.ts

@ -0,0 +1,159 @@ @@ -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<GifMetadata[]> {
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<GifMetadata[]> {
return fetchGifs(query, limit);
}

18
src/lib/services/nostr/nip30-emoji.ts

@ -31,6 +31,15 @@ const emojiSetCache = new Map<string, EmojiSet>(); @@ -31,6 +31,15 @@ const emojiSetCache = new Map<string, EmojiSet>();
// Global shortcode -> URL cache (built from all emoji packs)
const shortcodeCache = new Map<string, string>();
/**
* 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<void> { @@ -144,15 +153,16 @@ export async function loadAllEmojiPacks(): Promise<void> {
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`);

94
src/lib/services/text-utils.ts

@ -1,39 +1,81 @@ @@ -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;
}

Loading…
Cancel
Save