diff --git a/www/js/version.json b/www/js/version.json index 0021fe7..a160e6f 100644 --- a/www/js/version.json +++ b/www/js/version.json @@ -1,5 +1,5 @@ { - "VERSION": "v0.7.87", - "VERSION_NUMBER": "0.7.87", - "BUILD_DATE": "2026-07-01T13:33:29.318Z" + "VERSION": "v0.7.88", + "VERSION_NUMBER": "0.7.88", + "BUILD_DATE": "2026-07-01T19:56:16.258Z" } diff --git a/www/notifications.html b/www/notifications.html index d1ce825..4f89753 100644 --- a/www/notifications.html +++ b/www/notifications.html @@ -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; @@ -424,6 +456,9 @@ import { createProfileCache } from './js/profile-cache.mjs'; const unreadNotificationsById = new Map(); const timeElements = new Map(); const postImageCache = new Map(); + const targetEventCache = new Map(); + let interactions = null; + let notifInteractionsSubId = null; let visibleNotificationsCount = NOTIFICATIONS_PAGE_SIZE; const profileCache = createProfileCache({ @@ -443,6 +478,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)', @@ -1032,6 +1101,62 @@ import { createProfileCache } from './js/profile-cache.mjs'; } }); + // 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 hidden interaction bar appended to + // the row. Menu items trigger clicks on the corresponding buttons + // in that hidden bar. + if (item.targetEventId && interactions) { + const targetPubkey = item.targetPubkey || currentPubkey; + const targetEventData = { id: item.targetEventId, pubkey: targetPubkey }; + + const hiddenBar = interactions.renderInteractionBar( + item.targetEventId, + targetEventData, + { currentPubkey } + ); + hiddenBar.style.display = 'none'; + hiddenBar.dataset.notifHiddenBar = item.targetEventId; + row.appendChild(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'; @@ -1052,6 +1177,28 @@ import { createProfileCache } from './js/profile-cache.mjs'; 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)); @@ -1073,6 +1220,7 @@ import { createProfileCache } from './js/profile-cache.mjs'; updateHint(); refreshTimes(); renderNotificationFilters(); + wireNotificationInteractions(); } function prependNotificationRow(item) { @@ -1122,6 +1270,10 @@ import { createProfileCache } from './js/profile-cache.mjs'; updateHint(); refreshTimes(); + if (item.targetEventId && interactions) { + interactions.fetchExistingInteractions([item.targetEventId], { currentPubkey }); + } + return true; } @@ -1204,7 +1356,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, @@ -1212,11 +1403,14 @@ import { createProfileCache } from './js/profile-cache.mjs'; secondaryImage, description, targetEventId, + targetPubkey, isUnread: createdAt > notificationsReadAt - }); + }; + + notificationsById.set(evt.id, notifItem); if (divNotifications.children.length > 0) { - prependNotificationRow(item); + prependNotificationRow(notifItem); } else { renderNotifications(); } @@ -1364,6 +1558,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) {