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

372 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { renderHeader } from '../components/header.js';
import { attachScrollToBottomButton } from '../components/scroll-to-bottom-button.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';
const appScreen = document.getElementById('app-screen');
const scrollToBottomControl = attachScrollToBottomButton({ scrollContainer: appScreen });
chrome?.setTopbar(renderHeader({ title: 'Уведомления' }));
const tabs = document.createElement('div');
tabs.className = 'tabs';
tabs.innerHTML = `
<button class="tab-btn ${state.notificationsTab === 'replies' ? 'active' : ''}" data-tab="replies">Ответы</button>
<button class="tab-btn ${state.notificationsTab === 'events' ? 'active' : ''}" data-tab="events">События</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)));
scrollToBottomControl.refresh();
} 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();
screen.cleanup = () => {
scrollToBottomControl.cleanup();
};
return screen;
}