SHA256
395 lines
14 KiB
JavaScript
395 lines
14 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 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 notification-empty-state';
|
||
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: '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') {
|
||
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 = 'notification-feed-tabs app-top-tabs';
|
||
tabs.innerHTML = `
|
||
<button
|
||
type="button"
|
||
class="fg-filter-chip notification-tab-btn ${state.notificationsTab === 'replies' ? 'is-active' : ''}"
|
||
data-tab="replies"
|
||
data-selected="${state.notificationsTab === 'replies' ? 'true' : 'false'}"
|
||
aria-selected="${state.notificationsTab === 'replies' ? 'true' : 'false'}"
|
||
>Ответы</button>
|
||
<button
|
||
type="button"
|
||
class="fg-filter-chip notification-tab-btn ${state.notificationsTab === 'events' ? 'is-active' : ''}"
|
||
data-tab="events"
|
||
data-selected="${state.notificationsTab === 'events' ? 'true' : 'false'}"
|
||
aria-selected="${state.notificationsTab === 'events' ? 'true' : 'false'}"
|
||
>События</button>
|
||
`;
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
function setActiveNotificationTab(nextTab) {
|
||
const normalizedTab = nextTab === 'events' ? 'events' : 'replies';
|
||
state.notificationsTab = normalizedTab;
|
||
|
||
tabs.querySelectorAll('.notification-tab-btn').forEach((node) => {
|
||
const selected = node.dataset.tab === normalizedTab;
|
||
node.classList.toggle('is-active', selected);
|
||
node.dataset.selected = selected ? 'true' : 'false';
|
||
node.setAttribute('aria-selected', selected ? 'true' : 'false');
|
||
});
|
||
}
|
||
|
||
// Активная кнопка остаётся визуально нажатой до выбора второй вкладки.
|
||
// При переключении класс active и aria-selected синхронно переходят на новую кнопку.
|
||
setActiveNotificationTab(state.notificationsTab);
|
||
|
||
tabs.querySelectorAll('.notification-tab-btn').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
const nextTab = btn.dataset.tab === 'events' ? 'events' : 'replies';
|
||
if (state.notificationsTab === nextTab) {
|
||
setActiveNotificationTab(nextTab);
|
||
return;
|
||
}
|
||
|
||
setActiveNotificationTab(nextTab);
|
||
void load();
|
||
});
|
||
});
|
||
|
||
screen.append(tabs, list);
|
||
void load();
|
||
return screen;
|
||
}
|