Files
client/www/notifications.html
T
2026-04-17 16:52:51 -04:00

1409 lines
43 KiB
HTML

<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>LOVE</title>
<link rel="stylesheet" href="./css/client.css" />
<script>
(function () {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
document.documentElement.classList.add('dark-mode');
if (document.body) {
document.body.classList.add('dark-mode');
}
}
})();
</script>
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
<style>
#divBody {
flex-direction: column !important;
flex-wrap: nowrap !important;
align-items: center !important;
justify-content: flex-start !important;
align-content: flex-start !important;
gap: 10px;
overflow-y: auto;
padding: 10px;
}
#divNotificationsHint,
#divNotifications {
width: 80%;
min-width: 300px;
max-width: 800px;
}
#divNotificationsHint {
text-align: center;
color: var(--muted-color);
font-size: 85%;
margin-top: 4px;
}
#divNotifications {
display: flex;
flex-direction: column;
gap: 5px;
background: transparent;
}
#btnViewMoreNotifications {
width: 80%;
min-width: 300px;
max-width: 800px;
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: 10px 12px;
cursor: pointer;
font-size: 90%;
display: none;
}
#btnViewMoreNotifications:hover {
color: var(--button-hover-color);
border-color: var(--button-hover-color);
}
.notif-item {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 12px;
padding: 12px;
border: 1px solid var(--muted-color);
border-radius: 12px;
background: var(--secondary-color);
}
.notif-avatar {
width: 48px;
height: 48px;
border-radius: 10px;
object-fit: cover;
flex: 0 0 48px;
background: var(--background-color);
border: 1px solid var(--muted-color);
}
.notif-avatar-secondary {
opacity: 0.9;
}
.notif-avatar-placeholder {
display: block;
box-sizing: border-box;
background: transparent;
text-decoration: none;
border: 1px solid var(--muted-color);
}
.notif-avatar-placeholder:hover {
border-color: var(--accent-color);
}
.notif-content {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
}
.notif-name {
font-weight: 700;
color: var(--muted-color);
font-size: 95%;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notif-desc {
color: var(--primary-color);
font-size: 90%;
line-height: 1.25;
white-space: normal;
overflow: visible;
text-overflow: clip;
word-break: break-word;
filter: grayscale(1);
}
.notif-right {
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
min-width: 32px;
}
.notif-time {
color: var(--muted-color);
font-size: 80%;
line-height: 1.1;
white-space: nowrap;
margin-top: 2px;
}
.notif-menu-btn {
border: none;
background: transparent;
color: var(--primary-color);
cursor: pointer;
font-size: 18px;
line-height: 1;
padding: 0 2px;
}
.notif-menu-btn:hover {
color: var(--accent-color);
}
.svgBtn {
fill: none;
stroke: var(--button-color);
stroke-width: 1.98;
stroke-linecap: round;
stroke-linejoin: round;
transition: 0.2s;
height: 1.1em;
width: 1.1em;
display: block;
}
.svgBtn_unsel {
stroke: var(--muted-color);
}
.svgBtn_sel {
stroke: var(--button-color);
}
.divSvg:hover,
.divSvg:hover .svgBtn {
stroke: var(--button-hover-color);
cursor: pointer;
}
#divNotificationFiltersWrap {
width: 100%;
display: flex;
flex-direction: column;
gap: 6px;
padding: 4px 0;
}
#divNotificationFiltersTitle {
font-family: pageKanji;
font-size: 85%;
color: var(--muted-color);
padding-top: 6px;
}
#divNotificationFiltersList {
font-family: var(--font-family);
display: flex;
flex-direction: column;
gap: 4px;
}
.notif-filter-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
color: var(--primary-color);
border-bottom: 1px solid var(--muted-color);
padding: 4px 0;
}
.notif-filter-label {
flex: 1;
font-size: 100%;
line-height: 1.2;
}
.notif-filter-controls {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.notif-filter-count {
font-size: 90%;
color: var(--muted-color);
min-width: 24px;
text-align: right;
}
.notif-filter-toggle {
width: 16px;
height: 16px;
flex: 0 0 16px;
display: inline-flex;
align-items: center;
justify-content: center;
}
#divFooterCenter {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
#btnMarkAllRead,
#btnSetRead24h {
font: inherit;
background: transparent;
border: none;
color: var(--primary-color);
cursor: pointer;
padding: 0;
text-decoration: underline;
text-underline-offset: 2px;
}
#btnMarkAllRead:hover,
#btnSetRead24h:hover {
color: var(--accent-color);
}
</style>
</head>
<body>
<div id="divSvgHam" class="divHeaderButtons"></div>
<div id="divHeader">
<div id="divHeaderFlexLeft"></div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText">LOVE</div>
</div>
<div id="divHeaderFlexRight"></div>
</div>
<div id="divBody">
<div id="divNotificationsHint">Loading notifications…</div>
<div id="divNotifications"></div>
<button id="btnViewMoreNotifications" type="button">View more</button>
</div>
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox">
<button id="btnMarkAllRead" type="button">Mark all as read</button>
<button id="btnSetRead24h" type="button">Set read to 24h ago</button>
</div>
<div id="divFooterRight" class="divFooterBox"></div>
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
</div>
<div id="divSideNav">
<div id="divSideNavHeader"></div>
<div id="divSideNavBody">
<div id="divNotificationFiltersWrap">
<div id="divNotificationFiltersList">Loading filters...</div>
</div>
</div>
<div id="divAiSection" class="sidenavSection">
<div id="divAiSectionTitle" class="sidenavSectionTitle">AI</div>
<div id="divAiList" class="sidenavSectionList">
<div id="divAiProvidersList">No saved providers yet.</div>
</div>
</div>
<div id="divRelaySection">
<div id="divRelaySectionTitle">リレー</div>
<div id="divRelayList">Loading relays...</div>
</div>
<div id="divBlossomSection">
<div id="divBlossomSectionTitle">ブロッサム</div>
<div id="divBlossomList">Loading blossom servers...</div>
</div>
<div id="divVersionBar">
<span id="versionDisplay">loading...</span>
<div id="divVersionBarButtons">
<button id="themeToggleButton" title="Toggle Dark/Light Mode">
<div id="themeToggleHamburgerContainer"></div>
</button>
<button id="logoutButton" title="Logout">
<div id="logoutHamburgerContainer"></div>
</button>
</div>
</div>
</div>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script type="module">
import {
initNDKPage,
getPubkey,
injectHeaderAvatar,
subscribe,
disconnect,
getVersion,
updateVersionDisplay,
getUserSettings,
patchUserSettings,
onUserSettings,
fetchCachedProfile,
ndkFetchEvents,
storeProfile,
queryCache,
publishEvent,
ensureMuteListLoaded,
isEventMuted,
addMute
} from './js/init-ndk.mjs';
import { HamburgerMorphing } from './hamburger_morphing/hamburger.mjs';
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
import { createProfileCache } from './js/profile-cache.mjs';
import { formatTimeAgo } from './js/post-interactions.mjs';
const versionInfo = await getVersion();
const VERSION = versionInfo.VERSION;
console.log(`[notifications.html ${VERSION}] Loading...`);
const NOTIFICATION_KINDS = [1, 3, 4, 6, 7, 9735];
const NOTIFICATIONS_PAGE_SIZE = 40;
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 = [
{ key: 'kind7', kind: 7, label: 'Reactions' },
{ key: 'kind6', kind: 6, label: 'Reposts' },
{ key: 'kind9735', kind: 9735, label: 'Zaps' },
{ key: 'kind3', kind: 3, label: 'Follows' },
{ key: 'kind1', kind: 1, label: 'Mentions/replies/comments' },
{ key: 'kind4', kind: 4, label: 'DMs' }
];
let updateIntervalId = null;
let currentPubkey = null;
let hamburgerInstance = null;
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
let isNavOpen = false;
let pageSettings = {};
let notificationsReadAt = 0;
let unsubscribeUserSettings = null;
let notificationsSub = null;
let notificationFilters = {};
const notificationsById = new Map();
const unreadNotificationsById = new Map();
const timeElements = new Map();
const postImageCache = new Map();
let visibleNotificationsCount = NOTIFICATIONS_PAGE_SIZE;
const profileCache = createProfileCache({
fetchCachedProfile,
ndkFetchEvents,
storeProfile,
queryCache,
refreshIntervalMs: 20 * 60 * 1000
});
const divBody = document.getElementById('divBody');
const divNotifications = document.getElementById('divNotifications');
const divNotificationsHint = document.getElementById('divNotificationsHint');
const btnViewMoreNotifications = document.getElementById('btnViewMoreNotifications');
const divSideNav = document.getElementById('divSideNav');
const divFooterCenter = document.getElementById('divFooterCenter');
const divFooterRight = document.getElementById('divFooterRight');
const divNotificationFiltersList = document.getElementById('divNotificationFiltersList');
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
hamburgerInstance.animateTo('burger');
}
function openNav() {
divSideNav.style.zIndex = 3;
divSideNav.style.width = 'clamp(400px, 50vw, 600px)';
isNavOpen = true;
if (hamburgerInstance) hamburgerInstance.animateTo('arrow_left');
if (!logoutHamburger) {
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
logoutHamburger.animateTo('x');
}
if (!themeToggleHamburger) {
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
const savedTheme = localStorage.getItem('theme');
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
}
}
function closeNav() {
divSideNav.style.width = '0vw';
divSideNav.style.zIndex = -1;
isNavOpen = false;
if (hamburgerInstance) hamburgerInstance.animateTo('burger');
}
function toggleNav() {
if (isNavOpen) closeNav();
else openNav();
}
function normalizeNotificationFilters(rawFilters) {
const normalized = {};
const source = rawFilters && typeof rawFilters === 'object' ? rawFilters : {};
for (const def of NOTIFICATION_FILTER_DEFS) {
normalized[def.key] = typeof source[def.key] === 'boolean' ? source[def.key] : true;
}
return normalized;
}
function getFilterDefByKind(kind) {
return NOTIFICATION_FILTER_DEFS.find((def) => def.kind === Number(kind || 0)) || null;
}
function isNotificationKindEnabled(kind) {
const def = getFilterDefByKind(kind);
if (!def) return true;
return Boolean(notificationFilters?.[def.key]);
}
function getNotificationKindCounts() {
const counts = Object.fromEntries(NOTIFICATION_FILTER_DEFS.map((def) => [def.key, 0]));
for (const item of unreadNotificationsById.values()) {
const def = getFilterDefByKind(item?.kind);
if (!def) continue;
counts[def.key] = Number(counts[def.key] || 0) + 1;
}
return counts;
}
function renderNotificationFilters() {
if (!divNotificationFiltersList) return;
const counts = getNotificationKindCounts();
divNotificationFiltersList.innerHTML = NOTIFICATION_FILTER_DEFS.map((def) => {
const isEnabled = Boolean(notificationFilters?.[def.key]);
const count = Number(counts?.[def.key] || 0);
return `
<div class="notif-filter-row">
<div class="notif-filter-label">${def.label}</div>
<div class="notif-filter-controls">
<span class="notif-filter-count">${count}</span>
<div class="divSvg notif-filter-toggle" data-filter-key="${def.key}" role="button" tabindex="0" aria-label="Toggle ${def.label}">
${isEnabled ? SVG_CHECKED : SVG_UNCHECKED}
</div>
</div>
</div>
`;
}).join('');
document.querySelectorAll('.notif-filter-toggle[data-filter-key]').forEach((node) => {
const toggle = async () => {
const key = String(node.getAttribute('data-filter-key') || '').trim();
if (!key) return;
await setNotificationFilter(key, !Boolean(notificationFilters?.[key]));
};
node.addEventListener('click', (event) => {
event.preventDefault();
void toggle();
});
node.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
void toggle();
}
});
});
}
function syncNotificationFiltersFromSettings(settings) {
notificationFilters = normalizeNotificationFilters(settings?.notifications?.filters);
renderNotificationFilters();
}
function pruneNotificationsByCurrentSettings() {
for (const [id, item] of Array.from(unreadNotificationsById.entries())) {
const createdAt = Number(item?.created_at || 0);
const isUnread = createdAt > notificationsReadAt;
if (!isUnread) {
unreadNotificationsById.delete(id);
}
}
for (const [id, item] of Array.from(notificationsById.entries())) {
const createdAt = Number(item?.created_at || 0);
const isUnread = createdAt > notificationsReadAt;
const isKindEnabled = isNotificationKindEnabled(item?.kind);
item.isUnread = isUnread;
if (!isUnread || !isKindEnabled) {
notificationsById.delete(id);
}
}
}
async function setNotificationFilter(key, enabled) {
if (!key) return;
notificationFilters = {
...normalizeNotificationFilters(notificationFilters),
[key]: Boolean(enabled)
};
pageSettings = {
...(pageSettings || {}),
notifications: {
...((pageSettings && pageSettings.notifications) || {}),
filters: { ...notificationFilters }
}
};
pruneNotificationsByCurrentSettings();
renderNotificationFilters();
renderNotifications();
try {
await patchUserSettings({
notifications: {
filters: { ...notificationFilters }
}
});
} catch (error) {
console.warn('[notifications] Failed to patch notifications.filters:', error?.message || error);
}
void hydrateNotificationsFromCache();
void hydrateNotificationsFromRelays();
}
function getEventTags(evt, tagName) {
const tags = Array.isArray(evt?.tags) ? evt.tags : [];
return tags.filter((tag) => Array.isArray(tag) && tag[0] === tagName);
}
function parseZapAmountSats(evt) {
const amountTag = getEventTags(evt, 'amount')[0];
if (!amountTag || amountTag.length < 2) return null;
const msats = Number(amountTag[1]);
if (!Number.isFinite(msats) || msats <= 0) return null;
return Math.floor(msats / 1000);
}
function describeNotification(evt) {
if (!evt || typeof evt !== 'object') return null;
if (!NOTIFICATION_KINDS.includes(evt.kind)) return null;
if (evt.kind === 7) {
let reaction = String(evt.content || '').trim();
if (reaction === '+') reaction = '❤️';
return reaction || '❤️';
}
if (evt.kind === 6) {
return 'reposted your post';
}
if (evt.kind === 9735) {
const sats = parseZapAmountSats(evt);
return sats ? `zapped you (${sats} sats)` : 'zapped you';
}
if (evt.kind === 3) {
const pTags = getEventTags(evt, 'p').map((tag) => String(tag[1] || ''));
if (!pTags.includes(currentPubkey)) return null;
return 'started following you';
}
if (evt.kind === 4) {
return 'sent you a message';
}
if (evt.kind === 1) {
const hasETag = getEventTags(evt, 'e').length > 0;
if (hasETag) {
const commentText = String(evt.content || '')
.replace(/\s+/g, ' ')
.trim();
return commentText || 'commented on your post';
}
return 'mentioned you';
}
return null;
}
function buildNotificationReason(evt, description) {
const kind = Number(evt?.kind || 0);
const pTags = getEventTags(evt, 'p').map((tag) => String(tag?.[1] || '').trim()).filter(Boolean);
const eTags = getEventTags(evt, 'e').map((tag) => String(tag?.[1] || '').trim()).filter(Boolean);
const targetEventId = getNotificationTargetEventId(evt);
const hasMyPTags = currentPubkey ? pTags.includes(currentPubkey) : false;
const detailBits = [
`kind=${kind}`,
`desc=${JSON.stringify(String(description || '').trim())}`,
`author=${String(evt?.pubkey || '').slice(0, 8)}…`,
`hasMyPTag=${hasMyPTags ? 'yes' : 'no'}`,
`pTags=${pTags.length}`,
`eTags=${eTags.length}`
];
if (eTags.length > 0) {
detailBits.push(`firstE=${eTags[0].slice(0, 8)}…`);
}
if (targetEventId) {
detailBits.push(`target=${targetEventId.slice(0, 8)}…`);
}
return `reason: ${detailBits.join(' | ')}`;
}
function fallbackName(pubkey) {
if (!pubkey || pubkey.length < 12) return '(unknown)';
return `${pubkey.slice(0, 8)}${pubkey.slice(-4)}`;
}
function normalizeMediaUrl(value) {
const raw = String(value || '').trim();
if (!raw) return '';
if (raw.startsWith('http://')) {
return `https://${raw.slice('http://'.length)}`;
}
return raw;
}
function getReferencedPostIds(evt) {
const eTags = getEventTags(evt, 'e');
return eTags
.map((tag) => String(tag?.[1] || '').trim())
.filter(Boolean);
}
function getNotificationTargetEventId(evt) {
const eTags = getEventTags(evt, 'e')
.filter((tag) => Array.isArray(tag) && typeof tag[1] === 'string' && tag[1].trim().length > 0);
if (eTags.length === 0) return '';
const byMarker = (marker) => eTags.find((tag) => String(tag?.[3] || '').toLowerCase() === marker);
const replyTag = byMarker('reply');
if (replyTag) return String(replyTag[1]).trim();
const rootTag = byMarker('root');
if (rootTag) return String(rootTag[1]).trim();
return String(eTags[eTags.length - 1][1] || '').trim();
}
function makeEventPermalink(eventId) {
const id = String(eventId || '').trim();
if (!id) return '';
return `./post.html?event=${encodeURIComponent(id)}`;
}
function extractFirstImageUrlFromEvent(evt) {
const tags = Array.isArray(evt?.tags) ? evt.tags : [];
for (const tag of tags) {
if (!Array.isArray(tag) || tag[0] !== 'imeta') continue;
for (const part of tag.slice(1)) {
const value = String(part || '').trim();
if (value.startsWith('url ')) {
return normalizeMediaUrl(value.slice(4).trim());
}
}
}
const content = String(evt?.content || '');
const markdownImage = content.match(/!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/i);
if (markdownImage?.[1]) {
return normalizeMediaUrl(markdownImage[1]);
}
const extensionImage = content.match(/(https?:\/\/[^\s"'<>]+\.(?:png|jpe?g|gif|webp|avif|svg))(\?[^\s"'<>]*)?/i);
if (extensionImage) {
return normalizeMediaUrl(`${extensionImage[1]}${extensionImage[2] || ''}`);
}
return '';
}
async function resolveSecondaryImage(evt, actor) {
if (!evt || typeof evt !== 'object') return '';
if (evt.kind === 3) {
return '';
}
if (evt.kind === 1 || evt.kind === 7 || evt.kind === 6) {
const postIds = Array.from(new Set(getReferencedPostIds(evt)));
if (postIds.length === 0) {
return '';
}
for (const postId of postIds) {
const cachedImage = postImageCache.get(postId);
if (typeof cachedImage === 'string' && cachedImage.length > 0) {
return cachedImage;
}
}
let refs = [];
try {
refs = await queryCache({ ids: postIds, limit: postIds.length });
} catch (_) {
refs = [];
}
let resolvedFromCache = '';
const unresolvedPostIds = [];
for (const postId of postIds) {
const ref = Array.isArray(refs)
? refs.find((candidate) => String(candidate?.id || '') === postId)
: null;
if (!ref) {
unresolvedPostIds.push(postId);
continue;
}
const imageUrl = extractFirstImageUrlFromEvent(ref);
postImageCache.set(postId, imageUrl || '');
if (!resolvedFromCache && imageUrl) {
resolvedFromCache = imageUrl;
}
}
if (unresolvedPostIds.length > 0) {
void ndkFetchEvents({ ids: unresolvedPostIds, limit: unresolvedPostIds.length })
.then((relayRefs) => {
for (const postId of unresolvedPostIds) {
const ref = Array.isArray(relayRefs)
? relayRefs.find((candidate) => String(candidate?.id || '') === postId)
: null;
const imageUrl = ref ? extractFirstImageUrlFromEvent(ref) : '';
postImageCache.set(postId, imageUrl || '');
}
})
.catch((error) => {
console.warn('[notifications] resolveSecondaryImage:relay hydration failed', {
postIds: unresolvedPostIds,
error: error?.message || error
});
});
}
return resolvedFromCache;
}
return '';
}
async function resolveActor(pubkey) {
const profile = await profileCache.fetchProfile(pubkey, {
allowNetwork: true,
backgroundRefresh: false,
logPrefix: '[notifications]'
});
const displayName = String(
profile?.display_name || profile?.name || profile?.username || fallbackName(pubkey)
).trim();
return {
pubkey,
name: displayName || fallbackName(pubkey),
picture: normalizeMediaUrl(String(profile?.picture || profile?.image || '').trim())
};
}
function updateHint() {
const total = notificationsById.size;
if (total === 0) {
divNotificationsHint.textContent = 'No unread notifications';
return;
}
const showing = Math.min(total, visibleNotificationsCount);
divNotificationsHint.textContent = `Showing ${showing} of ${total} notification${total === 1 ? '' : 's'}`;
}
function refreshTimes() {
for (const [el, timestamp] of timeElements.entries()) {
if (!el.isConnected) {
timeElements.delete(el);
continue;
}
el.textContent = formatTimeAgo(timestamp);
}
}
function renderNotifications() {
const items = Array.from(notificationsById.values())
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
const visibleItems = items.slice(0, visibleNotificationsCount);
divNotifications.innerHTML = '';
timeElements.clear();
for (const item of visibleItems) {
const row = document.createElement('div');
row.className = 'notif-item';
const avatar = document.createElement('img');
avatar.className = 'notif-avatar';
avatar.alt = '';
avatar.src = item.actor.picture || './favicon/favicon-dots2.ico';
avatar.onerror = () => {
console.warn('[notifications] actor avatar failed to load', {
eventId: item.id,
actor: item.actor.name,
src: avatar.src
});
avatar.onerror = null;
avatar.src = './favicon/favicon-dots2.ico';
};
if (item.actor?.pubkey) {
avatar.style.cursor = 'pointer';
avatar.title = 'Open profile';
avatar.addEventListener('click', (event) => {
event.stopPropagation();
const profileUrl = `./post.html?profile=${encodeURIComponent(item.actor.pubkey)}`;
window.open(profileUrl, '_blank', 'noopener,noreferrer');
});
}
const secondaryImageUrl = normalizeMediaUrl(item.secondaryImage || '');
const secondaryEventUrl = makeEventPermalink(item.targetEventId);
let secondaryNode;
if (secondaryImageUrl) {
const secondaryAvatar = document.createElement('img');
secondaryAvatar.className = 'notif-avatar notif-avatar-secondary';
secondaryAvatar.alt = '';
secondaryAvatar.src = secondaryImageUrl;
secondaryAvatar.onerror = () => {
console.debug('[notifications] secondary image failed to load', {
eventId: item.id,
src: secondaryAvatar.src
});
secondaryAvatar.style.visibility = 'hidden';
};
if (secondaryEventUrl) {
secondaryAvatar.style.cursor = 'pointer';
secondaryAvatar.title = 'Open related event';
secondaryAvatar.addEventListener('click', (event) => {
event.stopPropagation();
window.open(secondaryEventUrl, '_blank', 'noopener,noreferrer');
});
}
secondaryNode = secondaryAvatar;
} else if (secondaryEventUrl) {
const secondaryPlaceholder = document.createElement('a');
secondaryPlaceholder.className = 'notif-avatar notif-avatar-secondary notif-avatar-placeholder';
secondaryPlaceholder.href = secondaryEventUrl;
secondaryPlaceholder.target = '_blank';
secondaryPlaceholder.rel = 'noopener noreferrer';
secondaryPlaceholder.title = 'Open related event';
secondaryPlaceholder.setAttribute('aria-label', 'Open related event');
secondaryPlaceholder.addEventListener('click', (event) => {
event.stopPropagation();
});
secondaryNode = secondaryPlaceholder;
} else {
const secondaryAvatar = document.createElement('div');
secondaryAvatar.className = 'notif-avatar notif-avatar-secondary';
secondaryAvatar.style.visibility = 'hidden';
secondaryNode = secondaryAvatar;
}
const content = document.createElement('div');
content.className = 'notif-content';
const name = document.createElement('div');
name.className = 'notif-name';
name.textContent = item.actor.name;
const desc = document.createElement('div');
desc.className = 'notif-desc';
desc.textContent = item.description;
content.appendChild(name);
content.appendChild(desc);
const time = document.createElement('div');
time.className = 'notif-time';
time.textContent = formatTimeAgo(item.created_at || 0);
timeElements.set(time, item.created_at || 0);
const right = document.createElement('div');
right.className = 'notif-right';
const menuButton = document.createElement('button');
menuButton.className = 'notif-menu-btn';
menuButton.type = 'button';
menuButton.textContent = '⋯';
menuButton.title = 'Notification menu';
const menuPanel = document.createElement('div');
menuPanel.style.position = 'absolute';
menuPanel.style.right = '0';
menuPanel.style.top = '26px';
menuPanel.style.zIndex = '10';
menuPanel.style.display = 'none';
menuPanel.style.minWidth = '140px';
menuPanel.style.padding = '6px';
menuPanel.style.border = 'var(--border)';
menuPanel.style.borderRadius = 'var(--border-radius)';
menuPanel.style.background = 'var(--secondary-color)';
const blockButton = document.createElement('button');
blockButton.type = 'button';
blockButton.textContent = 'Block account';
blockButton.style.width = '100%';
blockButton.style.textAlign = 'left';
blockButton.style.border = 'var(--button-border-width) solid var(--button-border-color)';
blockButton.style.borderRadius = 'var(--button-border-radius)';
blockButton.style.background = 'var(--button-background-color)';
blockButton.style.color = 'var(--button-color)';
blockButton.style.padding = '6px 8px';
blockButton.style.cursor = 'pointer';
blockButton.addEventListener('click', async (event) => {
event.stopPropagation();
const actorPubkey = String(item?.actor?.pubkey || '').trim();
if (!actorPubkey) return;
const ok = window.confirm(`Block ${item.actor.name || actorPubkey.slice(0, 8)}?`);
if (!ok) return;
try {
await blockActorPubkey(actorPubkey);
} catch (error) {
console.warn('[notifications] Failed to block account:', error?.message || error);
} finally {
menuPanel.style.display = 'none';
}
});
menuPanel.appendChild(blockButton);
right.style.position = 'relative';
menuButton.addEventListener('click', (event) => {
event.stopPropagation();
menuPanel.style.display = menuPanel.style.display === 'none' ? 'block' : 'none';
});
document.addEventListener('click', (event) => {
if (!right.contains(event.target)) {
menuPanel.style.display = 'none';
}
});
right.appendChild(menuButton);
right.appendChild(menuPanel);
right.appendChild(time);
row.appendChild(avatar);
row.appendChild(secondaryNode);
row.appendChild(content);
row.appendChild(right);
divNotifications.appendChild(row);
}
const hasMore = items.length > visibleItems.length;
if (btnViewMoreNotifications) {
btnViewMoreNotifications.style.display = hasMore ? 'block' : 'none';
}
updateHint();
refreshTimes();
renderNotificationFilters();
}
async function blockActorPubkey(pubkey) {
const target = String(pubkey || '').trim();
if (!target || target === currentPubkey) return;
await addMute('p', target, false);
for (const [id, item] of notificationsById.entries()) {
if (String(item?.actor?.pubkey || '') === target) {
notificationsById.delete(id);
}
}
for (const [id, item] of unreadNotificationsById.entries()) {
if (String(item?.actorPubkey || '') === target) {
unreadNotificationsById.delete(id);
}
}
renderNotifications();
}
async function addNotificationFromEvent(evt) {
if (!evt || !evt.id || !evt.pubkey) return;
if (evt.pubkey === currentPubkey) return;
if (isEventMuted(evt)) return;
if (!eventTargetsCurrentPubkey(evt)) return;
if (notificationsById.has(evt.id)) return;
const description = describeNotification(evt);
if (!description) return;
const createdAt = Number(evt.created_at || 0);
if (createdAt <= notificationsReadAt) {
return;
}
const kind = Number(evt.kind || 0);
if (!unreadNotificationsById.has(evt.id)) {
unreadNotificationsById.set(evt.id, {
id: evt.id,
created_at: createdAt,
kind,
actorPubkey: String(evt.pubkey || '')
});
}
if (!isNotificationKindEnabled(kind)) {
renderNotificationFilters();
return;
}
const actor = await resolveActor(evt.pubkey);
const secondaryImage = await resolveSecondaryImage(evt, actor);
const targetEventId = getNotificationTargetEventId(evt);
notificationsById.set(evt.id, {
id: evt.id,
created_at: createdAt,
kind,
actor,
secondaryImage,
description,
targetEventId,
isUnread: createdAt > notificationsReadAt
});
renderNotifications();
}
function eventTargetsCurrentPubkey(evt) {
if (!evt || !Array.isArray(evt.tags)) return false;
return evt.tags.some((tag) => Array.isArray(tag) && tag[0] === 'p' && tag[1] === currentPubkey);
}
async function hydrateNotificationsFromCache() {
try {
const cached = await queryCache({
kinds: NOTIFICATION_KINDS,
limit: NOTIFICATIONS_PAGE_SIZE * 6
});
const filtered = (Array.isArray(cached) ? cached : [])
.filter((evt) => eventTargetsCurrentPubkey(evt))
.sort((a, b) => Number(b?.created_at || 0) - Number(a?.created_at || 0))
.slice(0, NOTIFICATIONS_PAGE_SIZE);
for (const evt of filtered) {
await addNotificationFromEvent(evt);
}
console.log('[notifications] cache preload complete', {
cachedCount: Array.isArray(cached) ? cached.length : 0,
loadedCount: filtered.length
});
} catch (error) {
console.warn('[notifications] cache preload failed:', error?.message || error);
}
}
async function hydrateNotificationsFromRelays() {
void ndkFetchEvents({
kinds: NOTIFICATION_KINDS,
'#p': [currentPubkey],
limit: NOTIFICATIONS_PAGE_SIZE
}).then(async (relayEvents) => {
const sorted = (Array.isArray(relayEvents) ? relayEvents : [])
.sort((a, b) => Number(b?.created_at || 0) - Number(a?.created_at || 0));
for (const evt of sorted) {
await addNotificationFromEvent(evt);
}
console.log('[notifications] relay hydration complete', {
relayCount: sorted.length
});
}).catch((error) => {
console.warn('[notifications] relay hydration failed:', error?.message || error);
});
}
async function subscribeNotifications() {
notificationsSub = subscribe(
{
kinds: NOTIFICATION_KINDS,
'#p': [currentPubkey],
limit: NOTIFICATIONS_PAGE_SIZE
},
{
closeOnEose: false,
cacheUsage: 'CACHE_FIRST'
}
);
window.addEventListener('ndkEvent', async (event) => {
const evt = event.detail;
try {
await addNotificationFromEvent(evt);
} catch (error) {
console.warn('[notifications] Failed to process event', error);
}
});
}
async function applyReadTimestamp(readAt) {
notificationsReadAt = readAt;
notificationsById.clear();
unreadNotificationsById.clear();
visibleNotificationsCount = NOTIFICATIONS_PAGE_SIZE;
renderNotifications();
try {
await patchUserSettings({
notifications: {
readAt
}
});
pageSettings = {
...(pageSettings || {}),
notifications: {
...((pageSettings && pageSettings.notifications) || {}),
readAt
}
};
} catch (error) {
console.error('[notifications] Failed to patch notifications.readAt:', error);
}
}
async function markAllAsRead() {
const now = Math.floor(Date.now() / 1000);
await applyReadTimestamp(now);
}
async function setReadTo24HoursAgo() {
const twentyFourHoursAgo = Math.floor(Date.now() / 1000) - (24 * 60 * 60);
await applyReadTimestamp(twentyFourHoursAgo);
}
const UpdateFooter = async () => {
try {
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
divFooterRight.textContent = notificationsById.size > 0
? `${notificationsById.size} unread`
: '';
} catch (error) {
console.error('[notifications] Error updating footer:', error);
}
};
const Logout = async () => {
if (updateIntervalId) {
clearInterval(updateIntervalId);
updateIntervalId = null;
}
if (notificationsSub?.close) {
notificationsSub.close();
notificationsSub = null;
}
disconnect();
if (window.NOSTR_LOGIN_LITE?.logout) {
await window.NOSTR_LOGIN_LITE.logout();
}
localStorage.clear();
sessionStorage.clear();
if (window.indexedDB) {
const databases = await window.indexedDB.databases();
for (const db of databases) {
if (db.name) window.indexedDB.deleteDatabase(db.name);
}
}
location.reload(true);
};
(async function main() {
try {
initHamburgerMenu();
syncNotificationFiltersFromSettings({});
const divSvgHam = document.getElementById('divSvgHam');
if (divSvgHam) divSvgHam.addEventListener('click', toggleNav);
const themeToggleButton = document.getElementById('themeToggleButton');
const logoutButton = document.getElementById('logoutButton');
const btnMarkAllRead = document.getElementById('btnMarkAllRead');
const btnSetRead24h = document.getElementById('btnSetRead24h');
if (themeToggleButton) {
themeToggleButton.addEventListener('click', () => {
isDarkMode = !isDarkMode;
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
document.documentElement.classList.toggle('dark-mode', isDarkMode);
document.body.classList.toggle('dark-mode', isDarkMode);
if (themeToggleHamburger) {
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
}
});
}
if (logoutButton) {
logoutButton.addEventListener('click', async () => {
try {
await Logout();
} catch (error) {
console.error('[notifications] Logout failed:', error);
}
});
}
if (btnMarkAllRead) {
btnMarkAllRead.addEventListener('click', markAllAsRead);
}
if (btnSetRead24h) {
btnSetRead24h.addEventListener('click', setReadTo24HoursAgo);
}
if (btnViewMoreNotifications) {
btnViewMoreNotifications.addEventListener('click', () => {
visibleNotificationsCount += NOTIFICATIONS_PAGE_SIZE;
renderNotifications();
});
}
await initNDKPage();
currentPubkey = await getPubkey();
await injectHeaderAvatar(currentPubkey);
await ensureMuteListLoaded();
try {
pageSettings = await getUserSettings();
} catch (error) {
console.warn('[notifications] getUserSettings failed:', error);
pageSettings = {};
}
notificationsReadAt = Number(pageSettings?.notifications?.readAt || 0) || 0;
syncNotificationFiltersFromSettings(pageSettings);
if (!unsubscribeUserSettings) {
unsubscribeUserSettings = onUserSettings((settings) => {
pageSettings = settings || {};
notificationsReadAt = Number(pageSettings?.notifications?.readAt || 0) || 0;
syncNotificationFiltersFromSettings(pageSettings);
pruneNotificationsByCurrentSettings();
renderNotifications();
});
}
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
await UpdateFooter();
window.addEventListener('ndkRelayActivity', (event) => {
const { relayUrl, activity } = event.detail || {};
setRelayActivityState(relayUrl, activity);
});
await hydrateNotificationsFromCache();
void hydrateNotificationsFromRelays();
await subscribeNotifications();
if (!updateIntervalId) {
updateIntervalId = setInterval(UpdateFooter, 1000);
}
setInterval(refreshTimes, 30000);
await updateVersionDisplay();
updateHint();
} catch (error) {
console.error('[notifications] Initialization failed:', error);
divBody.innerHTML = `<div style="text-align:center;padding:40px;color:var(--primary-color);">
<div style="font-size:20px;margin-bottom:10px;">Initialization failed</div>
<div style="color:var(--muted-color);">${String(error?.message || error)}</div>
</div>`;
}
})();
</script>
</body>
</html>