diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java
index 18861805..920797fc 100644
--- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java
+++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java
@@ -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),
diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetUserCounters_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetUserCounters_Handler.java
new file mode 100644
index 00000000..8583b316
--- /dev/null
+++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetUserCounters_Handler.java
@@ -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", "Не удалось получить счётчики");
+ }
+ }
+}
diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/UserCountersSupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/UserCountersSupport.java
new file mode 100644
index 00000000..c0e32f61
--- /dev/null
+++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/UserCountersSupport.java
@@ -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;
+ }
+}
diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetUserCounters_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetUserCounters_Request.java
new file mode 100644
index 00000000..7a1129af
--- /dev/null
+++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetUserCounters_Request.java
@@ -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 {
+}
diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetUserCounters_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetUserCounters_Response.java
new file mode 100644
index 00000000..398132a5
--- /dev/null
+++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetUserCounters_Response.java
@@ -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; }
+}
diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/Net_SetNotificationState_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/Net_SetNotificationState_Handler.java
index f795b472..a51e0c6e 100644
--- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/Net_SetNotificationState_Handler.java
+++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/Net_SetNotificationState_Handler.java
@@ -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()); }
}
}
diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_UpsertUserSetting_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_UpsertUserSetting_Handler.java
index 375dacfd..eeaf5a3e 100644
--- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_UpsertUserSetting_Handler.java
+++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/userSettings/Net_UpsertUserSetting_Handler.java
@@ -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) {
diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java
index 053038bc..39f504e7 100644
--- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java
+++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_ReceiveIncomingMessage_Handler.java
@@ -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);
diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java
index 50146df3..d7797ca0 100644
--- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java
+++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/messages/Net_SendMessagePair_Handler.java
@@ -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) {
diff --git a/VERSION.properties b/VERSION.properties
index 778037b3..521538c7 100644
--- a/VERSION.properties
+++ b/VERSION.properties
@@ -1,2 +1,2 @@
-client.version=1.12.1
-server.version=1.10.0
+client.version=1.12.2
+server.version=1.10.1
diff --git a/docs/API/17_User_Counters_API.md b/docs/API/17_User_Counters_API.md
new file mode 100644
index 00000000..8ef373ea
--- /dev/null
+++ b/docs/API/17_User_Counters_API.md
@@ -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.
diff --git a/shine-UI/js/components/toolbar.js b/shine-UI/js/components/toolbar.js
index 0ad7e397..5a645139 100644
--- a/shine-UI/js/components/toolbar.js
+++ b/shine-UI/js/components/toolbar.js
@@ -20,16 +20,68 @@ function iconHtml(item) {
: `${item.icon}`;
}
-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)}${item.label}`;
}
- 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;
}
diff --git a/shine-UI/js/pages/channel-about-view.js b/shine-UI/js/pages/channel-about-view.js
index 57054a76..bbaa231a 100644
--- a/shine-UI/js/pages/channel-about-view.js
+++ b/shine-UI/js/pages/channel-about-view.js
@@ -88,11 +88,7 @@ export function render({ navigate, route, chrome }) {
`;
- 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();
diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js
index a6ac0d56..0c6f59cc 100644
--- a/shine-UI/js/pages/channel-view.js
+++ b/shine-UI/js/pages/channel-view.js
@@ -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);
diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js
index f72bf1ec..a0a6d119 100644
--- a/shine-UI/js/pages/channels-list.js
+++ b/shine-UI/js/pages/channels-list.js
@@ -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');
diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js
index c3554386..94cf1757 100644
--- a/shine-UI/js/services/auth-service.js
+++ b/shine-UI/js/services/auth-service.js
@@ -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);
diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js
index 2677aeb3..b5002c4d 100644
--- a/shine-UI/js/state.js
+++ b/shine-UI/js/state.js
@@ -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,
diff --git a/shine-UI/styles/components/toolbar.css b/shine-UI/styles/components/toolbar.css
index 8610a177..0666f51f 100644
--- a/shine-UI/styles/components/toolbar.css
+++ b/shine-UI/styles/components/toolbar.css
@@ -26,6 +26,7 @@
}
.toolbar-btn {
+ position: relative;
min-height: 52px;
padding: 4px 3px 2px;
display: grid;