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 profileSnapshotCache = new Map(); const profileSnapshotPending = new Map(); function connectionTypeLabel(typeCode) { switch (Number(typeCode)) { case CONNECTION_CLOSE_FRIEND: return 'близкие друзья'; default: 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'; const title = document.createElement('strong'); title.textContent = activeTab === 'events' ? 'Пока нет событий' : 'Пока нет ответов'; const text = document.createElement('p'); text.className = 'meta-muted'; text.textContent = activeTab === 'events' ? 'Когда кто-то добавит новую связь с вами, событие появится здесь.' : 'Когда кто-то ответит на ваше сообщение в канале или треде, ответ появится здесь.'; 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: 'small', 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') { 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 === 'events') { action.textContent = `Добавил(а) вас в ${connectionTypeLabel(item.connectionTypeCode)}.`; } 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 = 'tabs'; tabs.innerHTML = ` `; const list = document.createElement('div'); list.className = 'stack notifications-list'; let requestSeq = 0; async function load() { const seq = ++requestSeq; const activeTab = state.notificationsTab; list.replaceChildren(renderEmpty(activeTab)); try { const payload = await authService.getNotifications(50); if (seq !== requestSeq) return; const baseItems = (activeTab === 'events' ? payload.events : payload.replies) .map(normalizeItem); if (!baseItems.length) { list.replaceChildren(renderEmpty(activeTab)); return; } const items = await Promise.all(baseItems.map((item) => enrichItem(item, activeTab))); if (seq !== requestSeq) return; list.replaceChildren(...items.map((item) => renderItem(item, activeTab, navigate))); } catch (error) { if (seq !== requestSeq) return; const card = document.createElement('article'); card.className = 'card stack'; const title = document.createElement('strong'); title.textContent = 'Не удалось загрузить уведомления'; const text = document.createElement('p'); text.className = 'meta-muted'; text.textContent = error?.message || 'Ошибка запроса к серверу'; card.append(title, text); list.replaceChildren(card); } } tabs.querySelectorAll('.tab-btn').forEach((btn) => { btn.addEventListener('click', () => { const nextTab = String(btn.dataset.tab || 'replies'); if (state.notificationsTab === nextTab) return; state.notificationsTab = nextTab; tabs.querySelectorAll('.tab-btn').forEach((node) => node.classList.remove('active')); btn.classList.add('active'); void load(); }); }); screen.append(tabs, list); void load(); return screen; }