diff --git a/src/app.css b/src/app.css index 1ab1bc6..0f8fd93 100644 --- a/src/app.css +++ b/src/app.css @@ -116,10 +116,12 @@ img[src*="profile" i] { } /* Emoji images - grayscale like profile pics */ +/* Only apply filter to actual image elements, not text emojis */ .emoji, [class*="emoji"], img[alt*="emoji" i], -img[src*="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; } @@ -127,10 +129,18 @@ img[src*="emoji" i] { .dark .emoji, .dark [class*="emoji"], .dark img[alt*="emoji" i], -.dark img[src*="emoji" i] { +.dark img[src*="emoji" i], +.dark img.emoji-inline { filter: grayscale(100%) sepia(20%) hue-rotate(200deg) saturate(70%) brightness(0.9); } +/* Ensure normal Unicode emojis (text characters) are displayed correctly */ +/* Use emoji-friendly fonts and ensure they're not filtered */ +body, .markdown-content, .post-content { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji", sans-serif; + /* 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), diff --git a/src/lib/components/content/EmbeddedEvent.svelte b/src/lib/components/content/EmbeddedEvent.svelte index 98a9e63..4f28e63 100644 --- a/src/lib/components/content/EmbeddedEvent.svelte +++ b/src/lib/components/content/EmbeddedEvent.svelte @@ -16,12 +16,28 @@ let event = $state(null); let loading = $state(true); let error = $state(false); + let loadingEvent = $state(false); + let lastEventId = $state(null); onMount(async () => { - await loadEvent(); + if (eventId && eventId !== lastEventId) { + await loadEvent(); + } + }); + + $effect(() => { + if (eventId && eventId !== lastEventId && !loadingEvent) { + lastEventId = eventId; + loadEvent(); + } }); async function loadEvent() { + // Prevent concurrent loads for the same event + if (loadingEvent) { + return; + } + loadingEvent = true; loading = true; error = false; try { @@ -95,6 +111,7 @@ error = true; } finally { loading = false; + loadingEvent = false; } } @@ -102,13 +119,10 @@ if (!event) return ''; if (event.kind === 11) { const titleTag = event.tags.find(t => t[0] === 'title'); - return titleTag?.[1] || 'Untitled'; - } - const firstLine = event.content.split('\n')[0].trim(); - if (firstLine.length > 0 && firstLine.length < 100) { - return firstLine; + return titleTag?.[1] || ''; } - return 'Event'; + // For other event kinds, don't use content as title - leave it blank + return ''; } function getSubject(): string | null { @@ -123,9 +137,38 @@ return imageTag?.[1] || null; } + // Extract image URLs from event content + function getImageUrlsFromContent(): string[] { + if (!event) return []; + const imageUrls: string[] = []; + const imageExtensions = /\.(png|jpg|jpeg|gif|webp|svg|bmp|ico)(\?[^\s<>"']*)?$/i; + const urlPattern = /https?:\/\/[^\s<>"']+/g; + + let match; + while ((match = urlPattern.exec(event.content)) !== null) { + const url = match[0]; + if (imageExtensions.test(url)) { + // Check if it's not already in markdown or HTML + const before = event.content.substring(Math.max(0, match.index - 10), match.index); + const after = event.content.substring(match.index + url.length, Math.min(event.content.length, match.index + url.length + 10)); + if (!before.includes('![') && !before.includes('')) { + imageUrls.push(url); + } + } + } + + return imageUrls; + } + function getPreview(): string { if (!event) return ''; - const preview = stripMarkdown(event.content).slice(0, 150); + // Remove image URLs from preview text + let preview = event.content; + const imageUrls = getImageUrlsFromContent(); + for (const url of imageUrls) { + preview = preview.replace(url, '').trim(); + } + preview = stripMarkdown(preview).slice(0, 150); return preview.length < event.content.length ? preview + '...' : preview; } @@ -159,10 +202,23 @@
{getTitle()}
+ {:else} + {@const contentImages = getImageUrlsFromContent()} + {#if contentImages.length > 0} +
+ {#each contentImages.slice(0, 3) as imageUrl} +
+ {getTitle()} +
+ {/each} +
+ {/if} {/if}
-

{getTitle()}

+ {#if getTitle()} +

{getTitle()}

+ {/if} {#if event} {/if} @@ -213,6 +269,17 @@ object-fit: cover; } + .embedded-event-images { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 0.75rem; + } + + .embedded-event-images .embedded-event-image { + margin-bottom: 0; + } + .embedded-event-content { display: flex; flex-direction: column; @@ -225,13 +292,24 @@ justify-content: space-between; gap: 1rem; flex-wrap: wrap; + line-height: 1.5; + } + + .embedded-event-header :global(.profile-badge) { + display: inline-flex; + align-items: center; + vertical-align: middle; + line-height: 1.5; } .embedded-event-title { font-weight: 600; font-size: 1.125rem; margin: 0; + line-height: 1.5; color: var(--fog-text, #1f2937); + display: inline-block; + vertical-align: middle; } :global(.dark) .embedded-event-title { diff --git a/src/lib/components/content/MarkdownRenderer.svelte b/src/lib/components/content/MarkdownRenderer.svelte index 78f2ac5..9988687 100644 --- a/src/lib/components/content/MarkdownRenderer.svelte +++ b/src/lib/components/content/MarkdownRenderer.svelte @@ -527,4 +527,15 @@ display: block; margin: 1rem 0; } + + /* Ensure normal Unicode emojis are displayed correctly */ + :global(.markdown-content) { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji", sans-serif; + /* Ensure emojis are not filtered or hidden */ + } + + :global(.markdown-content *) { + /* Normal emojis (Unicode characters) should not have filters applied */ + /* Only emoji images (custom emojis) should have the grayscale filter */ + } diff --git a/src/lib/modules/feed/FeedPage.svelte b/src/lib/modules/feed/FeedPage.svelte index f2dcd8a..e8789af 100644 --- a/src/lib/modules/feed/FeedPage.svelte +++ b/src/lib/modules/feed/FeedPage.svelte @@ -12,6 +12,9 @@ let hasMore = $state(true); let oldestTimestamp = $state(null); + // Batch-loaded reactions: eventId -> reactions[] + let reactionsMap = $state>(new Map()); + // Drawer state for viewing parent/quoted events let drawerOpen = $state(false); let drawerEvent = $state(null); @@ -110,6 +113,8 @@ if (sorted.length > 0) { oldestTimestamp = Math.min(...sorted.map(e => e.created_at)); + // Batch load reactions for all posts + await loadReactionsForPosts(sorted); } hasMore = events.length >= 20; @@ -161,6 +166,8 @@ if (oldest < (oldestTimestamp || Infinity)) { oldestTimestamp = oldest; } + // Batch load reactions for new posts + await loadReactionsForPosts(sorted); hasMore = events.length >= 20; } else if (events.length > 0) { // All events were duplicates, but we got some results @@ -212,6 +219,53 @@ pendingUpdates = []; }, 500); } + + // Batch load reactions for multiple posts at once + async function loadReactionsForPosts(postsToLoad: NostrEvent[]) { + if (postsToLoad.length === 0) return; + + try { + const reactionRelays = relayManager.getProfileReadRelays(); + const eventIds = postsToLoad.map(p => p.id); + + // Batch fetch all reactions for all posts in one query + const allReactions = await nostrClient.fetchEvents( + [ + { kinds: [7], '#e': eventIds, limit: 1000 }, + { kinds: [7], '#E': eventIds, limit: 1000 } + ], + reactionRelays, + { useCache: true, cacheResults: true, timeout: 10000 } + ); + + // Group reactions by event ID + const newReactionsMap = new Map(); + for (const reaction of allReactions) { + // Find which event(s) this reaction is for + const eTags = reaction.tags.filter(t => (t[0] === 'e' || t[0] === 'E') && t[1]); + for (const tag of eTags) { + const eventId = tag[1]; + if (eventIds.includes(eventId)) { + if (!newReactionsMap.has(eventId)) { + newReactionsMap.set(eventId, []); + } + newReactionsMap.get(eventId)!.push(reaction); + } + } + } + + // Merge with existing reactions + for (const [eventId, reactions] of newReactionsMap.entries()) { + const existing = reactionsMap.get(eventId) || []; + const combined = [...existing, ...reactions]; + // Deduplicate by reaction ID + const unique = Array.from(new Map(combined.map(r => [r.id, r])).values()); + reactionsMap.set(eventId, unique); + } + } catch (error) { + console.error('[FeedPage] Error batch loading reactions:', error); + } + }
@@ -226,7 +280,7 @@ {:else}
{#each posts as post (post.id)} - + {/each}
diff --git a/src/lib/modules/feed/FeedPost.svelte b/src/lib/modules/feed/FeedPost.svelte index 68323e1..16d0dbd 100644 --- a/src/lib/modules/feed/FeedPost.svelte +++ b/src/lib/modules/feed/FeedPost.svelte @@ -461,6 +461,7 @@ {/if} +
{#if post.kind === 11} @@ -479,7 +480,7 @@ {zapCount} {/if} - + {#if onReply}