1409 lines
43 KiB
HTML
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="./js/vendor/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>
|