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;
}