Browse Source

bug-fixes

master
Silberengel 1 month ago
parent
commit
a84c3bd6b0
  1. 14
      src/app.css
  2. 96
      src/lib/components/content/EmbeddedEvent.svelte
  3. 11
      src/lib/components/content/MarkdownRenderer.svelte
  4. 56
      src/lib/modules/feed/FeedPage.svelte
  5. 25
      src/lib/modules/feed/FeedPost.svelte
  6. 18
      src/lib/modules/feed/Reply.svelte
  7. 45
      src/lib/modules/reactions/FeedReactionButtons.svelte
  8. 64
      src/lib/services/nostr/nostr-client.ts

14
src/app.css

@ -116,10 +116,12 @@ img[src*="profile" i] { @@ -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] { @@ -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),

96
src/lib/components/content/EmbeddedEvent.svelte

@ -16,12 +16,28 @@ @@ -16,12 +16,28 @@
let event = $state<NostrEvent | null>(null);
let loading = $state(true);
let error = $state(false);
let loadingEvent = $state(false);
let lastEventId = $state<string | null>(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 @@ @@ -95,6 +111,7 @@
error = true;
} finally {
loading = false;
loadingEvent = false;
}
}
@ -102,13 +119,10 @@ @@ -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 @@ @@ -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('<img') && !after.startsWith('](') && !after.startsWith('</img>')) {
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 @@ @@ -159,10 +202,23 @@
<div class="embedded-event-image">
<img src={getImageUrl()} alt={getTitle()} loading="lazy" />
</div>
{:else}
{@const contentImages = getImageUrlsFromContent()}
{#if contentImages.length > 0}
<div class="embedded-event-images">
{#each contentImages.slice(0, 3) as imageUrl}
<div class="embedded-event-image">
<img src={imageUrl} alt={getTitle()} loading="lazy" />
</div>
{/each}
</div>
{/if}
{/if}
<div class="embedded-event-content">
<div class="embedded-event-header">
<h4 class="embedded-event-title">{getTitle()}</h4>
{#if getTitle()}
<h4 class="embedded-event-title">{getTitle()}</h4>
{/if}
{#if event}
<ProfileBadge pubkey={event.pubkey} />
{/if}
@ -213,6 +269,17 @@ @@ -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 @@ @@ -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 {

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

@ -527,4 +527,15 @@ @@ -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 */
}
</style>

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

@ -12,6 +12,9 @@ @@ -12,6 +12,9 @@
let hasMore = $state(true);
let oldestTimestamp = $state<number | null>(null);
// Batch-loaded reactions: eventId -> reactions[]
let reactionsMap = $state<Map<string, NostrEvent[]>>(new Map());
// Drawer state for viewing parent/quoted events
let drawerOpen = $state(false);
let drawerEvent = $state<NostrEvent | null>(null);
@ -110,6 +113,8 @@ @@ -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 @@ @@ -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 @@ @@ -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<string, NostrEvent[]>();
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);
}
}
</script>
<div class="feed-page">
@ -226,7 +280,7 @@ @@ -226,7 +280,7 @@
{:else}
<div class="feed-posts">
{#each posts as post (post.id)}
<FeedPost post={post} onOpenEvent={openDrawer} />
<FeedPost post={post} onOpenEvent={openDrawer} reactions={reactionsMap.get(post.id)} />
{/each}
</div>

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

@ -461,6 +461,7 @@ @@ -461,6 +461,7 @@
</button>
{/if}
<!-- Post actions (reactions, etc.) - always visible, outside collapsible content -->
<div class="post-actions flex flex-wrap items-center gap-2 sm:gap-4">
{#if post.kind === 11}
<!-- Show vote counts for threads -->
@ -479,7 +480,7 @@ @@ -479,7 +480,7 @@
<span class="zap-count-number">{zapCount}</span>
</span>
{/if}
<FeedReactionButtons event={post} />
<FeedReactionButtons event={post} preloadedReactions={reactions} />
{#if onReply}
<button
onclick={() => onReply(post)}
@ -520,6 +521,10 @@ @@ -520,6 +521,10 @@
padding-right: 6rem; /* Reserve space for kind badge */
border-top: 1px solid var(--fog-border, #e5e7eb);
margin-top: 0.5rem;
/* Ensure post actions are always visible, even when content is collapsed */
position: relative;
z-index: 1;
overflow: visible;
}
:global(.dark) .post-actions {
@ -608,4 +613,22 @@ @@ -608,4 +613,22 @@
background: var(--fog-dark-highlight, #374151);
}
.post-header {
display: flex;
align-items: center;
line-height: 1.5;
}
.post-header :global(.profile-badge) {
display: inline-flex;
align-items: center;
vertical-align: middle;
line-height: 1.5;
}
.post-header :global(.profile-badge span) {
line-height: 1.5;
vertical-align: middle;
}
</style>

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

@ -215,4 +215,22 @@ @@ -215,4 +215,22 @@
font-size: 0.625rem;
opacity: 0.8;
}
.reply-header {
display: flex;
align-items: center;
line-height: 1.5;
}
.reply-header :global(.profile-badge) {
display: inline-flex;
align-items: center;
vertical-align: middle;
line-height: 1.5;
}
.reply-header :global(.profile-badge span) {
line-height: 1.5;
vertical-align: baseline;
}
</style>

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

@ -12,9 +12,10 @@ @@ -12,9 +12,10 @@
interface Props {
event: NostrEvent;
forceUpvoteDownvote?: boolean; // Force upvote/downvote mode (for kind 1111 replies to kind 11 threads)
preloadedReactions?: NostrEvent[]; // Optional pre-loaded reactions to avoid duplicate fetches
}
let { event, forceUpvoteDownvote = false }: Props = $props();
let { event, forceUpvoteDownvote = false, preloadedReactions }: Props = $props();
let reactions = $state<Map<string, { content: string; pubkeys: Set<string>; eventIds: Map<string, string> }>>(new Map());
let userReaction = $state<string | null>(null);
@ -66,18 +67,44 @@ @@ -66,18 +67,44 @@
});
});
let loadingReactions = $state(false);
let lastEventId = $state<string | null>(null);
onMount(() => {
nostrClient.initialize().then(() => {
loadReactions();
nostrClient.initialize().then(async () => {
if (event.id) {
// Use pre-loaded reactions if available, otherwise fetch
if (preloadedReactions && preloadedReactions.length > 0) {
for (const r of preloadedReactions) {
allReactionsMap.set(r.id, r);
}
const filtered = await filterDeletedReactions(preloadedReactions);
processReactions(filtered);
} else {
loadReactions();
}
}
});
});
// Reload reactions when event changes
// Reload reactions when event changes (but prevent duplicate loads)
$effect(() => {
if (event.id) {
if (event.id && event.id !== lastEventId && !loadingReactions) {
lastEventId = event.id;
// Clear previous reactions map when event changes
allReactionsMap.clear();
loadReactions();
// Use pre-loaded reactions if available, otherwise fetch
if (preloadedReactions && preloadedReactions.length > 0) {
for (const r of preloadedReactions) {
allReactionsMap.set(r.id, r);
}
filterDeletedReactions(preloadedReactions).then(filtered => {
processReactions(filtered);
});
} else {
loadReactions();
}
}
});
@ -105,6 +132,11 @@ @@ -105,6 +132,11 @@
}
async function loadReactions() {
// Prevent concurrent loads for the same event
if (loadingReactions) {
return;
}
loadingReactions = true;
loading = true;
try {
// Use getProfileReadRelays() to include defaultRelays + profileRelays + user inbox + localRelays
@ -188,6 +220,7 @@ @@ -188,6 +220,7 @@
console.error('[FeedReactionButtons] Error loading reactions:', error);
} finally {
loading = false;
loadingReactions = false;
}
}

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

@ -39,6 +39,9 @@ class NostrClient { @@ -39,6 +39,9 @@ class NostrClient {
private readonly MAX_CONCURRENT_TOTAL = 3; // Max 3 total concurrent requests
private totalActiveRequests = 0;
// Track background refresh operations to prevent duplicates
private backgroundRefreshes: Set<string> = new Set();
// 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
@ -230,32 +233,32 @@ class NostrClient { @@ -230,32 +233,32 @@ class NostrClient {
// 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;
const eventTags = event.tags.filter(t => (t[0] === 'e' || t[0] === 'E') && t[1]).map(t => t[1]);
if (eventTags.length === 0 || !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;
const eventTags = event.tags.filter(t => (t[0] === 'e' || t[0] === 'E') && t[1]).map(t => t[1]);
if (eventTags.length === 0 || !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;
const pubkeyTags = event.tags.filter(t => (t[0] === 'p' || t[0] === 'P') && t[1]).map(t => t[1]);
if (pubkeyTags.length === 0 || !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;
const pubkeyTags = event.tags.filter(t => (t[0] === 'p' || t[0] === 'P') && t[1]).map(t => t[1]);
if (pubkeyTags.length === 0 || !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;
const aTags = event.tags.filter(t => (t[0] === 'a' || t[0] === 'A') && t[1]).map(t => t[1]);
if (aTags.length === 0 || !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;
const aTags = event.tags.filter(t => (t[0] === 'a' || t[0] === 'A') && t[1]).map(t => t[1]);
if (aTags.length === 0 || !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;
const dTags = event.tags.filter(t => (t[0] === 'd' || t[0] === 'D') && t[1]).map(t => t[1]);
if (dTags.length === 0 || !filter['#d'].some(d => dTags.includes(d))) continue;
}
// Use matchFilter for final validation
@ -568,19 +571,26 @@ class NostrClient { @@ -568,19 +571,26 @@ class NostrClient {
// Return cached immediately, fetch fresh in background with delay
// Don't pass onUpdate to background fetch to avoid interfering with cached results
if (cacheResults) {
// Use a longer delay for background refresh to avoid interfering with initial load
setTimeout(() => {
const bgKey = `${fetchKey}_bg_${Date.now()}`;
// 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);
bgPromise.finally(() => {
this.activeFetches.delete(bgKey);
}).catch((error) => {
// Log but don't throw - background refresh failures shouldn't affect cached results
console.debug('[nostr-client] Background refresh failed:', error);
});
}, 5000); // 5 second delay for background refresh to avoid interfering
// Prevent duplicate background refreshes for the same filter
if (!this.backgroundRefreshes.has(fetchKey)) {
this.backgroundRefreshes.add(fetchKey);
// Use a longer delay for background refresh to avoid interfering with initial load
setTimeout(() => {
// Only update cache, don't call onUpdate for background refresh
// This ensures cached events persist and are not cleared by background refresh
const bgPromise = this.fetchFromRelays(filters, relays, { cacheResults: true, onUpdate: undefined, timeout });
bgPromise.finally(() => {
// Remove from background refreshes set after a delay to allow re-refresh if needed
setTimeout(() => {
this.backgroundRefreshes.delete(fetchKey);
}, 60000); // Allow re-refresh after 60 seconds
}).catch((error) => {
// Log but don't throw - background refresh failures shouldn't affect cached results
console.debug('[nostr-client] Background refresh failed:', error);
this.backgroundRefreshes.delete(fetchKey);
});
}, 5000); // 5 second delay for background refresh to avoid interfering
}
}
return cachedEvents;
} else {

Loading…
Cancel
Save