Browse Source

bug-fixes

master
Silberengel 1 month ago
parent
commit
243861aed0
  1. 2
      src/lib/components/EventMenu.svelte
  2. 105
      src/lib/components/content/MarkdownRenderer.svelte
  3. 2
      src/lib/modules/comments/Comment.svelte
  4. 4
      src/lib/modules/feed/FeedPage.svelte
  5. 60
      src/lib/modules/feed/FeedPost.svelte
  6. 2
      src/lib/modules/feed/Reply.svelte
  7. 150
      src/lib/modules/reactions/FeedReactionButtons.svelte
  8. 160
      src/lib/services/nostr/nip30-emoji.ts
  9. 238
      src/lib/services/nostr/nostr-client.ts

2
src/lib/components/EventMenu.svelte

@ -295,7 +295,7 @@
{/if} {/if}
</div> </div>
<EventJsonModal bind:open={jsonModalOpen} bind:event /> <EventJsonModal bind:open={jsonModalOpen} event={event} />
<PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} /> <PublicationStatusModal bind:open={publicationModalOpen} bind:results={publicationResults} />
<style> <style>

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

@ -6,13 +6,17 @@
import ProfileBadge from '../layout/ProfileBadge.svelte'; import ProfileBadge from '../layout/ProfileBadge.svelte';
import EmbeddedEvent from './EmbeddedEvent.svelte'; import EmbeddedEvent from './EmbeddedEvent.svelte';
import { mountComponent } from './mount-component-action.js'; import { mountComponent } from './mount-component-action.js';
import { fetchEmojiSet, resolveEmojiShortcode } from '../../services/nostr/nip30-emoji.js';
import type { NostrEvent } from '../../types/nostr.js';
interface Props { interface Props {
content: string; content: string;
event?: NostrEvent; // Optional event for emoji resolution
} }
let { content }: Props = $props(); let { content, event }: Props = $props();
let containerRef = $state<HTMLElement | null>(null); let containerRef = $state<HTMLElement | null>(null);
let emojiUrls = $state<Map<string, string>>(new Map());
// Extract pubkey from npub or nprofile // Extract pubkey from npub or nprofile
function getPubkeyFromNIP21(parsed: ReturnType<typeof parseNIP21>): string | null { function getPubkeyFromNIP21(parsed: ReturnType<typeof parseNIP21>): string | null {
@ -120,10 +124,81 @@
return result; return result;
} }
// Resolve custom emojis in content
async function resolveContentEmojis(text: string): Promise<void> {
if (!event) return; // Need event to resolve emojis
// Find all :shortcode: patterns
const emojiPattern = /:([a-zA-Z0-9_+-]+):/g;
const matches: Array<{ shortcode: string; fullMatch: string }> = [];
let match;
while ((match = emojiPattern.exec(text)) !== null) {
const shortcode = match[1];
const fullMatch = match[0];
if (!matches.find(m => m.fullMatch === fullMatch)) {
matches.push({ shortcode, fullMatch });
}
}
if (matches.length === 0) return;
// Collect all pubkeys to check: event author and p tags
const pubkeysToCheck = new Set<string>();
pubkeysToCheck.add(event.pubkey);
for (const tag of event.tags) {
if (tag[0] === 'p' && tag[1]) {
pubkeysToCheck.add(tag[1]);
}
}
// Resolve emojis - first try specific pubkeys, then search broadly
const resolvedUrls = new Map<string, string>();
for (const { shortcode, fullMatch } of matches) {
// First try specific pubkeys (event author, p tags)
let url = await resolveEmojiShortcode(shortcode, Array.from(pubkeysToCheck), false);
// If not found, search broadly across all emoji packs
if (!url) {
url = await resolveEmojiShortcode(shortcode, [], true);
}
if (url) {
resolvedUrls.set(fullMatch, url);
}
}
emojiUrls = resolvedUrls;
}
// Replace emoji shortcodes with images in text
function replaceEmojis(text: string): string {
let processed = text;
// Replace from end to start to preserve indices
const sortedEntries = Array.from(emojiUrls.entries()).sort((a, b) => {
const indexA = processed.lastIndexOf(a[0]);
const indexB = processed.lastIndexOf(b[0]);
return indexB - indexA; // Sort by last index descending
});
for (const [shortcode, url] of sortedEntries) {
const escapedUrl = escapeHtml(url);
const escapedShortcode = escapeHtml(shortcode);
// Replace with img tag, preserving the shortcode as alt text
const imgTag = `<img src="${escapedUrl}" alt="${escapedShortcode}" class="emoji-inline" style="display: inline-block; width: 1.2em; height: 1.2em; vertical-align: middle;" />`;
processed = processed.replaceAll(shortcode, imgTag);
}
return processed;
}
// Process content: replace nostr URIs with HTML span elements and convert media URLs // Process content: replace nostr URIs with HTML span elements and convert media URLs
function processContent(text: string): string { function processContent(text: string): string {
// First, convert plain media URLs (images, videos, audio) to HTML tags // First, replace emoji shortcodes with images if resolved
let processed = convertMediaUrls(text); let processed = replaceEmojis(text);
// Then, convert plain media URLs (images, videos, audio) to HTML tags
processed = convertMediaUrls(processed);
// Find all NIP-21 links (nostr:npub, nostr:nprofile, nostr:nevent, etc.) // Find all NIP-21 links (nostr:npub, nostr:nprofile, nostr:nevent, etc.)
const links = findNIP21Links(processed); const links = findNIP21Links(processed);
@ -183,6 +258,18 @@
// HTML tags (like <img>) pass through by default in marked // HTML tags (like <img>) pass through by default in marked
}); });
// Resolve emojis when content or event changes
$effect(() => {
if (content && event) {
// Run async resolution without blocking
resolveContentEmojis(content).catch(err => {
console.warn('Error resolving content emojis:', err);
});
} else {
emojiUrls = new Map();
}
});
// Render markdown to HTML // Render markdown to HTML
function renderMarkdown(text: string): string { function renderMarkdown(text: string): string {
if (!content) return ''; if (!content) return '';
@ -350,6 +437,18 @@
filter: none !important; filter: none !important;
} }
:global(.markdown-content img.emoji-inline) {
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);
}
:global(.markdown-content video) { :global(.markdown-content video) {
max-width: 100%; max-width: 100%;
height: auto; height: auto;

2
src/lib/modules/comments/Comment.svelte

@ -92,7 +92,7 @@
</div> </div>
<div class="comment-content mb-2"> <div class="comment-content mb-2">
<MarkdownRenderer content={comment.content} /> <MarkdownRenderer content={comment.content} event={comment} />
</div> </div>
<div class="comment-actions flex gap-2 items-center"> <div class="comment-actions flex gap-2 items-center">

4
src/lib/modules/feed/FeedPage.svelte

@ -185,6 +185,7 @@
// Debounced update handler to prevent rapid re-renders // Debounced update handler to prevent rapid re-renders
function handleUpdate(updated: NostrEvent[]) { function handleUpdate(updated: NostrEvent[]) {
console.log(`[FeedPage] handleUpdate called with ${updated.length} events, current posts: ${posts.length}`);
pendingUpdates.push(...updated); pendingUpdates.push(...updated);
if (updateTimeout) { if (updateTimeout) {
@ -198,10 +199,13 @@
const existingIds = new Set(posts.map(p => p.id)); const existingIds = new Set(posts.map(p => p.id));
const newEvents = pendingUpdates.filter(e => !existingIds.has(e.id)); const newEvents = pendingUpdates.filter(e => !existingIds.has(e.id));
console.log(`[FeedPage] Processing ${newEvents.length} new events, existing: ${posts.length}`);
if (newEvents.length > 0) { if (newEvents.length > 0) {
// Merge and sort // Merge and sort
const merged = [...posts, ...newEvents]; const merged = [...posts, ...newEvents];
const sorted = merged.sort((a, b) => b.created_at - a.created_at); const sorted = merged.sort((a, b) => b.created_at - a.created_at);
console.log(`[FeedPage] Setting posts to ${sorted.length} events`);
posts = sorted; posts = sorted;
} }

60
src/lib/modules/feed/FeedPost.svelte

@ -448,36 +448,7 @@
<div class="post-content mb-2"> <div class="post-content mb-2">
<MediaAttachments event={post} /> <MediaAttachments event={post} />
<MarkdownRenderer content={post.content} /> <MarkdownRenderer content={post.content} event={post} />
</div>
<div class="post-actions flex flex-wrap items-center gap-2 sm:gap-4">
{#if post.kind === 11}
<!-- Show vote counts for threads -->
{#if upvotes > 0 || downvotes > 0}
<span class="vote-counts text-xs text-fog-text-light dark:text-fog-dark-text-light">
<span class="upvotes"> {upvotes}</span>
{#if downvotes > 0}
<span class="downvotes ml-2"> {downvotes}</span>
{/if}
</span>
{/if}
{/if}
{#if zapCount > 0}
<span class="zap-count-display">
<span class="zap-emoji"></span>
<span class="zap-count-number">{zapCount}</span>
</span>
{/if}
<FeedReactionButtons event={post} />
{#if onReply}
<button
onclick={() => onReply(post)}
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline"
>
Reply
</button>
{/if}
</div> </div>
</div> </div>
@ -489,6 +460,35 @@
{expanded ? 'Show less' : 'Show more'} {expanded ? 'Show less' : 'Show more'}
</button> </button>
{/if} {/if}
<div class="post-actions flex flex-wrap items-center gap-2 sm:gap-4">
{#if post.kind === 11}
<!-- Show vote counts for threads -->
{#if upvotes > 0 || downvotes > 0}
<span class="vote-counts text-xs text-fog-text-light dark:text-fog-dark-text-light">
<span class="upvotes"> {upvotes}</span>
{#if downvotes > 0}
<span class="downvotes ml-2"> {downvotes}</span>
{/if}
</span>
{/if}
{/if}
{#if zapCount > 0}
<span class="zap-count-display">
<span class="zap-emoji"></span>
<span class="zap-count-number">{zapCount}</span>
</span>
{/if}
<FeedReactionButtons event={post} />
{#if onReply}
<button
onclick={() => onReply(post)}
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline"
>
Reply
</button>
{/if}
</div>
<div class="kind-badge"> <div class="kind-badge">
<span class="kind-number">{getKindInfo(post.kind).number}</span> <span class="kind-number">{getKindInfo(post.kind).number}</span>

2
src/lib/modules/feed/Reply.svelte

@ -97,7 +97,7 @@
</div> </div>
<div class="reply-content mb-2"> <div class="reply-content mb-2">
<MarkdownRenderer content={reply.content} /> <MarkdownRenderer content={reply.content} event={reply} />
</div> </div>
<div class="reply-actions flex items-center gap-4"> <div class="reply-actions flex items-center gap-4">

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

@ -7,7 +7,7 @@
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import emojiData from 'unicode-emoji-json/data-ordered-emoji.json'; import emojiData from 'unicode-emoji-json/data-ordered-emoji.json';
import emojiNames from 'unicode-emoji-json/data-by-emoji.json'; import emojiNames from 'unicode-emoji-json/data-by-emoji.json';
import { resolveCustomEmojis } from '../../services/nostr/nip30-emoji.js'; import { resolveCustomEmojis, fetchEmojiSet, resolveEmojiShortcode } from '../../services/nostr/nip30-emoji.js';
interface Props { interface Props {
event: NostrEvent; event: NostrEvent;
@ -24,6 +24,7 @@
let menuButton: HTMLButtonElement | null = $state(null); let menuButton: HTMLButtonElement | null = $state(null);
let customEmojiUrls = $state<Map<string, string>>(new Map()); let customEmojiUrls = $state<Map<string, string>>(new Map());
let emojiSearchQuery = $state(''); 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 allReactionsMap = $state<Map<string, NostrEvent>>(new Map()); // Persist reactions map for real-time updates
let heartCount = $derived(getReactionCount('+')); let heartCount = $derived(getReactionCount('+'));
@ -314,8 +315,67 @@
}); });
reactions = reactionMap; reactions = reactionMap;
// Resolve custom emojis - include event author's pubkey as they may have defined emojis
// First resolve with original function (reactors' emojis), then enhance with event author
const emojiUrls = await resolveCustomEmojis(reactionMap); const emojiUrls = await resolveCustomEmojis(reactionMap);
customEmojiUrls = emojiUrls;
// Collect all pubkeys to check: event author, reactors, and p tags from the event
const pubkeysToCheck = new Set<string>();
pubkeysToCheck.add(event.pubkey); // Event author
for (const { pubkeys } of reactionMap.values()) {
for (const pubkey of pubkeys) {
pubkeysToCheck.add(pubkey);
}
}
// Also check p tags from the event (mentioned users might have emoji sets)
for (const tag of event.tags) {
if (tag[0] === 'p' && tag[1]) {
pubkeysToCheck.add(tag[1]);
}
}
// Check all collected pubkeys for unresolved text emojis
const unresolvedTextEmojis = Array.from(reactionMap.keys()).filter(
c => c.startsWith(':') && c.endsWith(':') && !emojiUrls.has(c)
);
if (unresolvedTextEmojis.length > 0) {
// Fetch emoji sets for all pubkeys in parallel
const emojiSetPromises = Array.from(pubkeysToCheck).map(pubkey =>
fetchEmojiSet(pubkey).catch(() => null)
);
const emojiSets = await Promise.all(emojiSetPromises);
// Try to resolve each unresolved emoji
for (const content of unresolvedTextEmojis) {
if (emojiUrls.has(content)) continue; // Already resolved
const shortcode = content.slice(1, -1); // Remove colons
// First check each emoji set from collected pubkeys
let found = false;
for (const emojiSet of emojiSets) {
if (emojiSet?.emojis.has(shortcode)) {
const emojiDef = emojiSet.emojis.get(shortcode)!;
emojiUrls.set(content, emojiDef.url);
found = true;
break; // Found it, move to next emoji
}
}
// If not found in specific pubkeys, search broadly
if (!found) {
const url = await resolveEmojiShortcode(shortcode, [], true);
if (url) {
emojiUrls.set(content, url);
}
}
}
}
// Update state - create new Map to ensure reactivity
customEmojiUrls = new Map(emojiUrls);
} }
async function toggleReaction(content: string) { async function toggleReaction(content: string) {
@ -466,12 +526,35 @@
if (content.startsWith(':') && content.endsWith(':')) { if (content.startsWith(':') && content.endsWith(':')) {
const url = customEmojiUrls.get(content); const url = customEmojiUrls.get(content);
if (url) { if (url) {
return content; return content; // Custom emoji with URL - will be rendered as image
} }
// Text emoji without URL - return the shortcode for display
return content;
} }
return content; return content;
} }
function formatTextEmoji(content: string): string {
// Format text emojis like ":clap:" to "Clap" for display (fallback when no image URL)
if (content.startsWith(':') && content.endsWith(':')) {
const shortcode = content.slice(1, -1); // Remove colons
// Capitalize first letter
return shortcode.charAt(0).toUpperCase() + shortcode.slice(1);
}
return content;
}
// Make this reactive so it updates when customEmojiUrls changes
function hasEmojiUrl(content: string): boolean {
// Check if this text emoji has a resolved URL
// Access customEmojiUrls to make this reactive
const urls = customEmojiUrls;
if (content.startsWith(':') && content.endsWith(':')) {
return urls.has(content);
}
return false;
}
function isCustomEmoji(content: string): boolean { function isCustomEmoji(content: string): boolean {
return content.startsWith(':') && content.endsWith(':') && customEmojiUrls.has(content); return content.startsWith(':') && content.endsWith(':') && customEmojiUrls.has(content);
} }
@ -536,6 +619,12 @@
} else { } else {
showMenu = true; showMenu = true;
emojiSearchQuery = ''; emojiSearchQuery = '';
// Focus the search input after menu opens
requestAnimationFrame(() => {
if (emojiSearchInput) {
emojiSearchInput.focus();
}
});
} }
} }
@ -549,6 +638,13 @@
// Prevent body scroll when drawer is open // Prevent body scroll when drawer is open
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
// Focus the search input when menu opens
requestAnimationFrame(() => {
if (emojiSearchInput) {
emojiSearchInput.focus();
}
});
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
document.addEventListener('click', closeMenuOnOutsideClick, true); document.addEventListener('click', closeMenuOnOutsideClick, true);
}, 0); }, 0);
@ -625,6 +721,7 @@
</div> </div>
<div class="emoji-search-container"> <div class="emoji-search-container">
<input <input
bind:this={emojiSearchInput}
type="text" type="text"
placeholder="Search emojis..." placeholder="Search emojis..."
value={emojiSearchQuery} value={emojiSearchQuery}
@ -646,12 +743,17 @@
class="reaction-menu-item {userReaction === reaction ? 'active' : ''}" class="reaction-menu-item {userReaction === reaction ? 'active' : ''}"
title={reaction === '+' ? 'Like' : `React with ${getReactionDisplay(reaction)}`} title={reaction === '+' ? 'Like' : `React with ${getReactionDisplay(reaction)}`}
> >
{#if isCustomEmoji(reaction)} {#if reaction.startsWith(':') && reaction.endsWith(':')}
{@const url = getCustomEmojiUrl(reaction)} <!-- Text emoji - try to display as image if URL available -->
{#if url} {#if hasEmojiUrl(reaction)}
<img src={url} alt={reaction} class="custom-emoji-img" /> {@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} {:else}
{reaction} <span class="text-emoji">{formatTextEmoji(reaction)}</span>
{/if} {/if}
{:else} {:else}
{getReactionDisplay(reaction)} {getReactionDisplay(reaction)}
@ -677,15 +779,15 @@
class="reaction-menu-item {userReaction === emoji ? 'active' : ''}" class="reaction-menu-item {userReaction === emoji ? 'active' : ''}"
title={`React with ${emoji}`} title={`React with ${emoji}`}
> >
{#if isCustomEmoji(emoji)} {#if hasEmojiUrl(emoji)}
{@const url = getCustomEmojiUrl(emoji)} {@const url = getCustomEmojiUrl(emoji)}
{#if url} {#if url}
<img src={url} alt={emoji} class="custom-emoji-img" /> <img src={url} alt={emoji} class="custom-emoji-img" />
{:else} {:else}
{emoji} <span class="text-emoji">{formatTextEmoji(emoji)}</span>
{/if} {/if}
{:else} {:else}
{emoji} <span class="text-emoji">{formatTextEmoji(emoji)}</span>
{/if} {/if}
{#if count > 0} {#if count > 0}
<span class="reaction-count">{count}</span> <span class="reaction-count">{count}</span>
@ -708,12 +810,17 @@
> >
{#if content === '+'} {#if content === '+'}
{:else if isCustomEmoji(content)} {:else if content.startsWith(':') && content.endsWith(':')}
{@const url = getCustomEmojiUrl(content)} <!-- Text emoji - try to display as image if URL available -->
{#if url} {#if hasEmojiUrl(content)}
<img src={url} alt={content} class="custom-emoji-img" /> {@const url = getCustomEmojiUrl(content)}
{#if url}
<img src={url} alt={content} class="custom-emoji-img" />
{:else}
<span class="text-emoji">{formatTextEmoji(content)}</span>
{/if}
{:else} {:else}
{content} <span class="text-emoji">{formatTextEmoji(content)}</span>
{/if} {/if}
{:else} {:else}
{content} {content}
@ -1050,6 +1157,17 @@
color: var(--fog-dark-text-light, #9ca3af); color: var(--fog-dark-text-light, #9ca3af);
} }
.text-emoji {
font-size: 0.875rem;
font-weight: 500;
color: var(--fog-text, #1f2937);
text-transform: capitalize;
}
:global(.dark) .text-emoji {
color: var(--fog-dark-text, #f9fafb);
}
.vote-btn { .vote-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

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

@ -28,6 +28,13 @@ export interface EmojiSet {
// Cache of emoji sets by pubkey // Cache of emoji sets by pubkey
const emojiSetCache = new Map<string, EmojiSet>(); const emojiSetCache = new Map<string, EmojiSet>();
// Global shortcode -> URL cache (built from all emoji packs)
const shortcodeCache = new Map<string, string>();
// Track if we've loaded all emoji packs
let allEmojiPacksLoaded = false;
let loadingEmojiPacks = false;
/** /**
* Parse a kind 10030 emoji set event or kind 30030 emoji pack * Parse a kind 10030 emoji set event or kind 30030 emoji pack
*/ */
@ -55,6 +62,7 @@ export function parseEmojiSet(event: NostrEvent): EmojiSet | null {
/** /**
* Fetch emoji set for a pubkey * Fetch emoji set for a pubkey
* Merges all emoji sets (kind 10030) and emoji packs (kind 30030) together
*/ */
export async function fetchEmojiSet(pubkey: string): Promise<EmojiSet | null> { export async function fetchEmojiSet(pubkey: string): Promise<EmojiSet | null> {
// Check cache first // Check cache first
@ -65,22 +73,53 @@ export async function fetchEmojiSet(pubkey: string): Promise<EmojiSet | null> {
try { try {
const relays = relayManager.getFeedReadRelays(); const relays = relayManager.getFeedReadRelays();
// Fetch both emoji sets (10030) and emoji packs (30030) // Fetch both emoji sets (10030) and emoji packs (30030)
// Get more events to capture all packs (30030 can have multiple with different d tags)
const events = await nostrClient.fetchEvents( const events = await nostrClient.fetchEvents(
[{ kinds: [10030, 30030], authors: [pubkey], limit: 10 }], // Get multiple in case of packs [{ kinds: [10030, 30030], authors: [pubkey], limit: 50 }], // Get more to capture all packs
relays, relays,
{ useCache: true, cacheResults: true, timeout: 5000 } { useCache: true, cacheResults: true, timeout: 5000 }
); );
if (events.length === 0) return null; if (events.length === 0) return null;
// Get the most recent event (replaceable events) // Separate emoji sets (10030) and emoji packs (30030)
const event = events.sort((a, b) => b.created_at - a.created_at)[0]; const emojiSetEvents = events.filter(e => e.kind === 10030);
const emojiSet = parseEmojiSet(event); const emojiPackEvents = events.filter(e => e.kind === 30030);
if (emojiSet) { const allEmojis = new Map<string, EmojiDefinition>();
emojiSetCache.set(pubkey, emojiSet);
// For kind 10030 (emoji sets): take the most recent (replaceable)
if (emojiSetEvents.length > 0) {
const mostRecentSet = emojiSetEvents.sort((a, b) => b.created_at - a.created_at)[0];
const parsedSet = parseEmojiSet(mostRecentSet);
if (parsedSet) {
// Merge emojis from the set
for (const [shortcode, def] of parsedSet.emojis.entries()) {
allEmojis.set(shortcode, def);
}
}
}
// For kind 30030 (emoji packs): merge ALL packs together
// Packs are parameterized with d tags, so a user can have multiple packs
for (const packEvent of emojiPackEvents) {
const parsedPack = parseEmojiSet(packEvent);
if (parsedPack) {
// Merge emojis from this pack (later packs override earlier ones for same shortcode)
for (const [shortcode, def] of parsedPack.emojis.entries()) {
allEmojis.set(shortcode, def);
}
}
} }
if (allEmojis.size === 0) return null;
const emojiSet: EmojiSet = {
pubkey,
emojis: allEmojis
};
emojiSetCache.set(pubkey, emojiSet);
return emojiSet; return emojiSet;
} catch (error) { } catch (error) {
console.error('Error fetching emoji set:', error); console.error('Error fetching emoji set:', error);
@ -88,18 +127,97 @@ export async function fetchEmojiSet(pubkey: string): Promise<EmojiSet | null> {
} }
} }
/**
* Fetch and cache all emoji packs/sets from relays
* This builds up a global cache of all available emojis
*/
export async function loadAllEmojiPacks(): Promise<void> {
// Prevent concurrent loads
if (loadingEmojiPacks) {
return;
}
if (allEmojiPacksLoaded) {
return;
}
loadingEmojiPacks = true;
try {
const relays = relayManager.getFeedReadRelays();
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
const events = await nostrClient.fetchEvents(
[{ kinds: [10030, 30030], limit: 500 }], // Get all emoji packs/sets
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
);
console.log(`[nip30-emoji] Found ${events.length} emoji pack/set events`);
// Process and cache all emoji sets/packs
// Track shortcode -> (url, created_at) to prefer most recent
const shortcodeToUrlAndTime = new Map<string, { url: string; created_at: number }>();
const emojiSetsByPubkey = new Map<string, EmojiSet>();
// Group events by pubkey and merge packs
for (const event of events) {
const parsed = parseEmojiSet(event);
if (!parsed) continue;
if (emojiSetsByPubkey.has(parsed.pubkey)) {
// Merge with existing set for this pubkey
const existing = emojiSetsByPubkey.get(parsed.pubkey)!;
for (const [shortcode, def] of parsed.emojis.entries()) {
existing.emojis.set(shortcode, def);
}
} else {
emojiSetsByPubkey.set(parsed.pubkey, parsed);
}
// Track shortcodes with their creation time to prefer most recent
for (const [shortcode, def] of parsed.emojis.entries()) {
const existing = shortcodeToUrlAndTime.get(shortcode);
if (!existing || event.created_at > existing.created_at) {
shortcodeToUrlAndTime.set(shortcode, { url: def.url, created_at: event.created_at });
}
}
}
// Cache all found emoji sets by pubkey
for (const [pubkey, emojiSet] of emojiSetsByPubkey.entries()) {
emojiSetCache.set(pubkey, emojiSet);
}
// Build shortcode -> URL cache (preferring most recent)
for (const [shortcode, { url }] of shortcodeToUrlAndTime.entries()) {
shortcodeCache.set(shortcode, url);
}
console.log(`[nip30-emoji] Cached ${emojiSetsByPubkey.size} emoji sets with ${shortcodeCache.size} unique shortcodes`);
allEmojiPacksLoaded = true;
} catch (error) {
console.error('Error loading all emoji packs:', error);
} finally {
loadingEmojiPacks = false;
}
}
/** /**
* Resolve a shortcode to an image URL * Resolve a shortcode to an image URL
* Tries to find the emoji in emoji sets from the given pubkeys * First checks specific pubkeys, then checks the global shortcode cache
*/ */
export async function resolveEmojiShortcode( export async function resolveEmojiShortcode(
shortcode: string, shortcode: string,
pubkeys: string[] = [] pubkeys: string[] = [],
searchBroadly: boolean = true
): Promise<string | null> { ): Promise<string | null> {
// Remove colons if present // Remove colons if present
const cleanShortcode = shortcode.replace(/^:|:$/g, ''); const cleanShortcode = shortcode.replace(/^:|:$/g, '');
// Try each pubkey's emoji set // Try each pubkey's emoji set first (these take priority)
for (const pubkey of pubkeys) { for (const pubkey of pubkeys) {
const emojiSet = await fetchEmojiSet(pubkey); const emojiSet = await fetchEmojiSet(pubkey);
if (emojiSet?.emojis.has(cleanShortcode)) { if (emojiSet?.emojis.has(cleanShortcode)) {
@ -107,6 +225,16 @@ export async function resolveEmojiShortcode(
} }
} }
// If not found in specific pubkeys, ensure all packs are loaded
if (searchBroadly && !allEmojiPacksLoaded) {
await loadAllEmojiPacks();
}
// Check the global shortcode cache
if (shortcodeCache.has(cleanShortcode)) {
return shortcodeCache.get(cleanShortcode)!;
}
return null; return null;
} }
@ -136,6 +264,11 @@ export async function resolveCustomEmojis(
const emojiSetPromises = pubkeys.map(pubkey => fetchEmojiSet(pubkey)); const emojiSetPromises = pubkeys.map(pubkey => fetchEmojiSet(pubkey));
await Promise.all(emojiSetPromises); await Promise.all(emojiSetPromises);
// Ensure all emoji packs are loaded for broader search
if (!allEmojiPacksLoaded) {
await loadAllEmojiPacks();
}
// Build map of shortcode -> URL // Build map of shortcode -> URL
const emojiMap = new Map<string, string>(); const emojiMap = new Map<string, string>();
@ -143,14 +276,21 @@ export async function resolveCustomEmojis(
if (content.startsWith(':') && content.endsWith(':')) { if (content.startsWith(':') && content.endsWith(':')) {
const shortcode = content.slice(1, -1); // Remove colons const shortcode = content.slice(1, -1); // Remove colons
// Try to find in any emoji set // First try to find in specific pubkey emoji sets (priority)
let found = false;
for (const pubkey of pubkeys) { for (const pubkey of pubkeys) {
const emojiSet = emojiSetCache.get(pubkey); const emojiSet = emojiSetCache.get(pubkey);
if (emojiSet?.emojis.has(shortcode)) { if (emojiSet?.emojis.has(shortcode)) {
emojiMap.set(content, emojiSet.emojis.get(shortcode)!.url); emojiMap.set(content, emojiSet.emojis.get(shortcode)!.url);
found = true;
break; break;
} }
} }
// If not found in specific pubkeys, check global cache
if (!found && shortcodeCache.has(shortcode)) {
emojiMap.set(content, shortcodeCache.get(shortcode)!);
}
} }
} }

238
src/lib/services/nostr/nostr-client.ts

@ -7,6 +7,7 @@ import { Relay, type Filter, matchFilter } from 'nostr-tools';
import { config } from './config.js'; import { config } from './config.js';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { cacheEvent, cacheEvents, getEvent, getEventsByKind, getEventsByPubkey } from '../cache/event-cache.js'; import { cacheEvent, cacheEvents, getEvent, getEventsByKind, getEventsByPubkey } from '../cache/event-cache.js';
import { getDB } from '../cache/indexeddb-store.js';
import { filterEvents, shouldHideEvent } from '../event-filter.js'; import { filterEvents, shouldHideEvent } from '../event-filter.js';
export interface PublishOptions { export interface PublishOptions {
@ -37,6 +38,12 @@ class NostrClient {
private readonly MAX_CONCURRENT_PER_RELAY = 1; // Only 1 concurrent request per relay private readonly MAX_CONCURRENT_PER_RELAY = 1; // Only 1 concurrent request per relay
private readonly MAX_CONCURRENT_TOTAL = 3; // Max 3 total concurrent requests private readonly MAX_CONCURRENT_TOTAL = 3; // Max 3 total concurrent requests
private totalActiveRequests = 0; private totalActiveRequests = 0;
// Failed relay tracking with exponential backoff
private failedRelays: Map<string, { lastFailure: number; retryAfter: number; failureCount: number }> = new Map();
private readonly INITIAL_RETRY_DELAY = 5000; // 5 seconds
private readonly MAX_RETRY_DELAY = 300000; // 5 minutes
private readonly MAX_FAILURE_COUNT = 10; // After 10 failures, wait max delay
async initialize(): Promise<void> { async initialize(): Promise<void> {
if (this.initialized) return; if (this.initialized) return;
@ -61,10 +68,40 @@ class NostrClient {
async addRelay(url: string): Promise<void> { async addRelay(url: string): Promise<void> {
if (this.relays.has(url)) return; if (this.relays.has(url)) return;
// Check if this relay has failed recently and we should wait
const failureInfo = this.failedRelays.get(url);
if (failureInfo) {
const timeSinceFailure = Date.now() - failureInfo.lastFailure;
if (timeSinceFailure < failureInfo.retryAfter) {
const waitTime = failureInfo.retryAfter - timeSinceFailure;
console.log(`[nostr-client] Relay ${url} failed recently, waiting ${Math.round(waitTime / 1000)}s before retry`);
throw new Error(`Relay failed recently, retry after ${Math.round(waitTime / 1000)}s`);
}
}
try { try {
const relay = await Relay.connect(url); const relay = await Relay.connect(url);
this.relays.set(url, relay); this.relays.set(url, relay);
// Clear failure tracking on successful connection
this.failedRelays.delete(url);
console.log(`[nostr-client] Successfully connected to relay: ${url}`);
} catch (error) { } catch (error) {
// Track the failure
const existingFailure = this.failedRelays.get(url) || { lastFailure: 0, retryAfter: this.INITIAL_RETRY_DELAY, failureCount: 0 };
const failureCount = existingFailure.failureCount + 1;
const retryAfter = Math.min(
this.INITIAL_RETRY_DELAY * Math.pow(2, Math.min(failureCount - 1, 6)), // Exponential backoff, max 2^6 = 64x
this.MAX_RETRY_DELAY
);
this.failedRelays.set(url, {
lastFailure: Date.now(),
retryAfter,
failureCount
});
console.warn(`[nostr-client] Failed to connect to relay ${url} (failure #${failureCount}), will retry after ${Math.round(retryAfter / 1000)}s`);
throw error; throw error;
} }
} }
@ -114,33 +151,136 @@ class NostrClient {
for (const filter of filters) { for (const filter of filters) {
try { try {
if (filter.kinds && filter.kinds.length === 1) { let candidateEvents: NostrEvent[] = [];
const events = await getEventsByKind(filter.kinds[0], filter.limit || 50);
for (const event of events) { // Query by kind(s) if specified
if (seen.has(event.id)) continue; if (filter.kinds && filter.kinds.length > 0) {
if (matchFilter(filter, event)) { // If single kind, use index for efficiency
results.push(event); if (filter.kinds.length === 1) {
seen.add(event.id); candidateEvents = await getEventsByKind(filter.kinds[0], (filter.limit || 100) * 3);
} else {
// Multiple kinds - query each and combine
const allEvents: NostrEvent[] = [];
for (const kind of filter.kinds) {
const kindEvents = await getEventsByKind(kind, (filter.limit || 100) * 3);
allEvents.push(...kindEvents);
} }
candidateEvents = allEvents;
} }
} } else if (filter.authors && filter.authors.length > 0) {
if (filter.authors && filter.authors.length === 1) { // Query by author(s) if no kinds specified
const events = await getEventsByPubkey(filter.authors[0], filter.limit || 50); if (filter.authors.length === 1) {
for (const event of events) { candidateEvents = await getEventsByPubkey(filter.authors[0], (filter.limit || 100) * 3);
if (seen.has(event.id)) continue; } else {
if (matchFilter(filter, event)) { // Multiple authors - query each and combine
results.push(event); const allEvents: NostrEvent[] = [];
seen.add(event.id); for (const author of filter.authors) {
const authorEvents = await getEventsByPubkey(author, (filter.limit || 100) * 3);
allEvents.push(...authorEvents);
} }
candidateEvents = allEvents;
}
} else {
// No specific kind or author - get recent events by created_at
// This is a fallback for broad queries
try {
const db = await getDB();
const tx = db.transaction('events', 'readonly');
const index = tx.store.index('created_at');
const events: NostrEvent[] = [];
let count = 0;
const limit = (filter.limit || 100) * 3;
// Iterate in reverse (newest first)
let cursor = await index.openCursor(null, 'prev');
while (cursor && count < limit) {
events.push(cursor.value as NostrEvent);
count++;
cursor = await cursor.continue();
}
await tx.done;
candidateEvents = events;
} catch (dbError) {
console.error('[nostr-client] Error querying IndexedDB for recent events:', dbError);
candidateEvents = [];
}
}
console.log(`[nostr-client] Cache query found ${candidateEvents.length} candidate events for filter:`, filter);
// Filter candidates by all filter criteria
for (const event of candidateEvents) {
if (seen.has(event.id)) continue;
// Apply since/until filters
if (filter.since && event.created_at < filter.since) continue;
if (filter.until && event.created_at > filter.until) continue;
// Apply ids filter
if (filter.ids && filter.ids.length > 0 && !filter.ids.includes(event.id)) continue;
// Apply authors filter (if not already used for query)
if (filter.authors && filter.authors.length > 0 && !filter.authors.includes(event.pubkey)) continue;
// Apply kinds filter (if not already used for query)
if (filter.kinds && filter.kinds.length > 0 && !filter.kinds.includes(event.kind)) continue;
// Apply tag filters (#e, #E, #p, #P, #a, #A, #d, etc.)
// Handle both uppercase and lowercase tag filters (Nostr spec allows both)
// Ignore relays that don't support the tag filter, they need to be corrected by the relay operator.
if (filter['#e'] && filter['#e'].length > 0) {
const eventTags = event.tags.filter(t => t[0] === 'e' || t[0] === 'E').map(t => t[1]);
if (!filter['#e'].some(id => eventTags.includes(id))) continue;
}
if (filter['#E'] && filter['#E'].length > 0) {
const eventTags = event.tags.filter(t => t[0] === 'e' || t[0] === 'E').map(t => t[1]);
if (!filter['#E'].some(id => eventTags.includes(id))) continue;
}
if (filter['#p'] && filter['#p'].length > 0) {
const pubkeyTags = event.tags.filter(t => t[0] === 'p' || t[0] === 'P').map(t => t[1]);
if (!filter['#p'].some(pk => pubkeyTags.includes(pk))) continue;
}
if (filter['#P'] && filter['#P'].length > 0) {
const pubkeyTags = event.tags.filter(t => t[0] === 'p' || t[0] === 'P').map(t => t[1]);
if (!filter['#P'].some(pk => pubkeyTags.includes(pk))) continue;
}
if (filter['#a'] && filter['#a'].length > 0) {
const aTags = event.tags.filter(t => t[0] === 'a' || t[0] === 'A').map(t => t[1]);
if (!filter['#a'].some(a => aTags.includes(a))) continue;
}
if (filter['#A'] && filter['#A'].length > 0) {
const aTags = event.tags.filter(t => t[0] === 'a' || t[0] === 'A').map(t => t[1]);
if (!filter['#A'].some(a => aTags.includes(a))) continue;
}
if (filter['#d'] && filter['#d'].length > 0) {
const dTags = event.tags.filter(t => t[0] === 'd' || t[0] === 'D').map(t => t[1]);
if (!filter['#d'].some(d => dTags.includes(d))) continue;
}
// Use matchFilter for final validation
if (matchFilter(filter, event)) {
results.push(event);
seen.add(event.id);
} }
} }
} catch (error) { } catch (error) {
// Continue with other filters // Continue with other filters
console.error('[nostr-client] Error querying cache for filter:', error, filter);
} }
} }
return filterEvents(results); // Sort by created_at descending and apply limit
const sorted = results.sort((a, b) => b.created_at - a.created_at);
const limit = filters[0]?.limit || 100;
const limited = sorted.slice(0, limit);
const filtered = filterEvents(limited);
console.log(`[nostr-client] Cache query: ${limited.length} events before filter, ${filtered.length} after filter`);
return filtered;
} catch (error) { } catch (error) {
console.error('[nostr-client] Error getting cached events:', error);
return []; return [];
} }
} }
@ -424,22 +564,30 @@ class NostrClient {
try { try {
const cachedEvents = await this.getCachedEvents(filters); const cachedEvents = await this.getCachedEvents(filters);
if (cachedEvents.length > 0) { if (cachedEvents.length > 0) {
console.log(`[nostr-client] Returning ${cachedEvents.length} cached events for filter:`, filters);
// Return cached immediately, fetch fresh in background with delay // Return cached immediately, fetch fresh in background with delay
// Don't pass onUpdate to background fetch to avoid interfering with cached results
if (cacheResults) { if (cacheResults) {
// Use a longer delay for background refresh to avoid interfering with initial load
setTimeout(() => { setTimeout(() => {
const bgKey = `${fetchKey}_bg_${Date.now()}`; const bgKey = `${fetchKey}_bg_${Date.now()}`;
const bgPromise = this.fetchFromRelays(filters, relays, { cacheResults, onUpdate, timeout }); // Only update cache, don't call onUpdate for background refresh
const bgPromise = this.fetchFromRelays(filters, relays, { cacheResults, onUpdate: undefined, timeout });
this.activeFetches.set(bgKey, bgPromise); this.activeFetches.set(bgKey, bgPromise);
bgPromise.finally(() => { bgPromise.finally(() => {
this.activeFetches.delete(bgKey); this.activeFetches.delete(bgKey);
}).catch(() => { }).catch((error) => {
// Silently fail // Log but don't throw - background refresh failures shouldn't affect cached results
console.debug('[nostr-client] Background refresh failed:', error);
}); });
}, 2000); // 2 second delay for background refresh }, 5000); // 5 second delay for background refresh to avoid interfering
} }
return cachedEvents; return cachedEvents;
} else {
console.log(`[nostr-client] No cached events found for filter:`, filters);
} }
} catch (error) { } catch (error) {
console.error('[nostr-client] Error querying cache:', error);
// Continue to fetch from relays // Continue to fetch from relays
} }
} }
@ -460,20 +608,47 @@ class NostrClient {
): Promise<NostrEvent[]> { ): Promise<NostrEvent[]> {
const timeout = options.timeout || config.relayTimeout; const timeout = options.timeout || config.relayTimeout;
let availableRelays = relays.filter(url => this.relays.has(url)); // Filter out relays that have failed recently
const now = Date.now();
if (availableRelays.length === 0) { const availableRelays = relays.filter(url => {
await Promise.allSettled(relays.map(url => this.addRelay(url).catch(() => null))); if (this.relays.has(url)) return true; // Already connected
availableRelays = relays.filter(url => this.relays.has(url));
if (availableRelays.length === 0) { const failureInfo = this.failedRelays.get(url);
return []; if (failureInfo) {
const timeSinceFailure = now - failureInfo.lastFailure;
if (timeSinceFailure < failureInfo.retryAfter) {
return false; // Skip this relay, it failed recently
}
} }
return true; // Can try to connect
});
// Try to connect to relays that aren't already connected
const relaysToConnect = availableRelays.filter(url => !this.relays.has(url));
if (relaysToConnect.length > 0) {
await Promise.allSettled(
relaysToConnect.map(url =>
this.addRelay(url).catch((error) => {
// Error already logged in addRelay
return null;
})
)
);
} }
// Get list of actually connected relays
const connectedRelays = availableRelays.filter(url => this.relays.has(url));
if (connectedRelays.length === 0) {
console.warn(`[nostr-client] No connected relays available for fetch (${relays.length} requested, all failed or unavailable)`);
return [];
}
console.log(`[nostr-client] Fetching from ${connectedRelays.length} connected relay(s) out of ${relays.length} requested`);
// Process relays sequentially with throttling to avoid overload // Process relays sequentially with throttling to avoid overload
const events: Map<string, NostrEvent> = new Map(); const events: Map<string, NostrEvent> = new Map();
for (const relayUrl of availableRelays) { for (const relayUrl of connectedRelays) {
await this.throttledRelayRequest(relayUrl, filters, events, timeout); await this.throttledRelayRequest(relayUrl, filters, events, timeout);
// Small delay between relays // Small delay between relays
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise(resolve => setTimeout(resolve, 100));
@ -489,8 +664,15 @@ class NostrClient {
}); });
} }
// Only call onUpdate if we got new events AND onUpdate is provided
// This prevents clearing the UI when background fetch returns fewer results
if (options.onUpdate && filtered.length > 0) { if (options.onUpdate && filtered.length > 0) {
console.log(`[nostr-client] Fetch returned ${filtered.length} events, calling onUpdate`);
options.onUpdate(filtered); options.onUpdate(filtered);
} else if (options.onUpdate && filtered.length === 0) {
console.log(`[nostr-client] Fetch returned 0 events, skipping onUpdate to preserve cached results`);
} else if (!options.onUpdate) {
console.log(`[nostr-client] Fetch returned ${filtered.length} events (background refresh, no onUpdate)`);
} }
return filtered; return filtered;

Loading…
Cancel
Save