SHA256
Display new notification count
This commit is contained in:
@@ -2266,9 +2266,12 @@ export function render({ navigate, route, chrome }) {
|
||||
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
||||
rightActions: [
|
||||
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
|
||||
{ label: '⋯', className: 'channel-header-more-btn', onClick: () => {} },
|
||||
],
|
||||
});
|
||||
header.classList.add('channel-view-topbar');
|
||||
const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-btn');
|
||||
const channelMoreButton = header.querySelector('.header-actions .channel-header-more-btn');
|
||||
if (channelEntrypointButton) {
|
||||
channelEntrypointButton.disabled = true;
|
||||
channelEntrypointButton.hidden = true;
|
||||
@@ -2544,6 +2547,36 @@ export function render({ navigate, route, chrome }) {
|
||||
if (aboutRoute) navigate(aboutRoute);
|
||||
};
|
||||
}
|
||||
if (channelMoreButton) {
|
||||
channelMoreButton.disabled = false;
|
||||
channelMoreButton.onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
header.querySelector('.channel-header-more-menu')?.remove();
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'channel-header-more-menu';
|
||||
const about = document.createElement('button'); about.type='button'; about.textContent='О канале';
|
||||
about.onclick = () => {
|
||||
const aboutRoute = makeShineChannelAboutRoute({ ownerBlockchainName: apiData?.channel?.ownerBlockchainName || apiData?.selector?.ownerBlockchainName || '', channelRootBlockNumber: apiData?.selector?.channelRootBlockNumber ?? '', channelRootBlockHash: apiData?.selector?.channelRootBlockHash ?? '' });
|
||||
menu.remove(); if (aboutRoute) navigate(aboutRoute);
|
||||
};
|
||||
menu.append(about);
|
||||
if (apiData?.isSubscribed && !apiData?.isOwnChannel) {
|
||||
const unfollow = document.createElement('button'); unfollow.type='button'; unfollow.className='is-danger'; unfollow.textContent='Отписаться от канала';
|
||||
unfollow.onclick = async () => {
|
||||
menu.remove();
|
||||
try {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
await authService.addBlockFollowChannel({ login, storagePwd, targetBlockchainName: apiData.selector.ownerBlockchainName, targetBlockNumber: apiData.selector.channelRootBlockNumber, targetBlockHashHex: apiData.selector.channelRootBlockHash, unfollow: true });
|
||||
const feed = await authService.listSubscriptionsFeed(login, 200); setChannelsFeed(feed, state.channelsIndex); showToast('Вы отписались от канала'); rerender();
|
||||
} catch (error) { showStatus(toUserMessage(error, 'Не удалось отписаться от канала.')); }
|
||||
};
|
||||
menu.append(unfollow);
|
||||
}
|
||||
header.append(menu);
|
||||
const close = (e) => { if (!menu.contains(e.target) && e.target !== channelMoreButton) { menu.remove(); document.removeEventListener('click', close, true); } };
|
||||
setTimeout(() => document.addEventListener('click', close, true), 0);
|
||||
};
|
||||
}
|
||||
if (channelEntrypointButton) {
|
||||
const canShowEntrypointButton = !apiData?.isDiary && entrypointPosts.length > 0;
|
||||
channelEntrypointButton.hidden = !canShowEntrypointButton;
|
||||
|
||||
@@ -5,18 +5,37 @@ 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 connectionTypeLabel(typeCode) {
|
||||
function connectionActionLabel(typeCode) {
|
||||
switch (Number(typeCode)) {
|
||||
case CONNECTION_CLOSE_FRIEND:
|
||||
return 'близкие друзья';
|
||||
default:
|
||||
return 'новую связь';
|
||||
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) {
|
||||
@@ -136,12 +155,10 @@ function renderEmpty(activeTab) {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card stack notification-empty-state';
|
||||
const title = document.createElement('strong');
|
||||
title.textContent = activeTab === 'events' ? 'Пока нет событий' : 'Пока нет ответов';
|
||||
title.textContent = activeTab === 'events' ? 'Пока нет событий' : activeTab === 'connections' ? 'Пока нет связей' : 'Пока нет ответов';
|
||||
const text = document.createElement('p');
|
||||
text.className = 'meta-muted';
|
||||
text.textContent = activeTab === 'events'
|
||||
? 'Когда кто-то добавит новую связь с вами, событие появится здесь.'
|
||||
: 'Когда кто-то ответит на ваше сообщение в канале или треде, ответ появится здесь.';
|
||||
text.textContent = activeTab === 'events' ? 'Подписки и отписки от ваших каналов появятся здесь.' : activeTab === 'connections' ? 'Изменения дружбы и подтверждений появятся здесь.' : 'Когда кто-то ответит на ваше сообщение в канале или треде, ответ появится здесь.';
|
||||
card.append(title, text);
|
||||
return card;
|
||||
}
|
||||
@@ -239,7 +256,7 @@ function renderEngagement(engagement) {
|
||||
}
|
||||
|
||||
function notificationRoute(item, activeTab) {
|
||||
if (activeTab === 'events') {
|
||||
if (activeTab === 'events' || activeTab === 'connections') {
|
||||
const login = String(item?.sourceLogin || '').trim();
|
||||
return login ? makeProfileRoute(login) : '';
|
||||
}
|
||||
@@ -278,8 +295,10 @@ function renderItem(item, activeTab, navigate) {
|
||||
|
||||
const action = document.createElement('p');
|
||||
action.className = 'notification-action';
|
||||
if (activeTab === 'events') {
|
||||
action.textContent = `Добавил(а) вас в ${connectionTypeLabel(item.connectionTypeCode)}.`;
|
||||
if (activeTab === 'connections') {
|
||||
action.textContent = connectionActionLabel(item.connectionTypeCode ?? item.sourceMsgSubType);
|
||||
} else if (activeTab === 'events') {
|
||||
action.textContent = eventActionLabel(item.sourceMsgSubType, item);
|
||||
} else {
|
||||
action.textContent = 'Ответил(а) на ваше сообщение.';
|
||||
}
|
||||
@@ -305,90 +324,98 @@ export function render({ navigate, chrome } = {}) {
|
||||
|
||||
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 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 };
|
||||
|
||||
async function load() {
|
||||
const seq = ++requestSeq;
|
||||
const activeTab = state.notificationsTab;
|
||||
list.replaceChildren(renderEmpty(activeTab));
|
||||
function countsFromPayload(payload) {
|
||||
return {
|
||||
replies: Number(payload?.repliesUnseenCount || 0),
|
||||
connections: Number(payload?.connectionsUnseenCount || 0),
|
||||
events: Number(payload?.eventsUnseenCount || 0),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
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 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'}));
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
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='<strong>Не удалось загрузить уведомления</strong>';const t=document.createElement('p');t.className='meta-muted';t.textContent=error?.message||'Ошибка запроса к серверу';card.append(t);list.replaceChildren(card);}
|
||||
}
|
||||
|
||||
// Активная кнопка остаётся визуально нажатой до выбора второй вкладки.
|
||||
// При переключении класс 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);
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user