Compare commits

..

3 Commits

Author SHA1 Message Date
Laan Tungir
9400508f3e Fix notifications: keep interaction bar detached so only dropdown menu shows 2026-07-01 16:50:21 -04:00
Laan Tungir
7edee38ea1 Add Like/Zap/Nutzap/Comment/Quote to notification dropdown menu via hidden interaction bar 2026-07-01 15:56:16 -04:00
Laan Tungir
1a17bef1ac 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.
2026-07-01 09:33:29 -04:00
5 changed files with 527 additions and 191 deletions

View File

@@ -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

View File

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

View File

@@ -1,5 +1,5 @@
{
"VERSION": "v0.7.86",
"VERSION_NUMBER": "0.7.86",
"BUILD_DATE": "2026-07-01T13:22:33.456Z"
"VERSION": "v0.7.89",
"VERSION_NUMBER": "0.7.89",
"BUILD_DATE": "2026-07-01T20:50:21.925Z"
}

View File

@@ -173,6 +173,32 @@
color: var(--accent-color);
}
.notif-menu-item {
width: 100%;
text-align: left;
border: var(--button-border-width) solid var(--button-border-color);
border-radius: var(--button-border-radius);
background: var(--button-background-color);
color: var(--button-color);
padding: 6px 8px;
cursor: pointer;
font: inherit;
font-size: 90%;
margin-bottom: 2px;
}
.notif-menu-item:hover {
color: var(--button-hover-color);
border-color: var(--button-hover-color);
}
.notif-menu-divider {
height: 1px;
background: var(--muted-color);
margin: 4px 0;
border: none;
}
.svgBtn {
fill: none;
stroke: var(--button-color);
@@ -377,7 +403,13 @@
publishEvent,
ensureMuteListLoaded,
isEventMuted,
addMute
addMute,
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
walletGetMints,
walletGetBalance,
getRelayData
} from './js/init-ndk.mjs';
import { HamburgerMorphing } from './hamburger_morphing/hamburger.mjs';
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
@@ -385,7 +417,7 @@
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
import { createProfileCache } from './js/profile-cache.mjs';
import { formatTimeAgo } from './js/post-interactions.mjs';
import { formatTimeAgo, initInteractions } from './js/post-interactions.mjs';
const versionInfo = await getVersion();
const VERSION = versionInfo.VERSION;
@@ -393,6 +425,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 = `<svg class="svgBtn svgBtn_unsel" viewBox="0 0 10 10"><rect x="2" y="2" width="6" height="6" /></svg>`;
const SVG_CHECKED = `<svg class="svgBtn svgBtn_sel" viewBox="0 0 10 10"><path d="M2,6 L4,8 L8,2 " /></svg>`;
const NOTIFICATION_FILTER_DEFS = [
@@ -422,6 +456,10 @@ import { createProfileCache } from './js/profile-cache.mjs';
const unreadNotificationsById = new Map();
const timeElements = new Map();
const postImageCache = new Map();
const targetEventCache = new Map();
const detachedInteractionBars = new Map();
let interactions = null;
let notifInteractionsSubId = null;
let visibleNotificationsCount = NOTIFICATIONS_PAGE_SIZE;
const profileCache = createProfileCache({
@@ -441,6 +479,40 @@ import { createProfileCache } from './js/profile-cache.mjs';
const divFooterRight = document.getElementById('divFooterRight');
const divNotificationFiltersList = document.getElementById('divNotificationFiltersList');
// Initialize the post-interactions module so notification dropdown menus
// can offer Like / Zap / Nutzap / Quote / Comment on the referenced post.
// The interaction bar itself is rendered hidden inside each notification
// row; menu items trigger clicks on its buttons.
interactions = initInteractions({
subscribe,
publishEvent,
getPubkey,
ndkFetchEvents,
queryCache,
fetchCachedProfile,
storeProfile,
walletPayInvoice,
walletSendNutzap,
walletFetchMintList,
walletGetMints,
walletGetBalance,
getRelayData,
getUserSettings,
patchUserSettings,
onCommentIntent: ({ postId }) => {
if (!postId) return false;
window.open('./post-feed.html?event=' + encodeURIComponent(postId), '_blank');
return true;
},
onMuteIntent: async ({ eventData }) => {
const targetPubkey = String(eventData?.pubkey || '').trim();
if (!targetPubkey || targetPubkey === currentPubkey) return;
const ok = window.confirm(`Block ${targetPubkey.slice(0, 8)}${targetPubkey.slice(-4)}?`);
if (!ok) return;
await blockActorPubkey(targetPubkey);
}
});
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
@@ -883,6 +955,252 @@ 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';
}
});
// Add interaction menu items (Like, Comment, Quote, Zap, Nutzap) when
// the notification references a target post. The actual interaction
// logic (publishing, zap dialogs, balance checks) is handled by the
// post-interactions module via a detached interaction bar (kept out of
// the DOM so it never shows up in the row). Menu items trigger clicks
// on the corresponding buttons in that detached bar.
if (item.targetEventId && interactions) {
const targetPubkey = item.targetPubkey || currentPubkey;
const targetEventData = { id: item.targetEventId, pubkey: targetPubkey };
let hiddenBar = detachedInteractionBars.get(item.id);
if (!hiddenBar) {
hiddenBar = interactions.renderInteractionBar(
item.targetEventId,
targetEventData,
{ currentPubkey }
);
detachedInteractionBars.set(item.id, hiddenBar);
}
const addMenuBtn = (label, selector) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'notif-menu-item';
btn.textContent = label;
btn.addEventListener('click', (ev) => {
ev.stopPropagation();
const target = hiddenBar.querySelector(selector);
if (target) target.click();
menuPanel.style.display = 'none';
});
menuPanel.appendChild(btn);
};
addMenuBtn('Like', '.interaction-item.like');
// Comment opens the post-feed thread page in a new tab.
const commentBtn = document.createElement('button');
commentBtn.type = 'button';
commentBtn.className = 'notif-menu-item';
commentBtn.textContent = 'Comment';
commentBtn.addEventListener('click', (ev) => {
ev.stopPropagation();
window.open('./post-feed.html?event=' + encodeURIComponent(item.targetEventId), '_blank');
menuPanel.style.display = 'none';
});
menuPanel.appendChild(commentBtn);
addMenuBtn('Quote', '.interaction-item.quote');
addMenuBtn('Zap', '.interaction-item.zap');
addMenuBtn('Nutzap', '.interaction-item.nutzap');
const divider = document.createElement('hr');
divider.className = 'notif-menu-divider';
menuPanel.appendChild(divider);
}
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 wireNotificationInteractions() {
if (!interactions) return;
const targetIds = Array.from(new Set(
Array.from(notificationsById.values())
.map((item) => item.targetEventId)
.filter(Boolean)
));
if (targetIds.length === 0) return;
interactions.fetchExistingInteractions(targetIds, { currentPubkey });
if (notifInteractionsSubId) {
interactions.unsubscribeFromInteractions(notifInteractionsSubId);
notifInteractionsSubId = null;
}
notifInteractionsSubId = interactions.subscribeToInteractions(targetIds, {
currentPubkey
});
}
function renderNotifications() {
const items = Array.from(notificationsById.values())
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
@@ -890,174 +1208,10 @@ import { createProfileCache } from './js/profile-cache.mjs';
divNotifications.innerHTML = '';
timeElements.clear();
detachedInteractionBars.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);
}
@@ -1069,6 +1223,61 @@ import { createProfileCache } from './js/profile-cache.mjs';
updateHint();
refreshTimes();
renderNotificationFilters();
wireNotificationInteractions();
}
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();
if (item.targetEventId && interactions) {
interactions.fetchExistingInteractions([item.targetEventId], { currentPubkey });
}
return true;
}
async function blockActorPubkey(pubkey) {
@@ -1092,6 +1301,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;
@@ -1127,7 +1359,46 @@ import { createProfileCache } from './js/profile-cache.mjs';
const targetEventId = getNotificationTargetEventId(evt);
notificationsById.set(evt.id, {
// Resolve the target event's pubkey so interaction handlers (like,
// zap, nutzap) target the correct post author. Most notifications
// reference the current user's own post, so default to currentPubkey
// and refine from cache/relays when available.
let targetPubkey = currentPubkey;
if (targetEventId) {
if (targetEventCache.has(targetEventId)) {
const cachedTarget = targetEventCache.get(targetEventId);
if (cachedTarget?.pubkey) targetPubkey = cachedTarget.pubkey;
} else {
try {
const cachedRefs = await queryCache({ ids: [targetEventId], limit: 1 });
const targetEvt = Array.isArray(cachedRefs)
? cachedRefs.find((e) => e?.id === targetEventId)
: null;
if (targetEvt) {
targetEventCache.set(targetEventId, targetEvt);
if (targetEvt.pubkey) targetPubkey = targetEvt.pubkey;
}
} catch (_) {}
// Async relay hydration (non-blocking).
void ndkFetchEvents({ ids: [targetEventId], limit: 1 })
.then((relayRefs) => {
const relayEvt = Array.isArray(relayRefs)
? relayRefs.find((e) => e?.id === targetEventId)
: null;
if (relayEvt) {
targetEventCache.set(targetEventId, relayEvt);
const existing = notificationsById.get(evt.id);
if (existing && existing.targetPubkey !== relayEvt.pubkey) {
existing.targetPubkey = relayEvt.pubkey;
}
}
})
.catch(() => {});
}
}
const notifItem = {
id: evt.id,
created_at: createdAt,
kind,
@@ -1135,10 +1406,21 @@ import { createProfileCache } from './js/profile-cache.mjs';
secondaryImage,
description,
targetEventId,
targetPubkey,
isUnread: createdAt > notificationsReadAt
});
};
renderNotifications();
notificationsById.set(evt.id, notifItem);
if (divNotifications.children.length > 0) {
prependNotificationRow(notifItem);
} else {
renderNotifications();
}
if (notificationsById.size > MAX_STORED_NOTIFICATIONS + 100) {
pruneNotificationMaps();
}
}
function eventTargetsCurrentPubkey(evt) {
@@ -1162,6 +1444,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 +1468,8 @@ import { createProfileCache } from './js/profile-cache.mjs';
await addNotificationFromEvent(evt);
}
pruneNotificationMaps();
console.log('[notifications] relay hydration complete', {
relayCount: sorted.length
});
@@ -1219,6 +1505,7 @@ import { createProfileCache } from './js/profile-cache.mjs';
notificationsReadAt = readAt;
notificationsById.clear();
unreadNotificationsById.clear();
detachedInteractionBars.clear();
visibleNotificationsCount = NOTIFICATIONS_PAGE_SIZE;
renderNotifications();
@@ -1275,6 +1562,11 @@ import { createProfileCache } from './js/profile-cache.mjs';
notificationsSub = null;
}
if (notifInteractionsSubId && interactions) {
interactions.unsubscribeFromInteractions(notifInteractionsSubId);
notifInteractionsSubId = null;
}
disconnect();
if (window.NOSTR_LOGIN_LITE?.logout) {
@@ -1297,6 +1589,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');

View File

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