From 1a17bef1ac99e7be8c659d0856126e1f89e73f7d Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Wed, 1 Jul 2026 09:33:29 -0400 Subject: [PATCH] Implement Phase 1 remaining tasks: notifications append-only, listener leak fix, memory caps Task 1.2: Append-only rendering for notifications page - Extracted buildNotificationRow(item) helper from renderNotifications() - Added prependNotificationRow(item) with sorted insertion by created_at - addNotificationFromEvent uses prepend when DOM has children Task 1.3: Fix notification document.click listener leak - Removed per-row document.addEventListener('click') that leaked on every render - Added single delegated click listener in main() using .notif-menu-panel class - Menu panels still close when clicking outside .notif-right Task 1.4: Cap posts[] and postIds with rolling eviction - Added MAX_STORED_POSTS=500 constant to feed.html and post.html - Added prunePostsArray() that sorts, slices, rebuilds postIds/renderedPostIds - Called in upsertFeedPost/upsertPost with +50 batch threshold - post.html skips pruning in event mode Task 1.5: Cap notificationsById/unreadNotificationsById with periodic pruning - Added MAX_STORED_NOTIFICATIONS=500, MAX_POST_IMAGE_CACHE=200 - Added pruneNotificationMaps() that prunes both Maps and LRU-caps postImageCache - Called in addNotificationFromEvent, hydrateNotificationsFromCache/Relays Updated plan checklist marking all Phase 1 tasks complete. --- plans/long-running-memory-bloat-fix.md | 37 ++- www/feed.html | 14 + www/js/version.json | 6 +- www/notifications.html | 436 +++++++++++++++---------- www/post.html | 14 + 5 files changed, 320 insertions(+), 187 deletions(-) diff --git a/plans/long-running-memory-bloat-fix.md b/plans/long-running-memory-bloat-fix.md index 0930b0d..4cc4daf 100644 --- a/plans/long-running-memory-bloat-fix.md +++ b/plans/long-running-memory-bloat-fix.md @@ -436,23 +436,26 @@ flowchart LR - [x] "See More" still does full re-render - [x] Verified: `data-post-id` attribute set by `renderPostItem` for trim logic - [x] Reviewed: no errors found, fallback paths correct -- [ ] **1.2** Append-only rendering for notifications page - - [ ] Split `renderNotifications()` into full rebuild + `prependNotificationRow()` - - [ ] New notifications prepend without rebuilding existing rows - - [ ] Filter toggles and "View more" still do full re-renders -- [ ] **1.3** Fix notification `document.click` listener leak - - [ ] Remove per-row `document.addEventListener('click', ...)` - - [ ] Register single delegated document-level click listener in `main()` - - [ ] Menu panels still close when clicking outside -- [ ] **1.4** Cap `posts[]` and `postIds` with rolling eviction - - [ ] Add `MAX_STORED_POSTS = 500` constant - - [ ] Evict oldest posts when array exceeds cap - - [ ] Rebuild `postIds` and `renderedPostIds` after eviction -- [ ] **1.5** Cap `notificationsById` / `unreadNotificationsById` with periodic pruning - - [ ] Add `MAX_STORED_NOTIFICATIONS = 500` - - [ ] Add `pruneNotificationMaps()` function - - [ ] Call prune on overflow, after cache/relay hydration - - [ ] LRU-cap `postImageCache` to 200 entries +- [x] **1.2** Append-only rendering for notifications page + - [x] Split `renderNotifications()` into full rebuild + `prependNotificationRow()` + - [x] Extracted `buildNotificationRow(item)` helper shared by both functions + - [x] New notifications prepend without rebuilding existing rows + - [x] Filter toggles and "View more" still do full re-renders +- [x] **1.3** Fix notification `document.click` listener leak + - [x] Remove per-row `document.addEventListener('click', ...)` + - [x] Register single delegated document-level click listener in `main()` + - [x] Added `notif-menu-panel` class for panel discovery + - [x] Menu panels still close when clicking outside +- [x] **1.4** Cap `posts[]` and `postIds` with rolling eviction + - [x] Add `MAX_STORED_POSTS = 500` constant + - [x] Evict oldest posts when array exceeds cap (+50 batch threshold) + - [x] Rebuild `postIds` and `renderedPostIds` after eviction + - [x] Skip pruning in post.html event mode +- [x] **1.5** Cap `notificationsById` / `unreadNotificationsById` with periodic pruning + - [x] Add `MAX_STORED_NOTIFICATIONS = 500` + - [x] Add `pruneNotificationMaps()` function + - [x] Call prune on overflow, after cache/relay hydration + - [x] LRU-cap `postImageCache` to 200 entries ### Phase 2: P1 — Subscription Hygiene & Polling Reduction diff --git a/www/feed.html b/www/feed.html index 658cb16..5ec4679 100644 --- a/www/feed.html +++ b/www/feed.html @@ -179,6 +179,7 @@ import { initPostCards } from './js/post-interactions2.mjs'; const MIN_BOOTSTRAP_POSTS = 10; const FEED_BOOTSTRAP_WINDOWS_SECONDS = [3600, 6 * 3600, 24 * 3600, 7 * 24 * 3600, 30 * 24 * 3600]; const FEED_BOOTSTRAP_WINDOW_LIMIT = 120; + const MAX_STORED_POSTS = 500; let currentPubkey = null; let updateIntervalId = null; @@ -449,11 +450,24 @@ import { initPostCards } from './js/post-interactions2.mjs'; } } + function prunePostsArray() { + posts.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); + if (posts.length > MAX_STORED_POSTS) { + posts.length = MAX_STORED_POSTS; + postIds.clear(); + posts.forEach((p) => postIds.add(p.id)); + for (const id of Array.from(renderedPostIds)) { + if (!postIds.has(id)) renderedPostIds.delete(id); + } + } + } + function upsertFeedPost(evt) { if (!evt?.id || postIds.has(evt.id) || !isOriginalPost(evt)) return false; if (isEventMuted(evt)) return false; postIds.add(evt.id); posts.push(evt); + if (posts.length > MAX_STORED_POSTS + 50) prunePostsArray(); return true; } diff --git a/www/js/version.json b/www/js/version.json index 7f8aa53..0021fe7 100644 --- a/www/js/version.json +++ b/www/js/version.json @@ -1,5 +1,5 @@ { - "VERSION": "v0.7.86", - "VERSION_NUMBER": "0.7.86", - "BUILD_DATE": "2026-07-01T13:22:33.456Z" + "VERSION": "v0.7.87", + "VERSION_NUMBER": "0.7.87", + "BUILD_DATE": "2026-07-01T13:33:29.318Z" } diff --git a/www/notifications.html b/www/notifications.html index 1480395..d1ce825 100644 --- a/www/notifications.html +++ b/www/notifications.html @@ -393,6 +393,8 @@ import { createProfileCache } from './js/profile-cache.mjs'; const NOTIFICATION_KINDS = [1, 3, 4, 6, 7, 9735]; const NOTIFICATIONS_PAGE_SIZE = 40; + const MAX_STORED_NOTIFICATIONS = 500; + const MAX_POST_IMAGE_CACHE = 200; const SVG_UNCHECKED = ``; const SVG_CHECKED = ``; const NOTIFICATION_FILTER_DEFS = [ @@ -883,6 +885,173 @@ import { createProfileCache } from './js/profile-cache.mjs'; } } + function buildNotificationRow(item) { + const row = document.createElement('div'); + row.className = 'notif-item'; + row.dataset.notifId = item.id; + row.dataset.notifCreatedAt = String(item.created_at || 0); + + const avatar = document.createElement('img'); + avatar.className = 'notif-avatar'; + avatar.alt = ''; + avatar.src = item.actor.picture || './favicon/favicon-dots2.ico'; + avatar.onerror = () => { + console.warn('[notifications] actor avatar failed to load', { + eventId: item.id, + actor: item.actor.name, + src: avatar.src + }); + avatar.onerror = null; + avatar.src = './favicon/favicon-dots2.ico'; + }; + + if (item.actor?.pubkey) { + avatar.style.cursor = 'pointer'; + avatar.title = 'Open profile'; + avatar.addEventListener('click', (event) => { + event.stopPropagation(); + const profileUrl = `./post.html?profile=${encodeURIComponent(item.actor.pubkey)}`; + window.open(profileUrl, '_blank', 'noopener,noreferrer'); + }); + } + + const secondaryImageUrl = normalizeMediaUrl(item.secondaryImage || ''); + const secondaryEventUrl = makeEventPermalink(item.targetEventId); + + let secondaryNode; + + if (secondaryImageUrl) { + const secondaryAvatar = document.createElement('img'); + secondaryAvatar.className = 'notif-avatar notif-avatar-secondary'; + secondaryAvatar.alt = ''; + secondaryAvatar.src = secondaryImageUrl; + secondaryAvatar.onerror = () => { + console.debug('[notifications] secondary image failed to load', { + eventId: item.id, + src: secondaryAvatar.src + }); + secondaryAvatar.style.visibility = 'hidden'; + }; + + if (secondaryEventUrl) { + secondaryAvatar.style.cursor = 'pointer'; + secondaryAvatar.title = 'Open related event'; + secondaryAvatar.addEventListener('click', (event) => { + event.stopPropagation(); + window.open(secondaryEventUrl, '_blank', 'noopener,noreferrer'); + }); + } + + secondaryNode = secondaryAvatar; + } else if (secondaryEventUrl) { + const secondaryPlaceholder = document.createElement('a'); + secondaryPlaceholder.className = 'notif-avatar notif-avatar-secondary notif-avatar-placeholder'; + secondaryPlaceholder.href = secondaryEventUrl; + secondaryPlaceholder.target = '_blank'; + secondaryPlaceholder.rel = 'noopener noreferrer'; + secondaryPlaceholder.title = 'Open related event'; + secondaryPlaceholder.setAttribute('aria-label', 'Open related event'); + secondaryPlaceholder.addEventListener('click', (event) => { + event.stopPropagation(); + }); + secondaryNode = secondaryPlaceholder; + } else { + const secondaryAvatar = document.createElement('div'); + secondaryAvatar.className = 'notif-avatar notif-avatar-secondary'; + secondaryAvatar.style.visibility = 'hidden'; + secondaryNode = secondaryAvatar; + } + + const content = document.createElement('div'); + content.className = 'notif-content'; + + const name = document.createElement('div'); + name.className = 'notif-name'; + name.textContent = item.actor.name; + + const desc = document.createElement('div'); + desc.className = 'notif-desc'; + desc.textContent = item.description; + + content.appendChild(name); + content.appendChild(desc); + + const time = document.createElement('div'); + time.className = 'notif-time'; + time.textContent = formatTimeAgo(item.created_at || 0); + timeElements.set(time, item.created_at || 0); + + const right = document.createElement('div'); + right.className = 'notif-right'; + + const menuButton = document.createElement('button'); + menuButton.className = 'notif-menu-btn'; + menuButton.type = 'button'; + menuButton.textContent = '⋯'; + menuButton.title = 'Notification menu'; + + const menuPanel = document.createElement('div'); + menuPanel.className = 'notif-menu-panel'; + menuPanel.style.position = 'absolute'; + menuPanel.style.right = '0'; + menuPanel.style.top = '26px'; + menuPanel.style.zIndex = '10'; + menuPanel.style.display = 'none'; + menuPanel.style.minWidth = '140px'; + menuPanel.style.padding = '6px'; + menuPanel.style.border = 'var(--border)'; + menuPanel.style.borderRadius = 'var(--border-radius)'; + menuPanel.style.background = 'var(--secondary-color)'; + + const blockButton = document.createElement('button'); + blockButton.type = 'button'; + blockButton.textContent = 'Block account'; + blockButton.style.width = '100%'; + blockButton.style.textAlign = 'left'; + blockButton.style.border = 'var(--button-border-width) solid var(--button-border-color)'; + blockButton.style.borderRadius = 'var(--button-border-radius)'; + blockButton.style.background = 'var(--button-background-color)'; + blockButton.style.color = 'var(--button-color)'; + blockButton.style.padding = '6px 8px'; + blockButton.style.cursor = 'pointer'; + + blockButton.addEventListener('click', async (event) => { + event.stopPropagation(); + const actorPubkey = String(item?.actor?.pubkey || '').trim(); + if (!actorPubkey) return; + + const ok = window.confirm(`Block ${item.actor.name || actorPubkey.slice(0, 8)}?`); + if (!ok) return; + + try { + await blockActorPubkey(actorPubkey); + } catch (error) { + console.warn('[notifications] Failed to block account:', error?.message || error); + } finally { + menuPanel.style.display = 'none'; + } + }); + + menuPanel.appendChild(blockButton); + + right.style.position = 'relative'; + menuButton.addEventListener('click', (event) => { + event.stopPropagation(); + menuPanel.style.display = menuPanel.style.display === 'none' ? 'block' : 'none'; + }); + + right.appendChild(menuButton); + right.appendChild(menuPanel); + right.appendChild(time); + + row.appendChild(avatar); + row.appendChild(secondaryNode); + row.appendChild(content); + row.appendChild(right); + + return row; + } + function renderNotifications() { const items = Array.from(notificationsById.values()) .sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); @@ -892,172 +1061,7 @@ import { createProfileCache } from './js/profile-cache.mjs'; timeElements.clear(); for (const item of visibleItems) { - const row = document.createElement('div'); - row.className = 'notif-item'; - - const avatar = document.createElement('img'); - avatar.className = 'notif-avatar'; - avatar.alt = ''; - avatar.src = item.actor.picture || './favicon/favicon-dots2.ico'; - avatar.onerror = () => { - console.warn('[notifications] actor avatar failed to load', { - eventId: item.id, - actor: item.actor.name, - src: avatar.src - }); - avatar.onerror = null; - avatar.src = './favicon/favicon-dots2.ico'; - }; - - if (item.actor?.pubkey) { - avatar.style.cursor = 'pointer'; - avatar.title = 'Open profile'; - avatar.addEventListener('click', (event) => { - event.stopPropagation(); - const profileUrl = `./post.html?profile=${encodeURIComponent(item.actor.pubkey)}`; - window.open(profileUrl, '_blank', 'noopener,noreferrer'); - }); - } - - const secondaryImageUrl = normalizeMediaUrl(item.secondaryImage || ''); - const secondaryEventUrl = makeEventPermalink(item.targetEventId); - - let secondaryNode; - - if (secondaryImageUrl) { - const secondaryAvatar = document.createElement('img'); - secondaryAvatar.className = 'notif-avatar notif-avatar-secondary'; - secondaryAvatar.alt = ''; - secondaryAvatar.src = secondaryImageUrl; - secondaryAvatar.onerror = () => { - console.debug('[notifications] secondary image failed to load', { - eventId: item.id, - src: secondaryAvatar.src - }); - secondaryAvatar.style.visibility = 'hidden'; - }; - - if (secondaryEventUrl) { - secondaryAvatar.style.cursor = 'pointer'; - secondaryAvatar.title = 'Open related event'; - secondaryAvatar.addEventListener('click', (event) => { - event.stopPropagation(); - window.open(secondaryEventUrl, '_blank', 'noopener,noreferrer'); - }); - } - - secondaryNode = secondaryAvatar; - } else if (secondaryEventUrl) { - const secondaryPlaceholder = document.createElement('a'); - secondaryPlaceholder.className = 'notif-avatar notif-avatar-secondary notif-avatar-placeholder'; - secondaryPlaceholder.href = secondaryEventUrl; - secondaryPlaceholder.target = '_blank'; - secondaryPlaceholder.rel = 'noopener noreferrer'; - secondaryPlaceholder.title = 'Open related event'; - secondaryPlaceholder.setAttribute('aria-label', 'Open related event'); - secondaryPlaceholder.addEventListener('click', (event) => { - event.stopPropagation(); - }); - secondaryNode = secondaryPlaceholder; - } else { - const secondaryAvatar = document.createElement('div'); - secondaryAvatar.className = 'notif-avatar notif-avatar-secondary'; - secondaryAvatar.style.visibility = 'hidden'; - secondaryNode = secondaryAvatar; - } - - const content = document.createElement('div'); - content.className = 'notif-content'; - - const name = document.createElement('div'); - name.className = 'notif-name'; - name.textContent = item.actor.name; - - const desc = document.createElement('div'); - desc.className = 'notif-desc'; - desc.textContent = item.description; - - content.appendChild(name); - content.appendChild(desc); - - const time = document.createElement('div'); - time.className = 'notif-time'; - time.textContent = formatTimeAgo(item.created_at || 0); - timeElements.set(time, item.created_at || 0); - - const right = document.createElement('div'); - right.className = 'notif-right'; - - const menuButton = document.createElement('button'); - menuButton.className = 'notif-menu-btn'; - menuButton.type = 'button'; - menuButton.textContent = '⋯'; - menuButton.title = 'Notification menu'; - - const menuPanel = document.createElement('div'); - menuPanel.style.position = 'absolute'; - menuPanel.style.right = '0'; - menuPanel.style.top = '26px'; - menuPanel.style.zIndex = '10'; - menuPanel.style.display = 'none'; - menuPanel.style.minWidth = '140px'; - menuPanel.style.padding = '6px'; - menuPanel.style.border = 'var(--border)'; - menuPanel.style.borderRadius = 'var(--border-radius)'; - menuPanel.style.background = 'var(--secondary-color)'; - - const blockButton = document.createElement('button'); - blockButton.type = 'button'; - blockButton.textContent = 'Block account'; - blockButton.style.width = '100%'; - blockButton.style.textAlign = 'left'; - blockButton.style.border = 'var(--button-border-width) solid var(--button-border-color)'; - blockButton.style.borderRadius = 'var(--button-border-radius)'; - blockButton.style.background = 'var(--button-background-color)'; - blockButton.style.color = 'var(--button-color)'; - blockButton.style.padding = '6px 8px'; - blockButton.style.cursor = 'pointer'; - - blockButton.addEventListener('click', async (event) => { - event.stopPropagation(); - const actorPubkey = String(item?.actor?.pubkey || '').trim(); - if (!actorPubkey) return; - - const ok = window.confirm(`Block ${item.actor.name || actorPubkey.slice(0, 8)}?`); - if (!ok) return; - - try { - await blockActorPubkey(actorPubkey); - } catch (error) { - console.warn('[notifications] Failed to block account:', error?.message || error); - } finally { - menuPanel.style.display = 'none'; - } - }); - - menuPanel.appendChild(blockButton); - - right.style.position = 'relative'; - menuButton.addEventListener('click', (event) => { - event.stopPropagation(); - menuPanel.style.display = menuPanel.style.display === 'none' ? 'block' : 'none'; - }); - - document.addEventListener('click', (event) => { - if (!right.contains(event.target)) { - menuPanel.style.display = 'none'; - } - }); - - right.appendChild(menuButton); - right.appendChild(menuPanel); - right.appendChild(time); - - row.appendChild(avatar); - row.appendChild(secondaryNode); - row.appendChild(content); - row.appendChild(right); - + const row = buildNotificationRow(item); divNotifications.appendChild(row); } @@ -1071,6 +1075,56 @@ import { createProfileCache } from './js/profile-cache.mjs'; renderNotificationFilters(); } + function prependNotificationRow(item) { + if (divNotifications.querySelector(`[data-notif-id="${CSS.escape(String(item.id))}"]`)) { + return true; + } + + if (divNotifications.children.length === 0) { + renderNotifications(); + return false; + } + + const row = buildNotificationRow(item); + const newCreatedAt = Number(item.created_at || 0); + + let inserted = false; + for (const existing of divNotifications.children) { + const existingCreatedAt = Number(existing.dataset?.notifCreatedAt || 0); + if (newCreatedAt > existingCreatedAt) { + divNotifications.insertBefore(row, existing); + inserted = true; + break; + } + } + + if (!inserted) { + divNotifications.appendChild(row); + } + + while (divNotifications.children.length > visibleNotificationsCount) { + const last = divNotifications.lastElementChild; + if (last) { + const timeEl = last.querySelector('.notif-time'); + if (timeEl) timeElements.delete(timeEl); + last.remove(); + } else { + break; + } + } + + const total = notificationsById.size; + const hasMore = total > visibleNotificationsCount; + if (btnViewMoreNotifications) { + btnViewMoreNotifications.style.display = hasMore ? 'block' : 'none'; + } + + updateHint(); + refreshTimes(); + + return true; + } + async function blockActorPubkey(pubkey) { const target = String(pubkey || '').trim(); if (!target || target === currentPubkey) return; @@ -1092,6 +1146,29 @@ import { createProfileCache } from './js/profile-cache.mjs'; renderNotifications(); } + function pruneNotificationMaps() { + if (notificationsById.size > MAX_STORED_NOTIFICATIONS) { + const sorted = Array.from(notificationsById.values()) + .sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); + const keep = new Set(sorted.slice(0, MAX_STORED_NOTIFICATIONS).map((n) => n.id)); + for (const id of Array.from(notificationsById.keys())) { + if (!keep.has(id)) { + notificationsById.delete(id); + unreadNotificationsById.delete(id); + } + } + } + if (postImageCache.size > MAX_POST_IMAGE_CACHE) { + const excess = postImageCache.size - MAX_POST_IMAGE_CACHE; + let removed = 0; + for (const key of Array.from(postImageCache.keys())) { + if (removed >= excess) break; + postImageCache.delete(key); + removed++; + } + } + } + async function addNotificationFromEvent(evt) { if (!evt || !evt.id || !evt.pubkey) return; if (evt.pubkey === currentPubkey) return; @@ -1138,7 +1215,15 @@ import { createProfileCache } from './js/profile-cache.mjs'; isUnread: createdAt > notificationsReadAt }); - renderNotifications(); + if (divNotifications.children.length > 0) { + prependNotificationRow(item); + } else { + renderNotifications(); + } + + if (notificationsById.size > MAX_STORED_NOTIFICATIONS + 100) { + pruneNotificationMaps(); + } } function eventTargetsCurrentPubkey(evt) { @@ -1162,6 +1247,8 @@ import { createProfileCache } from './js/profile-cache.mjs'; await addNotificationFromEvent(evt); } + pruneNotificationMaps(); + console.log('[notifications] cache preload complete', { cachedCount: Array.isArray(cached) ? cached.length : 0, loadedCount: filtered.length @@ -1184,6 +1271,8 @@ import { createProfileCache } from './js/profile-cache.mjs'; await addNotificationFromEvent(evt); } + pruneNotificationMaps(); + console.log('[notifications] relay hydration complete', { relayCount: sorted.length }); @@ -1297,6 +1386,19 @@ import { createProfileCache } from './js/profile-cache.mjs'; (async function main() { try { initHamburgerMenu(); + + document.addEventListener('click', (event) => { + const target = event.target; + if (!(target instanceof Element)) return; + if (target.closest('.notif-right')) return; + const panels = document.querySelectorAll('.notif-menu-panel'); + for (const panel of panels) { + if (panel.style.display !== 'none') { + panel.style.display = 'none'; + } + } + }); + syncNotificationFiltersFromSettings({}); const divSvgHam = document.getElementById('divSvgHam'); diff --git a/www/post.html b/www/post.html index b2939e4..d4240cd 100644 --- a/www/post.html +++ b/www/post.html @@ -240,6 +240,7 @@ import { initPostCards } from './js/post-interactions2.mjs'; const MIN_BOOTSTRAP_POSTS = 10; const BOOTSTRAP_WINDOWS_SECONDS = [24 * 3600, 7 * 24 * 3600, 30 * 24 * 3600, 90 * 24 * 3600]; const BOOTSTRAP_PER_WINDOW_LIMIT = 120; + const MAX_STORED_POSTS = 500; const urlParams = new URLSearchParams(window.location.search); const profileParamRaw = (urlParams.get('profile') || '').trim(); @@ -612,11 +613,24 @@ import { initPostCards } from './js/post-interactions2.mjs'; return eTags.every(t => t[3] === 'mention'); } + function prunePostsArray() { + posts.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); + if (posts.length > MAX_STORED_POSTS) { + posts.length = MAX_STORED_POSTS; + postIds.clear(); + posts.forEach((p) => postIds.add(p.id)); + for (const id of Array.from(renderedPostIds)) { + if (!postIds.has(id)) renderedPostIds.delete(id); + } + } + } + function upsertPost(evt) { if (!evt?.id || postIds.has(evt.id) || !isOriginalPost(evt)) return false; if (isEventMuted(evt)) return false; postIds.add(evt.id); posts.push(evt); + if (!isEventMode && posts.length > MAX_STORED_POSTS + 50) prunePostsArray(); return true; }