Files
SHiNE-server/shine-UI/js/pages/notifications-view.js
T

422 lines
18 KiB
JavaScript

import { renderHeader } from '../components/header.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(renderHeader({ 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;
}