From ae2f2fac417f4f989a68895e95f2324b7a6b5f324adcde587a94557ed4362933 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Tue, 8 Sep 2026 22:33:06 +0400 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20UI=20=D0=B8=20=D1=81=D1=87=D1=91=D1=82?= =?UTF-8?q?=D1=87=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ws_protocol/JSON/JsonHandlerRegistry.java | 4 + .../channels/Net_GetUserCounters_Handler.java | 46 +++++++ .../channels/UserCountersSupport.java | 116 ++++++++++++++++++ .../entyties/Net_GetUserCounters_Request.java | 7 ++ .../Net_GetUserCounters_Response.java | 29 +++++ .../Net_SetNotificationState_Handler.java | 3 +- .../Net_UpsertUserSetting_Handler.java | 4 + .../Net_ReceiveIncomingMessage_Handler.java | 3 + .../messages/Net_SendMessagePair_Handler.java | 9 ++ VERSION.properties | 4 +- docs/API/17_User_Counters_API.md | 49 ++++++++ shine-UI/js/components/toolbar.js | 105 ++++++++++------ shine-UI/js/pages/channel-about-view.js | 6 +- shine-UI/js/pages/channel-view.js | 36 +++++- shine-UI/js/pages/channels-list.js | 54 -------- shine-UI/js/services/auth-service.js | 6 + shine-UI/js/state.js | 1 + shine-UI/styles/components/toolbar.css | 1 + 18 files changed, 378 insertions(+), 105 deletions(-) create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetUserCounters_Handler.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/UserCountersSupport.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetUserCounters_Request.java create mode 100644 SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetUserCounters_Response.java create mode 100644 docs/API/17_User_Counters_API.md 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;