SHA256
422 lines
18 KiB
JavaScript
422 lines
18 KiB
JavaScript
import { createTopBar } from '../components/topbar.js';
|
|
import { renderUserAvatar } from '../components/avatar-image.js';
|
|
import { authService, state } from '../state.js';
|
|
import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
|
import { makeProfileRoute, makeShineMessageRoute } from '../services/shine-routes.js';
|
|
|
|
const CONNECTION_CLOSE_FRIEND = 10;
|
|
const CONNECTION_UNCLOSE_FRIEND = 11;
|
|
const CONNECTION_FRIEND = 14;
|
|
const CONNECTION_UNFRIEND = 15;
|
|
const CONNECTION_FOLLOW = 30;
|
|
const CONNECTION_UNFOLLOW = 31;
|
|
const CONNECTION_SHINE_CONFIRMED = 70;
|
|
const CONNECTION_SHINE_UNCONFIRMED = 71;
|
|
const CONNECTION_OFFICIAL_CONFIRMED = 80;
|
|
const CONNECTION_OFFICIAL_UNCONFIRMED = 81;
|
|
const profileSnapshotCache = new Map();
|
|
const profileSnapshotPending = new Map();
|
|
|
|
function connectionActionLabel(typeCode) {
|
|
switch (Number(typeCode)) {
|
|
case CONNECTION_CLOSE_FRIEND: return 'Добавил(а) вас в близкие друзья.';
|
|
case CONNECTION_UNCLOSE_FRIEND: return 'Удалил(а) вас из близких друзей.';
|
|
case CONNECTION_FRIEND: return 'Добавил(а) вас в друзья.';
|
|
case CONNECTION_UNFRIEND: return 'Удалил(а) вас из друзей.';
|
|
case CONNECTION_SHINE_CONFIRMED: return 'Подтвердил(а), что вы Сияющий.';
|
|
case CONNECTION_SHINE_UNCONFIRMED: return 'Снял(а) подтверждение «Сияющий».';
|
|
case CONNECTION_OFFICIAL_CONFIRMED: return 'Подтвердил(а) официальный статус аккаунта.';
|
|
case CONNECTION_OFFICIAL_UNCONFIRMED: return 'Снял(а) подтверждение официального статуса.';
|
|
default: return 'Изменил(а) связь с вами.';
|
|
}
|
|
}
|
|
|
|
function eventActionLabel(typeCode) {
|
|
if (Number(typeCode) === CONNECTION_UNFOLLOW) return 'Отписался(-ась) от вашего канала.';
|
|
return 'Подписался(-ась) на ваш канал.';
|
|
}
|
|
|
|
export const pageMeta = { id: 'notifications-view', title: 'Уведомления' };
|
|
|
|
function normalizeItem(item) {
|
|
return {
|
|
kind: String(item?.kind || ''),
|
|
createdAtMs: Number(item?.createdAtMs || 0),
|
|
sourceLogin: String(item?.sourceLogin || ''),
|
|
sourceBlockchainName: String(item?.sourceBlockchainName || ''),
|
|
sourceBlockNumber: Number(item?.sourceBlockNumber || 0),
|
|
sourceBlockHash: String(item?.sourceBlockHash || ''),
|
|
sourceMsgSubType: item?.sourceMsgSubType == null ? null : Number(item.sourceMsgSubType),
|
|
sourceText: String(item?.sourceText || ''),
|
|
connectionTypeCode: item?.connectionTypeCode == null ? null : Number(item.connectionTypeCode),
|
|
targetLogin: String(item?.targetLogin || ''),
|
|
targetBlockchainName: String(item?.targetBlockchainName || ''),
|
|
targetBlockNumber: item?.targetBlockNumber == null ? null : Number(item.targetBlockNumber),
|
|
targetBlockHash: String(item?.targetBlockHash || ''),
|
|
profile: null,
|
|
engagement: null,
|
|
};
|
|
}
|
|
|
|
function formatRelativeTime(value) {
|
|
const ts = Number(value || 0);
|
|
if (!Number.isFinite(ts) || ts <= 0) return '';
|
|
|
|
const diffMs = Math.max(0, Date.now() - ts);
|
|
const minute = 60 * 1000;
|
|
const hour = 60 * minute;
|
|
const day = 24 * hour;
|
|
const week = 7 * day;
|
|
|
|
if (diffMs < minute) return 'сейчас';
|
|
if (diffMs < hour) return `${Math.max(1, Math.floor(diffMs / minute))} мин.`;
|
|
if (diffMs < day) return `${Math.max(1, Math.floor(diffMs / hour))} ч.`;
|
|
if (diffMs < week) return `${Math.max(1, Math.floor(diffMs / day))} дн.`;
|
|
return `${Math.max(1, Math.floor(diffMs / week))} нед.`;
|
|
}
|
|
|
|
function profileField(snapshot, key) {
|
|
const row = (Array.isArray(snapshot?.fields) ? snapshot.fields : [])
|
|
.find((field) => String(field?.key || '') === key);
|
|
return String(row?.value || '').trim();
|
|
}
|
|
|
|
async function loadCachedProfileSnapshot(login) {
|
|
const cleanLogin = String(login || '').trim();
|
|
if (!cleanLogin) return null;
|
|
const key = cleanLogin.toLowerCase();
|
|
if (profileSnapshotCache.has(key)) return profileSnapshotCache.get(key);
|
|
if (profileSnapshotPending.has(key)) return profileSnapshotPending.get(key);
|
|
|
|
const pending = loadProfileSnapshot(cleanLogin)
|
|
.then((snapshot) => {
|
|
profileSnapshotCache.set(key, snapshot || null);
|
|
profileSnapshotPending.delete(key);
|
|
return snapshot || null;
|
|
})
|
|
.catch(() => {
|
|
profileSnapshotCache.set(key, null);
|
|
profileSnapshotPending.delete(key);
|
|
return null;
|
|
});
|
|
|
|
profileSnapshotPending.set(key, pending);
|
|
return pending;
|
|
}
|
|
|
|
function normalizeEngagement(source) {
|
|
if (!source || typeof source !== 'object') return null;
|
|
const likesCount = Math.max(0, Number(source.likesCount || 0));
|
|
const repliesCount = Math.max(0, Number(source.repliesCount || 0));
|
|
const ratingsCount = Math.max(0, Number(source.ratingsCount || 0));
|
|
const repostsCount = Math.max(0, Number(source.repostsCount ?? source.repostCount ?? 0));
|
|
const sharesCount = Math.max(0, Number(source.sharesCount ?? source.shareCount ?? 0));
|
|
|
|
const result = {
|
|
likesCount: Number.isFinite(likesCount) ? likesCount : 0,
|
|
repliesCount: Number.isFinite(repliesCount) ? repliesCount : 0,
|
|
ratingsCount: Number.isFinite(ratingsCount) ? ratingsCount : 0,
|
|
repostsCount: Number.isFinite(repostsCount) ? repostsCount : 0,
|
|
sharesCount: Number.isFinite(sharesCount) ? sharesCount : 0,
|
|
};
|
|
|
|
return Object.values(result).some((count) => count > 0) ? result : null;
|
|
}
|
|
|
|
async function loadSourceEngagement(item) {
|
|
const blockchainName = String(item?.sourceBlockchainName || '').trim();
|
|
const blockNumber = Number(item?.sourceBlockNumber);
|
|
const blockHash = String(item?.sourceBlockHash || '').trim();
|
|
if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0 || !blockHash) return null;
|
|
|
|
try {
|
|
const payload = await authService.getMessageThread(
|
|
{ blockchainName, blockNumber, blockHash },
|
|
0,
|
|
0,
|
|
1,
|
|
String(state.session.login || '').trim(),
|
|
);
|
|
return normalizeEngagement(payload?.focus);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function enrichItem(item, activeTab) {
|
|
const [profile, engagement] = await Promise.all([
|
|
loadCachedProfileSnapshot(item.sourceLogin),
|
|
activeTab === 'replies' ? loadSourceEngagement(item) : Promise.resolve(null),
|
|
]);
|
|
return { ...item, profile, engagement };
|
|
}
|
|
|
|
function renderEmpty(activeTab) {
|
|
const card = document.createElement('article');
|
|
card.className = 'card stack notification-empty-state';
|
|
const title = document.createElement('strong');
|
|
title.textContent = activeTab === 'events' ? 'Пока нет событий' : activeTab === 'connections' ? 'Пока нет связей' : 'Пока нет ответов';
|
|
const text = document.createElement('p');
|
|
text.className = 'meta-muted';
|
|
text.textContent = activeTab === 'events' ? 'Подписки и отписки от ваших каналов появятся здесь.' : activeTab === 'connections' ? 'Изменения дружбы и подтверждений появятся здесь.' : 'Когда кто-то ответит на ваше сообщение в канале или треде, ответ появится здесь.';
|
|
card.append(title, text);
|
|
return card;
|
|
}
|
|
|
|
function renderIdentity(item) {
|
|
const profile = item.profile;
|
|
const firstName = profileField(profile, 'first_name');
|
|
const lastName = profileField(profile, 'last_name');
|
|
const fullName = [firstName, lastName].filter(Boolean).join(' ') || item.sourceLogin || 'Пользователь';
|
|
const avatar = profile?.avatar?.txId
|
|
? {
|
|
ar: String(profile.avatar.txId || '').trim(),
|
|
sha256Hex: String(profile.avatar.sha256Hex || '').trim().toLowerCase(),
|
|
}
|
|
: null;
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'notification-identity';
|
|
header.append(renderUserAvatar({
|
|
login: item.sourceLogin || 'unknown',
|
|
firstName,
|
|
lastName,
|
|
avatar,
|
|
size: 'md',
|
|
className: 'notification-avatar',
|
|
}));
|
|
|
|
const text = document.createElement('div');
|
|
text.className = 'notification-identity-text';
|
|
|
|
const primary = document.createElement('div');
|
|
primary.className = 'notification-identity-primary';
|
|
const name = document.createElement('strong');
|
|
name.className = 'notification-person-name';
|
|
name.textContent = fullName;
|
|
primary.append(name);
|
|
|
|
const login = String(item.sourceLogin || '').trim();
|
|
if (login) {
|
|
const loginEl = document.createElement('span');
|
|
loginEl.className = 'notification-login';
|
|
loginEl.textContent = `@${login}`;
|
|
primary.append(loginEl);
|
|
}
|
|
|
|
const relative = formatRelativeTime(item.createdAtMs);
|
|
if (relative) {
|
|
const separator = document.createElement('span');
|
|
separator.className = 'notification-time-separator';
|
|
separator.textContent = '·';
|
|
const time = document.createElement('span');
|
|
time.className = 'notification-time';
|
|
time.textContent = relative;
|
|
primary.append(separator, time);
|
|
}
|
|
|
|
text.append(primary);
|
|
header.append(text);
|
|
return header;
|
|
}
|
|
|
|
function renderEngagement(engagement) {
|
|
if (!engagement) return null;
|
|
|
|
const stats = [
|
|
{ key: 'likesCount', icon: '♥', label: 'Лайки' },
|
|
{ key: 'repliesCount', icon: '💬', label: 'Ответы' },
|
|
{ key: 'ratingsCount', icon: '★', label: 'Оценки' },
|
|
{ key: 'repostsCount', icon: '↻', label: 'Репосты' },
|
|
{ key: 'sharesCount', icon: '↗', label: 'Отправки' },
|
|
].filter(({ key }) => Number(engagement[key] || 0) > 0);
|
|
|
|
if (!stats.length) return null;
|
|
|
|
const row = document.createElement('div');
|
|
row.className = 'notification-engagement';
|
|
stats.forEach(({ key, icon, label }) => {
|
|
const stat = document.createElement('span');
|
|
stat.className = 'notification-engagement-item';
|
|
stat.title = label;
|
|
|
|
const iconEl = document.createElement('span');
|
|
iconEl.className = 'notification-engagement-icon';
|
|
iconEl.setAttribute('aria-hidden', 'true');
|
|
iconEl.textContent = icon;
|
|
|
|
const countEl = document.createElement('span');
|
|
countEl.className = 'notification-engagement-count';
|
|
countEl.textContent = String(engagement[key]);
|
|
stat.append(iconEl, countEl);
|
|
row.append(stat);
|
|
});
|
|
|
|
return row;
|
|
}
|
|
|
|
function notificationRoute(item, activeTab) {
|
|
if (activeTab === 'events' || activeTab === 'connections') {
|
|
const login = String(item?.sourceLogin || '').trim();
|
|
return login ? makeProfileRoute(login) : '';
|
|
}
|
|
|
|
const blockchainName = String(item?.sourceBlockchainName || '').trim();
|
|
const blockNumber = Number(item?.sourceBlockNumber);
|
|
if (!blockchainName || !Number.isFinite(blockNumber) || blockNumber < 0) return '';
|
|
|
|
return makeShineMessageRoute({
|
|
messageBlockchainName: blockchainName,
|
|
messageBlockNumber: blockNumber,
|
|
});
|
|
}
|
|
|
|
function bindNotificationNavigation(row, routePath, navigate) {
|
|
if (!routePath || typeof navigate !== 'function') return;
|
|
|
|
row.classList.add('notification-card--clickable');
|
|
row.tabIndex = 0;
|
|
row.setAttribute('role', 'link');
|
|
|
|
const open = () => navigate(routePath);
|
|
row.addEventListener('click', open);
|
|
row.addEventListener('keydown', (event) => {
|
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
event.preventDefault();
|
|
open();
|
|
});
|
|
}
|
|
|
|
function renderItem(item, activeTab, navigate) {
|
|
const row = document.createElement('article');
|
|
row.className = 'card stack notification-card';
|
|
bindNotificationNavigation(row, notificationRoute(item, activeTab), navigate);
|
|
row.append(renderIdentity(item));
|
|
|
|
const action = document.createElement('p');
|
|
action.className = 'notification-action';
|
|
if (activeTab === 'connections') {
|
|
action.textContent = connectionActionLabel(item.connectionTypeCode ?? item.sourceMsgSubType);
|
|
} else if (activeTab === 'events') {
|
|
action.textContent = eventActionLabel(item.sourceMsgSubType, item);
|
|
} else {
|
|
action.textContent = 'Ответил(а) на ваше сообщение.';
|
|
}
|
|
row.append(action);
|
|
|
|
if (activeTab === 'replies') {
|
|
const body = document.createElement('p');
|
|
body.className = 'notification-content';
|
|
body.textContent = item.sourceText || 'Ответ без текста.';
|
|
row.append(body);
|
|
|
|
const engagement = renderEngagement(item.engagement);
|
|
if (engagement) row.append(engagement);
|
|
}
|
|
|
|
return row;
|
|
}
|
|
|
|
export function render({ navigate, chrome } = {}) {
|
|
const screen = document.createElement('section');
|
|
screen.className = 'stack notifications-screen';
|
|
chrome?.setTopbar(createTopBar({ title: 'Уведомления' }));
|
|
|
|
const tabs = document.createElement('div');
|
|
tabs.className = 'notification-feed-tabs app-top-tabs';
|
|
const tabDefs = [['replies','Ответы'],['connections','Связи'],['events','События']];
|
|
const list = document.createElement('div');
|
|
list.className = 'stack notifications-list';
|
|
let payloadCache = null;
|
|
let requestSeq = 0;
|
|
let observer = null;
|
|
const pendingSeenTimers = { replies: null, connections: null, events: null };
|
|
const localSeen = { replies: 0, connections: 0, events: 0 };
|
|
|
|
function countsFromPayload(payload) {
|
|
return {
|
|
replies: Number(payload?.repliesUnseenCount || 0),
|
|
connections: Number(payload?.connectionsUnseenCount || 0),
|
|
events: Number(payload?.eventsUnseenCount || 0),
|
|
};
|
|
}
|
|
|
|
function updateToolbarBadge(payload) {
|
|
const c = countsFromPayload(payload);
|
|
state.notificationUnreadTotal = c.replies + c.connections + c.events;
|
|
const btn = document.querySelector('[data-toolbar-page="notifications-view"]');
|
|
if (!btn) return;
|
|
let badge = btn.querySelector('.notification-toolbar-badge');
|
|
if (state.notificationUnreadTotal <= 0) { badge?.remove(); return; }
|
|
if (!badge) { badge = document.createElement('span'); badge.className = 'toolbar-unread-badge notification-toolbar-badge'; btn.append(badge); }
|
|
badge.textContent = state.notificationUnreadTotal > 99 ? '99+' : String(state.notificationUnreadTotal);
|
|
}
|
|
|
|
function renderTabs(payload) {
|
|
const counts = countsFromPayload(payload);
|
|
tabs.replaceChildren(...tabDefs.map(([key,label]) => {
|
|
const b=document.createElement('button'); b.type='button'; b.className=`fg-filter-chip notification-tab-btn ${state.notificationsTab===key?'is-active':''}`; b.dataset.tab=key; b.setAttribute('aria-selected',state.notificationsTab===key?'true':'false');
|
|
b.textContent = counts[key] > 0 ? `${label} ${counts[key]}` : label;
|
|
b.addEventListener('click',()=>{ if(state.notificationsTab===key)return; state.notificationsTab=key; renderCurrent(); });
|
|
return b;
|
|
}));
|
|
}
|
|
|
|
function categoryData(payload, tab) {
|
|
if (tab === 'connections') return { items: payload?.connections || [], seenAt: Number(payload?.connectionsSeenAtMs || 0) };
|
|
if (tab === 'events') return { items: payload?.events || [], seenAt: Number(payload?.eventsSeenAtMs || 0) };
|
|
return { items: payload?.replies || [], seenAt: Number(payload?.repliesSeenAtMs || 0) };
|
|
}
|
|
|
|
function scheduleSeen(category, seenAtMs) {
|
|
if (seenAtMs <= Number(localSeen[category] || 0)) return;
|
|
localSeen[category] = seenAtMs;
|
|
clearTimeout(pendingSeenTimers[category]);
|
|
pendingSeenTimers[category] = setTimeout(async () => {
|
|
const target = Number(localSeen[category] || 0);
|
|
try {
|
|
await authService.setNotificationSeen({ login: state.session.login, category, seenAtMs: target, storagePwd: state.session.storagePwdInMemory });
|
|
if (!payloadCache) return;
|
|
const key = category === 'connections' ? 'connectionsSeenAtMs' : category === 'events' ? 'eventsSeenAtMs' : 'repliesSeenAtMs';
|
|
const countKey = category === 'connections' ? 'connectionsUnseenCount' : category === 'events' ? 'eventsUnseenCount' : 'repliesUnseenCount';
|
|
payloadCache[key] = Math.max(Number(payloadCache[key] || 0), target);
|
|
payloadCache[countKey] = (payloadCache[category] || []).filter(x => Number(x?.createdAtMs || 0) > payloadCache[key]).length;
|
|
renderTabs(payloadCache); updateToolbarBadge(payloadCache);
|
|
} catch (e) { console.warn('Не удалось подписать watermark уведомлений', e); }
|
|
}, 350);
|
|
}
|
|
|
|
async function renderCurrent() {
|
|
observer?.disconnect(); observer=null; renderTabs(payloadCache || {});
|
|
const tab=state.notificationsTab; const {items:raw,seenAt}=categoryData(payloadCache || {},tab); localSeen[tab]=Math.max(localSeen[tab]||0,seenAt);
|
|
const base=raw.map(normalizeItem); if(!base.length){list.replaceChildren(renderEmpty(tab));return;}
|
|
const items=await Promise.all(base.map(x=>enrichItem(x,tab)));
|
|
const unread=items.filter(x=>x.createdAtMs>seenAt); const old=items.filter(x=>x.createdAtMs<=seenAt);
|
|
const nodes=[];
|
|
unread.forEach(x=>{const n=renderItem(x,tab,navigate);n.classList.add('notification-card--new');n.dataset.createdAtMs=String(x.createdAtMs);nodes.push(n);});
|
|
let divider=null;
|
|
if(unread.length){divider=document.createElement('div');divider.className='notification-new-divider';divider.textContent=`НОВЫЕ · ${unread.length}`;nodes.push(divider);}
|
|
old.forEach(x=>nodes.push(renderItem(x,tab,navigate))); list.replaceChildren(...nodes);
|
|
if(unread.length && 'IntersectionObserver' in window){
|
|
observer=new IntersectionObserver(entries=>{ entries.forEach(e=>{ if(e.isIntersecting && e.intersectionRatio>=0.5){ const ts=Number(e.target.dataset.createdAtMs||0); if(ts>0){e.target.classList.remove('notification-card--new');scheduleSeen(tab,ts);} } }); },{threshold:[0.5]});
|
|
list.querySelectorAll('.notification-card--new').forEach(n=>observer.observe(n));
|
|
requestAnimationFrame(()=>divider?.scrollIntoView({block:'end'}));
|
|
}
|
|
}
|
|
|
|
async function load() {
|
|
const seq=++requestSeq; list.replaceChildren(renderEmpty(state.notificationsTab));
|
|
try { payloadCache=await authService.getNotifications(); if(seq!==requestSeq)return; updateToolbarBadge(payloadCache); await renderCurrent(); }
|
|
catch(error){ if(seq!==requestSeq)return; const card=document.createElement('article');card.className='card stack';card.innerHTML='<strong>Не удалось загрузить уведомления</strong>';const t=document.createElement('p');t.className='meta-muted';t.textContent=error?.message||'Ошибка запроса к серверу';card.append(t);list.replaceChildren(card);}
|
|
}
|
|
|
|
if (!['replies','connections','events'].includes(state.notificationsTab)) state.notificationsTab='replies';
|
|
screen.cleanup = () => {
|
|
observer?.disconnect();
|
|
Object.values(pendingSeenTimers).forEach((timer) => clearTimeout(timer));
|
|
};
|
|
screen.append(tabs,list);
|
|
void load();
|
|
return screen;
|
|
}
|