SHA256
Добавить поддержку уведомлений
This commit is contained in:
@@ -1370,7 +1370,7 @@ async function loadFromApi(route, channelId) {
|
||||
throw new Error('Канал не найден.');
|
||||
}
|
||||
unreadCount = Number(channel?.unreadCount || 0);
|
||||
messagesCount = Number(channel?.messagesCount || mergedMessages.length || 0);
|
||||
messagesCount = Number(channel?.messagesCount || 0);
|
||||
selector = {
|
||||
ownerBlockchainName: String(channel.channel.ownerBlockchainName),
|
||||
channelRootBlockNumber: Number(channel.channel.channelRoot.blockNumber),
|
||||
|
||||
@@ -1,32 +1,304 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { state } from '../state.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 renderList(container) {
|
||||
const active = state.notificationsTab;
|
||||
container.innerHTML = '';
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card stack';
|
||||
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = active === 'events' ? 'События в разработке' : 'Ответы в разработке';
|
||||
|
||||
const description = document.createElement('p');
|
||||
description.className = 'meta-muted';
|
||||
description.textContent = active === 'events'
|
||||
? 'Здесь будут отображаться события: кто подписался на вас, куда вас добавили, кто поставил лайк и другие действия.'
|
||||
: 'Здесь будут отображаться ответы и комментарии на ваши сообщения в публичных каналах.';
|
||||
|
||||
const note = document.createElement('p');
|
||||
note.className = 'meta-muted';
|
||||
note.textContent = 'Раздел находится в разработке. Функционал будет добавлен в следующих обновлениях.';
|
||||
|
||||
card.append(title, description, note);
|
||||
container.append(card);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export function render({ chrome } = {}) {
|
||||
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: 'Уведомления' }));
|
||||
@@ -40,17 +312,53 @@ export function render({ chrome } = {}) {
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'stack notifications-list';
|
||||
renderList(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', () => {
|
||||
state.notificationsTab = btn.dataset.tab;
|
||||
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');
|
||||
renderList(list);
|
||||
void load();
|
||||
});
|
||||
});
|
||||
|
||||
screen.append(tabs, list);
|
||||
void load();
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -2862,6 +2862,14 @@ export class AuthService {
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async getNotifications(limit = 50) {
|
||||
const payload = {};
|
||||
if (Number.isFinite(Number(limit))) payload.limit = Math.max(1, Math.min(200, Number(limit)));
|
||||
const response = await this.ws.request('GetNotifications', payload);
|
||||
if (response.status !== 200) throw opError('GetNotifications', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async getUserConnectionsGraph(login) {
|
||||
const response = await this.ws.request('GetUserConnectionsGraph', { login });
|
||||
if (response.status !== 200) throw opError('GetUserConnectionsGraph', response);
|
||||
|
||||
@@ -8042,3 +8042,102 @@ html, body { overflow-x: hidden; }
|
||||
pointer-events: auto;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* ===== Notifications: social event cards ===== */
|
||||
.notifications-screen .notification-card {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.notification-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notification-avatar {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.notification-identity-text {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.notification-identity-primary {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.notification-person-name {
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.notification-login,
|
||||
.notification-time-separator,
|
||||
.notification-time {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.notification-action,
|
||||
.notification-content {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.notification-action {
|
||||
color: rgba(255, 255, 255, 0.66);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
color: rgba(255, 255, 255, 0.94);
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.notification-engagement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 2px;
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
|
||||
.notification-engagement-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 24px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.notification-engagement-icon {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
|
||||
.notifications-screen .notification-card--clickable {
|
||||
cursor: pointer;
|
||||
transition: transform 120ms ease, border-color 120ms ease, background-color 120ms ease;
|
||||
}
|
||||
|
||||
.notifications-screen .notification-card--clickable:hover,
|
||||
.notifications-screen .notification-card--clickable:focus-visible {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.055);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.notifications-screen .notification-card--clickable:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user