Compare commits

..

4 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
Laan Tungir
04405da933 Fix feed page dropped posts: out-of-order events and race condition
prependPostCard() now inserts posts in the correct sorted position within
the existing DOM instead of falling back to a full renderFeed() rebuild
when a post is older than the first visible post. This handles relays
that deliver events out of order without wiping the feed.

Also fixed a race condition in fetchFeedWindow: the fire-and-forget
ndkFetchEvents callback was calling renderFeed() after bootstrap
completed, which wiped prepended live posts and caused concurrent
rebuild races. Now it only calls renderFeed() if the feed hasn't been
rendered yet (renderedPostIds.size === 0).

Same prependPostCard sorted-insert fix applied to post.html.
2026-07-01 09:22:33 -04:00
5 changed files with 578 additions and 214 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;
}
@@ -488,21 +502,33 @@ import { initPostCards } from './js/post-interactions2.mjs';
return false;
}
// Only prepend if the post is newer than the first currently visible post.
const firstChild = divFeed.firstElementChild;
const firstPostId = firstChild ? firstChild.getAttribute('data-post-id') : null;
const firstPost = firstPostId ? posts.find((p) => p.id === firstPostId) : null;
const postCreatedAt = post.created_at || 0;
if (!firstPost || (post.created_at || 0) < (firstPost.created_at || 0)) {
// Post is older than the first visible post (or unknown) — full rebuild for safety.
renderFeed();
return false;
// Find the correct insertion point by scanning visible children's created_at.
// Posts are displayed newest-first, so we insert before the first child
// that is older than (or equal to) the new post.
const postEl = cards.createCard(post, { currentPubkey, autoWire: false, autoplayVideo: autoplayVideosEnabled });
let inserted = false;
for (const child of divFeed.children) {
const childId = child.getAttribute('data-post-id');
if (!childId) continue;
const childPost = posts.find((p) => p.id === childId);
if (!childPost) continue;
const childCreatedAt = childPost.created_at || 0;
if (postCreatedAt >= childCreatedAt) {
divFeed.insertBefore(postEl, child);
inserted = true;
break;
}
}
const postEl = cards.createCard(post, { currentPubkey, autoWire: false, autoplayVideo: autoplayVideosEnabled });
divFeed.insertBefore(postEl, divFeed.firstChild);
renderedPostIds.add(post.id);
if (!inserted) {
// Post is older than all visible posts — append at the bottom.
divFeed.appendChild(postEl);
}
renderedPostIds.add(post.id);
cards.wireInteractions([post.id], { currentPubkey });
// Trim DOM to displayCount.
@@ -543,7 +569,11 @@ import { initPostCards } from './js/post-interactions2.mjs';
(Array.isArray(fresh) ? fresh : []).forEach((evt1) => {
upsertFeedPost(evt1);
});
if (posts.length > 0) {
// Only do a full re-render if the feed hasn't been rendered yet.
// If the feed is already rendered, the posts are in the array and
// will appear when the user clicks "See More" or on next full render.
// This prevents the relay fetch callback from wiping prepended live posts.
if (posts.length > 0 && renderedPostIds.size === 0) {
renderFeed();
}
}).catch(() => {});

View File

@@ -1,5 +1,5 @@
{
"VERSION": "v0.7.85",
"VERSION_NUMBER": "0.7.85",
"BUILD_DATE": "2026-07-01T11:59:39.440Z"
"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;
}
@@ -667,21 +681,33 @@ import { initPostCards } from './js/post-interactions2.mjs';
return false;
}
// Only prepend if the post is newer than the first currently visible post.
const firstChild = divFeed.firstElementChild;
const firstPostId = firstChild ? firstChild.getAttribute('data-post-id') : null;
const firstPost = firstPostId ? posts.find((p) => p.id === firstPostId) : null;
const postCreatedAt = post.created_at || 0;
if (!firstPost || (post.created_at || 0) < (firstPost.created_at || 0)) {
// Post is older than the first visible post (or unknown) — full rebuild for safety.
renderFeed();
return false;
// Find the correct insertion point by scanning visible children's created_at.
// Posts are displayed newest-first, so we insert before the first child
// that is older than (or equal to) the new post.
const postEl = cards.createCard(post, { currentPubkey, autoWire: false, autoplayVideo: false });
let inserted = false;
for (const child of divFeed.children) {
const childId = child.getAttribute('data-post-id');
if (!childId) continue;
const childPost = posts.find((p) => p.id === childId);
if (!childPost) continue;
const childCreatedAt = childPost.created_at || 0;
if (postCreatedAt >= childCreatedAt) {
divFeed.insertBefore(postEl, child);
inserted = true;
break;
}
}
const postEl = cards.createCard(post, { currentPubkey, autoWire: false, autoplayVideo: false });
divFeed.insertBefore(postEl, divFeed.firstChild);
renderedPostIds.add(post.id);
if (!inserted) {
// Post is older than all visible posts — append at the bottom.
divFeed.appendChild(postEl);
}
renderedPostIds.add(post.id);
cards.wireInteractions([post.id], { currentPubkey });
// Trim DOM to displayCount.