|
|
|
|
@@ -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');
|
|
|
|
|
|