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='Не удалось загрузить уведомления';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; }