Доработать 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
@@ -80,6 +80,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.Net_GetGroupDialog_Handle
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelsCounters_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListGroupChats200_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListSubscriptionsFeed_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetUserCounters_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelsCounters_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDialog_Request;
@@ -87,6 +88,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageTh
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalDiary_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListGroupChats200_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Request;
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler;
import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileRelations_Handler;
import server.logic.ws_protocol.JSON.handlers.profile.Net_ListUserProfileChannels_Handler;
@@ -211,6 +213,7 @@ public final class JsonHandlerRegistry {
Map.entry("GetGroupDialog", new Net_GetGroupDialog_Handler()),
Map.entry("ListGroupChats200", new Net_ListGroupChats200_Handler()),
Map.entry("GetChannelsCounters", new Net_GetChannelsCounters_Handler()),
Map.entry("GetUserCounters", new Net_GetUserCounters_Handler()),
Map.entry("ListContacts", new Net_ListContacts_Handler()),
Map.entry("GetUserConnectionsGraph", new Net_GetUserConnectionsGraph_Handler()),
Map.entry("ListUserProfileRelations", new Net_ListUserProfileRelations_Handler()),
@@ -302,6 +305,7 @@ public final class JsonHandlerRegistry {
Map.entry("GetGroupDialog", Net_GetGroupDialog_Request.class),
Map.entry("ListGroupChats200", Net_ListGroupChats200_Request.class),
Map.entry("GetChannelsCounters", Net_GetChannelsCounters_Request.class),
Map.entry("GetUserCounters", Net_GetUserCounters_Request.class),
Map.entry("ListContacts", Net_ListContacts_Request.class),
Map.entry("GetUserConnectionsGraph", Net_GetUserConnectionsGraph_Request.class),
Map.entry("ListUserProfileRelations", Net_ListUserProfileRelations_Request.class),
@@ -0,0 +1,46 @@
package server.logic.ws_protocol.JSON.handlers.channels;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetUserCounters_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.DbController;
import java.sql.Connection;
public class Net_GetUserCounters_Handler implements JsonMessageHandler {
private static final Logger log = LoggerFactory.getLogger(Net_GetUserCounters_Handler.class);
@Override
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
Net_GetUserCounters_Request req = (Net_GetUserCounters_Request) baseRequest;
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getLogin() == null || ctx.getLogin().isBlank()) {
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Требуется авторизация");
}
String login = ctx.getLogin().trim();
try (Connection c = DbController.getInstance().getConnection()) {
UserCountersSupport.Snapshot s = UserCountersSupport.calculate(c, login);
Net_GetUserCounters_Response r = new Net_GetUserCounters_Response();
r.setOp(req.getOp());
r.setRequestId(req.getRequestId());
r.setStatus(WireCodes.Status.OK);
r.setLogin(login);
r.setDmUnreadCount(s.dmUnreadCount());
r.setChannelsUnreadCount(s.channelsUnreadCount());
r.setNotificationsUnreadCount(s.notificationsUnreadCount());
r.setNotificationRepliesUnreadCount(s.repliesUnreadCount());
r.setNotificationConnectionsUnreadCount(s.connectionsUnreadCount());
r.setNotificationEventsUnreadCount(s.eventsUnreadCount());
return r;
} catch (Exception e) {
log.error("GetUserCounters failed for {}", login, e);
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Не удалось получить счётчики");
}
}
}
@@ -0,0 +1,116 @@
package server.logic.ws_protocol.JSON.handlers.channels;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import server.logic.ws_protocol.JSON.ActiveConnectionsRegistry;
import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.push.WsEventSender;
import shine.db.DbController;
import shine.db.MsgSubType;
import shine.db.dao.UserNotificationSeenStateDAO;
import shine.db.dao.UserNotificationsStateDAO;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
/**
* Temporary calculator behind the stable GetUserCounters/UserCountersChanged contract.
* It intentionally uses current DB state today; later it can read one materialized row/cache
* without changing API or UI.
*/
public final class UserCountersSupport {
private static final ObjectMapper MAPPER = new ObjectMapper();
private UserCountersSupport() {}
public record Snapshot(long dmUnreadCount, long channelsUnreadCount, long repliesUnreadCount,
long connectionsUnreadCount, long eventsUnreadCount) {
public long notificationsUnreadCount() {
return repliesUnreadCount + connectionsUnreadCount + eventsUnreadCount;
}
}
public static Snapshot calculate(Connection c, String login) throws Exception {
long dm = countDmUnread(c, login);
long channels = countChannelsUnread(c, login);
UserNotificationSeenStateDAO seen = UserNotificationSeenStateDAO.getInstance();
UserNotificationsStateDAO notifications = UserNotificationsStateDAO.getInstance();
long repliesSeen = seen.getSeenAt(c, login, "replies");
long connectionsSeen = seen.getSeenAt(c, login, "connections");
long eventsSeen = seen.getSeenAt(c, login, "events");
long replies = notifications.countUnseen(c, login, "reply", repliesSeen);
long connections = notifications.countUnseen(c, login, "connection", connectionsSeen);
long events = notifications.countUnseen(c, login, "event", eventsSeen);
return new Snapshot(dm, channels, replies, connections, events);
}
public static Snapshot calculate(String login) throws Exception {
try (Connection c = DbController.getInstance().getConnection()) {
return calculate(c, login);
}
}
public static void pushChanged(String login) {
if (login == null || login.isBlank()) return;
try {
Snapshot s = calculate(login);
ObjectNode payload = toPayload(login, s);
String eventId = "user-counters-" + System.currentTimeMillis();
for (ConnectionContext ctx : ActiveConnectionsRegistry.getInstance().getByLogin(login)) {
WsEventSender.sendEvent(ctx, "UserCountersChanged", eventId, payload);
}
} catch (Exception ignored) {
// Counter push is best-effort. The client can always recover with GetUserCounters.
}
}
public static ObjectNode toPayload(String login, Snapshot s) {
ObjectNode p = MAPPER.createObjectNode();
p.put("login", login);
p.put("dmUnreadCount", s.dmUnreadCount());
p.put("channelsUnreadCount", s.channelsUnreadCount());
p.put("notificationsUnreadCount", s.notificationsUnreadCount());
ObjectNode n = p.putObject("notifications");
n.put("replies", s.repliesUnreadCount());
n.put("connections", s.connectionsUnreadCount());
n.put("events", s.eventsUnreadCount());
return p;
}
private static long countDmUnread(Connection c, String login) throws Exception {
String sql = "SELECT COALESCE(SUM(unread_count),0) FROM dm_dialog_state WHERE LOWER(owner_login)=LOWER(?)";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? Math.max(0L, rs.getLong(1)) : 0L;
}
}
}
private static long countChannelsUnread(Connection c, String login) throws Exception {
String sql = """
SELECT cs.to_bch_name, COALESCE(cs.to_block_number,0) AS root_number
FROM connections_state cs
WHERE LOWER(cs.login)=LOWER(?) AND cs.rel_type=?
""";
long total = 0L;
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, login);
ps.setInt(2, MsgSubType.CONNECTION_FOLLOW);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
String ownerBch = rs.getString("to_bch_name");
int rootNumber = rs.getInt("root_number");
if (ownerBch == null || ownerBch.isBlank()) continue;
ChannelsReadSupport.ChannelMeta meta = ChannelsReadSupport.detectChannelMeta(c, ownerBch, rootNumber);
if (meta == null || meta.channelName == null || meta.channelName.isBlank()) continue;
if (meta.channelTypeCode == 0 || "stories".equalsIgnoreCase(meta.channelName)) continue;
int messages = ChannelsReadSupport.countPosts(c, ownerBch, rootNumber);
total += Math.max(0, ChannelsReadSupport.countUnreadMessages(c, login, ownerBch, meta.channelName, messages));
}
}
}
return total;
}
}
@@ -0,0 +1,7 @@
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
/** Lightweight authenticated request for bottom-toolbar counters. */
public class Net_GetUserCounters_Request extends Net_Request {
}
@@ -0,0 +1,29 @@
package server.logic.ws_protocol.JSON.handlers.channels.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
/** Stable UI contract. Server-side calculation may be replaced by materialized counters later. */
public class Net_GetUserCounters_Response extends Net_Response {
private String login;
private long dmUnreadCount;
private long channelsUnreadCount;
private long notificationsUnreadCount;
private long notificationRepliesUnreadCount;
private long notificationConnectionsUnreadCount;
private long notificationEventsUnreadCount;
public String getLogin() { return login; }
public void setLogin(String login) { this.login = login; }
public long getDmUnreadCount() { return dmUnreadCount; }
public void setDmUnreadCount(long value) { this.dmUnreadCount = value; }
public long getChannelsUnreadCount() { return channelsUnreadCount; }
public void setChannelsUnreadCount(long value) { this.channelsUnreadCount = value; }
public long getNotificationsUnreadCount() { return notificationsUnreadCount; }
public void setNotificationsUnreadCount(long value) { this.notificationsUnreadCount = value; }
public long getNotificationRepliesUnreadCount() { return notificationRepliesUnreadCount; }
public void setNotificationRepliesUnreadCount(long value) { this.notificationRepliesUnreadCount = value; }
public long getNotificationConnectionsUnreadCount() { return notificationConnectionsUnreadCount; }
public void setNotificationConnectionsUnreadCount(long value) { this.notificationConnectionsUnreadCount = value; }
public long getNotificationEventsUnreadCount() { return notificationEventsUnreadCount; }
public void setNotificationEventsUnreadCount(long value) { this.notificationEventsUnreadCount = value; }
}
@@ -5,6 +5,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.*;
import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.DbController;
@@ -23,7 +24,7 @@ public final class Net_SetNotificationState_Handler implements JsonMessageHandle
byte[] pub=Ed25519Util.keyFromBase64(ctx.getCurrentUser().getClientKey()); if(!Ed25519Util.verify(p.signedBody,p.signature64,pub)) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_SIGNATURE","Некорректная подпись clientKey");
long now=System.currentTimeMillis(); if(p.timeMs>now+5*60_000L) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_TIME","Некорректное время подписи");
long actual; try(Connection c=DbController.getInstance().getConnection()){ actual=UserNotificationSeenStateDAO.getInstance().advance(c,login,p.categoryName(),p.seenAtMs,p.timeMs,raw); }
Net_SetNotificationState_Response r=new Net_SetNotificationState_Response(); r.setOp(req.getOp()); r.setRequestId(req.getRequestId()); r.setStatus(WireCodes.Status.OK); r.setCategory(p.categoryName()); r.setSeenAtMs(actual); return r;
Net_SetNotificationState_Response r=new Net_SetNotificationState_Response(); r.setOp(req.getOp()); r.setRequestId(req.getRequestId()); r.setStatus(WireCodes.Status.OK); r.setCategory(p.categoryName()); r.setSeenAtMs(actual); UserCountersSupport.pushChanged(login); return r;
}catch(Exception e){ return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_NOTIFICATION_STATE",e.getMessage()==null?"Некорректное состояние уведомлений":e.getMessage()); }
}
}
@@ -9,6 +9,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_UpsertUserSetting_Request;
import server.logic.ws_protocol.JSON.handlers.userSettings.entyties.Net_UpsertUserSetting_Response;
import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import server.logic.ws_protocol.WireCodes;
import shine.db.DbController;
@@ -100,6 +101,9 @@ public class Net_UpsertUserSetting_Handler implements JsonMessageHandler {
resp.setSetting_type(settingType);
resp.setSetting_key(settingKey);
resp.setTime_ms(timeMs);
if (settingType == 1) {
UserCountersSupport.pushChanged(login);
}
return resp;
}
} catch (SQLException e) {
@@ -4,6 +4,7 @@ import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport;
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_ReceiveIncomingMessage_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
@@ -59,6 +60,8 @@ public class Net_ReceiveIncomingMessage_Handler implements JsonMessageHandler {
SignedMessagesRealtime.DeliveryCounters counters = new SignedMessagesRealtime.DeliveryCounters();
if (status.applied()) {
counters = SignedMessagesRealtime.deliverToRelevantSessions(entry, incoming);
UserCountersSupport.pushChanged(incoming.toLogin);
UserCountersSupport.pushChanged(incoming.fromLogin);
}
if (status == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) {
bounceConversationDeleteIfKnown(incoming.fromLogin, incoming.toLogin);
@@ -4,6 +4,7 @@ import server.logic.ws_protocol.JSON.ConnectionContext;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
import server.logic.ws_protocol.JSON.entyties.Net_Response;
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
import server.logic.ws_protocol.JSON.handlers.channels.UserCountersSupport;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Request;
import server.logic.ws_protocol.JSON.messages.entyties.Net_SendMessagePair_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
@@ -78,6 +79,14 @@ public class Net_SendMessagePair_Handler implements JsonMessageHandler {
SignedMessagesRealtime.DeliveryCounters outCounters = new SignedMessagesRealtime.DeliveryCounters();
if (pairStatus.applied()) {
outCounters = SignedMessagesRealtime.deliverToRelevantSessions(outgoingEntry, outgoing, excludeSessionId);
// SendMessagePair is also the local-server delivery path. Without this push,
// a recipient on the same access server receives the DM but the toolbar
// counter stays stale until the next explicit GetUserCounters refresh.
UserCountersSupport.pushChanged(incoming.toLogin);
if (!incoming.fromLogin.equalsIgnoreCase(incoming.toLogin)) {
UserCountersSupport.pushChanged(incoming.fromLogin);
}
}
if (pairStatus == SignedMessagesDAO.ApplyStatus.BLOCKED_BY_CONVERSATION_TOMBSTONE) {
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.12.1
server.version=1.10.0
client.version=1.12.2
server.version=1.10.1
+49
View File
@@ -0,0 +1,49 @@
# User counters API
## GetUserCounters
Authenticated lightweight WebSocket request used by the bottom toolbar.
Request:
```json
{"op":"GetUserCounters","requestId":"...","payload":{}}
```
Success payload:
```json
{
"login": "alice",
"dmUnreadCount": 7,
"channelsUnreadCount": 12,
"notificationsUnreadCount": 5,
"notificationRepliesUnreadCount": 2,
"notificationConnectionsUnreadCount": 1,
"notificationEventsUnreadCount": 2
}
```
The fields form a stable UI contract. Their server-side calculation is intentionally replaceable by materialized counters/cache later.
## UserCountersChanged
Server push event for every active session of the user. It lets the toolbar update without polling.
```json
{
"op":"UserCountersChanged",
"event":true,
"payload":{
"login":"alice",
"dmUnreadCount":7,
"channelsUnreadCount":12,
"notificationsUnreadCount":5,
"notifications":{"replies":2,"connections":1,"events":2}
}
}
```
Current implementation emits this event after channel read-state writes, notification seen-state changes and applied incoming DM/read-receipt blocks. Future materialized counter logic can emit the same event immediately on any counter increment/decrement.
## Channel read behaviour
- Opening a subscribed channel does **not** mark all messages read.
- The UI advances readCount only when message cards cross the viewport read threshold while scrolling.
- Writes are debounced; failed writes are shown to the user and retried.
- Immediately after a successful subscription from inside an open channel, the client stores `readCount = current messagesCount`, because those existing messages are treated as already viewed at subscription time.
+66 -37
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');
});
}
let countersPushBound = false;
function ensureCountersPushBound() {
if (countersPushBound) return;
countersPushBound = true;
authService.onEvent('UserCountersChanged', (event) => {
setCounterState(event?.payload || {});
applyCountersToMountedToolbars();
});
return total;
}
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,
+1
View File
@@ -26,6 +26,7 @@
}
.toolbar-btn {
position: relative;
min-height: 52px;
padding: 4px 3px 2px;
display: grid;