Доработать UI и счётчики

This commit is contained in:
AidarKC
2026-09-08 22:33:06 +04:00
parent 1f70d36e74
commit ae2f2fac41
18 changed files with 378 additions and 105 deletions
+67 -38
View File
@@ -20,16 +20,68 @@ function iconHtml(item) {
: `<span>${item.icon}</span>`;
}
function getTotalUnreadMessages() {
const chats = Object.values(state.chats || {});
let total = 0;
chats.forEach((messages) => {
if (!Array.isArray(messages)) return;
messages.forEach((msg) => {
if (msg?.from === 'in' && msg?.unread) total += 1;
});
function normalizeCounters(payload = {}) {
const notifications = payload?.notifications || {};
const notificationTotal = Math.max(0, Number(payload?.notificationsUnreadCount ?? (
Number(notifications.replies || 0) + Number(notifications.connections || 0) + Number(notifications.events || 0)
)) || 0);
return {
dmUnreadCount: Math.max(0, Number(payload?.dmUnreadCount || 0) || 0),
channelsUnreadCount: Math.max(0, Number(payload?.channelsUnreadCount || 0) || 0),
notificationsUnreadCount: notificationTotal,
notifications: {
replies: Math.max(0, Number(notifications.replies ?? payload?.notificationRepliesUnreadCount ?? 0) || 0),
connections: Math.max(0, Number(notifications.connections ?? payload?.notificationConnectionsUnreadCount ?? 0) || 0),
events: Math.max(0, Number(notifications.events ?? payload?.notificationEventsUnreadCount ?? 0) || 0),
},
};
}
function setCounterState(payload = {}) {
state.userCounters = normalizeCounters(payload);
state.notificationUnreadTotal = state.userCounters.notificationsUnreadCount;
}
function renderBadge(btn, count, ariaLabel, extraClass = '') {
if (!btn) return;
let badge = btn.querySelector('.toolbar-unread-badge');
if (count <= 0) { badge?.remove(); return; }
if (!badge) {
badge = document.createElement('span');
badge.className = `toolbar-unread-badge${extraClass ? ` ${extraClass}` : ''}`;
btn.append(badge);
}
badge.textContent = count > 99 ? '99+' : String(count);
badge.setAttribute('aria-label', `${ariaLabel}: ${count}`);
}
function applyCountersToMountedToolbars() {
const c = normalizeCounters(state.userCounters);
document.querySelectorAll('.toolbar').forEach((toolbar) => {
renderBadge(toolbar.querySelector('[data-toolbar-page="messages-list"]'), c.dmUnreadCount, 'Непрочитанных личных сообщений');
renderBadge(toolbar.querySelector('[data-toolbar-page="channels-list"]'), c.channelsUnreadCount, 'Непрочитанных сообщений в каналах');
renderBadge(toolbar.querySelector('[data-toolbar-page="notifications-view"]'), c.notificationsUnreadCount, 'Новых уведомлений', 'notification-toolbar-badge');
});
return total;
}
let countersPushBound = false;
function ensureCountersPushBound() {
if (countersPushBound) return;
countersPushBound = true;
authService.onEvent('UserCountersChanged', (event) => {
setCounterState(event?.payload || {});
applyCountersToMountedToolbars();
});
}
async function refreshUserCounters() {
if (!state.session.isAuthorized) return;
try {
setCounterState(await authService.getUserCounters());
applyCountersToMountedToolbars();
} catch {
// Keep the last known counters; realtime push or the next refresh can recover.
}
}
function navigateWithGuestRules(pageId, navigate) {
@@ -65,7 +117,8 @@ export function renderToolbar(currentPageId, navigate) {
const root = document.createElement('nav');
root.className = 'toolbar';
const active = resolveToolbarActive(currentPageId);
const unreadTotal = getTotalUnreadMessages();
ensureCountersPushBound();
const counters = normalizeCounters(state.userCounters);
ITEMS.forEach((item) => {
const btn = document.createElement('button');
@@ -92,21 +145,9 @@ export function renderToolbar(currentPageId, navigate) {
} else {
btn.innerHTML = `${iconHtml(item)}<span>${item.label}</span>`;
}
if (isMessages && unreadTotal > 0) {
const badge = document.createElement('span');
badge.className = 'toolbar-unread-badge';
badge.textContent = unreadTotal > 99 ? '99+' : String(unreadTotal);
badge.setAttribute('aria-label', `Непрочитанных сообщений: ${badge.textContent}`);
btn.append(badge);
}
if (isNotifications && Number(state.notificationUnreadTotal || 0) > 0) {
const badge = document.createElement('span');
badge.className = 'toolbar-unread-badge notification-toolbar-badge';
const n = Number(state.notificationUnreadTotal || 0);
badge.textContent = n > 99 ? '99+' : String(n);
badge.setAttribute('aria-label', `Новых уведомлений: ${n}`);
btn.append(badge);
}
if (isMessages) renderBadge(btn, counters.dmUnreadCount, 'Непрочитанных личных сообщений');
if (item.pageId === 'channels-list') renderBadge(btn, counters.channelsUnreadCount, 'Непрочитанных сообщений в каналах');
if (isNotifications) renderBadge(btn, counters.notificationsUnreadCount, 'Новых уведомлений', 'notification-toolbar-badge');
if (item.pageId === 'channels-list') {
btn.addEventListener('click', () => navigate('channels-list'));
} else {
@@ -115,19 +156,7 @@ export function renderToolbar(currentPageId, navigate) {
root.append(btn);
});
if (state.session.isAuthorized && currentPageId !== 'notifications-view') {
void authService.getNotifications(true).then((payload) => {
const total = Number(payload?.repliesUnseenCount || 0) + Number(payload?.connectionsUnseenCount || 0) + Number(payload?.eventsUnseenCount || 0);
state.notificationUnreadTotal = total;
const btn = root.querySelector('[data-toolbar-page="notifications-view"]');
if (!btn) return;
let badge = btn.querySelector('.notification-toolbar-badge');
if (total <= 0) { badge?.remove(); return; }
if (!badge) { badge = document.createElement('span'); badge.className = 'toolbar-unread-badge notification-toolbar-badge'; btn.append(badge); }
badge.textContent = total > 99 ? '99+' : String(total);
badge.setAttribute('aria-label', `Новых уведомлений: ${total}`);
}).catch(() => {});
}
if (state.session.isAuthorized) void refreshUserCounters();
return root;
}
+1 -5
View File
@@ -88,11 +88,7 @@ export function render({ navigate, route, chrome }) {
</div>
`;
const footer = document.createElement('div');
footer.className = 'meta-muted screen-footer';
footer.textContent = 'О канале (channel-about-view)';
screen.append(card, footer);
screen.append(card);
const renderContent = (channel) => {
const cleanName = String(channel?.displayTitle || channel?.displayName || channel?.channelName || 'Канал').trim();
+31 -5
View File
@@ -290,6 +290,8 @@ function createChannelReadTracker({
unreadCount,
messagesCount,
initialSeenCount,
onPersistError = null,
onPersistSuccess = null,
}) {
const login = String(state.session.login || '').trim();
const storagePwd = state.session.storagePwdInMemory;
@@ -343,7 +345,9 @@ function createChannelReadTracker({
storagePwd,
});
persistedSeenCount = next;
} catch {
if (typeof onPersistSuccess === 'function') onPersistSuccess(persistedSeenCount);
} catch (error) {
if (typeof onPersistError === 'function') onPersistError(error);
queueFlush(800);
} finally {
inFlight = false;
@@ -393,10 +397,8 @@ function createChannelReadTracker({
}
window.addEventListener('resize', onResize);
if (canWrite) {
desiredSeenCount = safeMessagesCount;
void flush();
}
// Opening a channel must NOT mark the whole channel as read.
// Only cards actually crossed by the viewport tracker advance desiredSeenCount.
window.setTimeout(() => measure(), 120);
const cleanup = () => {
@@ -2230,6 +2232,12 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
unreadCount,
messagesCount,
initialSeenCount: readCount,
onPersistError: () => {
showStatus('Не удалось сохранить, сколько сообщений прочитано. Сервер повторит попытку автоматически.');
},
onPersistSuccess: () => {
showStatus('');
},
});
return () => {
@@ -2785,6 +2793,24 @@ export function render({ navigate, route, chrome }) {
});
if (disposed) return;
const readSettingKey = buildChannelSettingsKey(
apiData.channel?.ownerBlockchainName || apiData.selector?.ownerBlockchainName,
apiData.channel?.name || apiData.channel?.channelName,
);
try {
await authService.upsertUserSetting({
login,
settingType: 1,
settingKey: readSettingKey,
timeMs: Date.now(),
valueText: '',
valueNum: Math.max(0, Number(apiData.messagesCount || 0)),
storagePwd,
});
} catch (readStateError) {
showStatus(toUserMessage(readStateError, 'Подписка выполнена, но не удалось сохранить, сколько сообщений уже прочитано.'));
}
const feed = await authService.listSubscriptionsFeed(login, 200);
if (disposed) return;
setChannelsFeed(feed, state.channelsIndex);
-54
View File
@@ -26,7 +26,6 @@ const CHANNEL_TYPE_STORIES = 0;
const CHANNEL_TYPE_PERSONAL = 100;
const DIARY_CHANNEL_NAME = 'diary';
const DIARY_DISPLAY_NAME = 'Дневник';
const CHANNEL_READ_SETTING_TYPE = 1;
const CHANNELS_VIEW_ALL = 'all';
const CHANNELS_VIEW_OWNED = 'owned';
@@ -111,58 +110,6 @@ function isVisibleChannelSummary(summary) {
return !!ownerLogin && !!channelName;
}
function channelReadSettingKey(summary) {
const ownerBch = String(summary?.channel?.ownerBlockchainName || '').trim();
const channelName = String(summary?.channel?.channelName || '').trim();
if (!ownerBch || !channelName) return '';
return `${ownerBch}/${channelName}`;
}
async function ensureChannelReadBaselines(feed) {
const login = String(state.session.login || '').trim();
const storagePwd = state.session.storagePwdInMemory;
if (!login || !storagePwd) return;
let settingsPayload;
try {
settingsPayload = await authService.listUserSettings(login);
} catch {
return;
}
const existing = new Set(
(Array.isArray(settingsPayload?.settings) ? settingsPayload.settings : [])
.filter((item) => Number(item?.setting_type) === CHANNEL_READ_SETTING_TYPE)
.map((item) => String(item?.setting_key || '').trim())
.filter(Boolean),
);
const summaries = [
...(Array.isArray(feed?.followedUsersChannels) ? feed.followedUsersChannels : []),
...(Array.isArray(feed?.followedChannels) ? feed.followedChannels : []),
].filter(isVisibleChannelSummary);
for (const summary of summaries) {
const settingKey = channelReadSettingKey(summary);
if (!settingKey || existing.has(settingKey)) continue;
try {
await authService.upsertUserSetting({
login,
settingType: CHANNEL_READ_SETTING_TYPE,
settingKey,
timeMs: Date.now(),
valueText: '',
valueNum: Math.max(0, Number(summary?.messagesCount || 0)),
storagePwd,
});
existing.add(settingKey);
} catch {
// Не ломаем экран каналов из-за фоновой инициализации read-state.
}
}
}
function avatarLetterFromName(name = '') {
const first = Array.from(String(name || '').trim())[0] || '#';
return first.toUpperCase();
@@ -1120,7 +1067,6 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
try {
const feed = await authService.listSubscriptionsFeed(state.session.login, 200);
void ensureChannelReadBaselines(feed);
let diaryPayload = null;
try {
diaryPayload = await authService.getPersonalDiary(state.session.login, 200, 'asc');
+6
View File
@@ -2944,6 +2944,12 @@ export class AuthService {
return response.payload || {};
}
async getUserCounters() {
const response = await this.ws.request('GetUserCounters', {});
if (response.status !== 200) throw opError('GetUserCounters', response);
return response.payload || {};
}
async getNotifications(countsOnly = false) {
const response = await this.ws.request('GetNotifications', countsOnly ? { countsOnly: true } : {});
if (response.status !== 200) throw opError('GetNotifications', response);
+1
View File
@@ -383,6 +383,7 @@ function createInitialState({ withStoredSession = true } = {}) {
outgoingTempSeq: 1,
notificationsTab: 'replies',
notificationUnreadTotal: 0,
userCounters: { dmUnreadCount: 0, channelsUnreadCount: 0, notificationsUnreadCount: 0, notifications: { replies: 0, connections: 0, events: 0 } },
pageLabelCollapsed: false,
session: {
isAuthorized: storedLocalDemo,