SHA256
Compare commits
2
Commits
main
...
ae2f2fac41
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
ae2f2fac41 | ||
|
|
1f70d36e74 |
+4
@@ -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),
|
||||
|
||||
+46
@@ -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", "Не удалось получить счётчики");
|
||||
}
|
||||
}
|
||||
}
|
||||
+116
@@ -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;
|
||||
}
|
||||
}
|
||||
+7
@@ -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 {
|
||||
}
|
||||
+29
@@ -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; }
|
||||
}
|
||||
+2
-1
@@ -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()); }
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -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) {
|
||||
|
||||
+3
@@ -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);
|
||||
|
||||
+9
@@ -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
@@ -1,2 +1,2 @@
|
||||
client.version=1.12.0
|
||||
server.version=1.10.0
|
||||
client.version=1.12.2
|
||||
server.version=1.10.1
|
||||
|
||||
+18
-1
@@ -123,6 +123,23 @@ find_offline_gradle_zip() {
|
||||
fi
|
||||
done
|
||||
|
||||
local extracted_dir=""
|
||||
if [[ -d "$HOME/.gradle/wrapper/dists/gradle-8.14-bin" ]]; then
|
||||
extracted_dir="$(find "$HOME/.gradle/wrapper/dists/gradle-8.14-bin" -type d -name 'gradle-8.14' | sort | head -n 1 || true)"
|
||||
fi
|
||||
if [[ -n "$extracted_dir" && -d "$extracted_dir" ]]; then
|
||||
local generated_zip="$TMP/gradle-offline.zip"
|
||||
(
|
||||
cd "$(dirname -- "$extracted_dir")"
|
||||
rm -f -- "$generated_zip"
|
||||
zip -qr "$generated_zip" "gradle-8.14"
|
||||
)
|
||||
if [[ -f "$generated_zip" ]]; then
|
||||
printf '%s\n' "$generated_zip"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -269,7 +286,7 @@ if OFFLINE_ZIP_SRC="$(find_offline_gradle_zip)"; then
|
||||
OFFLINE_ZIP_NAME="gradle-offline.zip"
|
||||
else
|
||||
echo "ERROR: offline Gradle zip not found." >&2
|
||||
echo "Place it at ./offline/gradle-offline.zip or set BUNDLE_OFFLINE_GRADLE_ZIP." >&2
|
||||
echo "Place it at ./offline/gradle-offline.zip, set BUNDLE_OFFLINE_GRADLE_ZIP, or install Gradle 8.14 locally." >&2
|
||||
echo "The bundle is not created because this variant is meant to be offline-ready." >&2
|
||||
exit 4
|
||||
fi
|
||||
|
||||
@@ -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.
|
||||
@@ -20,16 +20,68 @@ function iconHtml(item) {
|
||||
: `<span>${item.icon}</span>`;
|
||||
}
|
||||
|
||||
function getTotalUnreadMessages() {
|
||||
const chats = Object.values(state.chats || {});
|
||||
let total = 0;
|
||||
chats.forEach((messages) => {
|
||||
if (!Array.isArray(messages)) return;
|
||||
messages.forEach((msg) => {
|
||||
if (msg?.from === 'in' && msg?.unread) total += 1;
|
||||
});
|
||||
function normalizeCounters(payload = {}) {
|
||||
const notifications = payload?.notifications || {};
|
||||
const notificationTotal = Math.max(0, Number(payload?.notificationsUnreadCount ?? (
|
||||
Number(notifications.replies || 0) + Number(notifications.connections || 0) + Number(notifications.events || 0)
|
||||
)) || 0);
|
||||
return {
|
||||
dmUnreadCount: Math.max(0, Number(payload?.dmUnreadCount || 0) || 0),
|
||||
channelsUnreadCount: Math.max(0, Number(payload?.channelsUnreadCount || 0) || 0),
|
||||
notificationsUnreadCount: notificationTotal,
|
||||
notifications: {
|
||||
replies: Math.max(0, Number(notifications.replies ?? payload?.notificationRepliesUnreadCount ?? 0) || 0),
|
||||
connections: Math.max(0, Number(notifications.connections ?? payload?.notificationConnectionsUnreadCount ?? 0) || 0),
|
||||
events: Math.max(0, Number(notifications.events ?? payload?.notificationEventsUnreadCount ?? 0) || 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setCounterState(payload = {}) {
|
||||
state.userCounters = normalizeCounters(payload);
|
||||
state.notificationUnreadTotal = state.userCounters.notificationsUnreadCount;
|
||||
}
|
||||
|
||||
function renderBadge(btn, count, ariaLabel, extraClass = '') {
|
||||
if (!btn) return;
|
||||
let badge = btn.querySelector('.toolbar-unread-badge');
|
||||
if (count <= 0) { badge?.remove(); return; }
|
||||
if (!badge) {
|
||||
badge = document.createElement('span');
|
||||
badge.className = `toolbar-unread-badge${extraClass ? ` ${extraClass}` : ''}`;
|
||||
btn.append(badge);
|
||||
}
|
||||
badge.textContent = count > 99 ? '99+' : String(count);
|
||||
badge.setAttribute('aria-label', `${ariaLabel}: ${count}`);
|
||||
}
|
||||
|
||||
function applyCountersToMountedToolbars() {
|
||||
const c = normalizeCounters(state.userCounters);
|
||||
document.querySelectorAll('.toolbar').forEach((toolbar) => {
|
||||
renderBadge(toolbar.querySelector('[data-toolbar-page="messages-list"]'), c.dmUnreadCount, 'Непрочитанных личных сообщений');
|
||||
renderBadge(toolbar.querySelector('[data-toolbar-page="channels-list"]'), c.channelsUnreadCount, 'Непрочитанных сообщений в каналах');
|
||||
renderBadge(toolbar.querySelector('[data-toolbar-page="notifications-view"]'), c.notificationsUnreadCount, 'Новых уведомлений', 'notification-toolbar-badge');
|
||||
});
|
||||
return total;
|
||||
}
|
||||
|
||||
let countersPushBound = false;
|
||||
function ensureCountersPushBound() {
|
||||
if (countersPushBound) return;
|
||||
countersPushBound = true;
|
||||
authService.onEvent('UserCountersChanged', (event) => {
|
||||
setCounterState(event?.payload || {});
|
||||
applyCountersToMountedToolbars();
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshUserCounters() {
|
||||
if (!state.session.isAuthorized) return;
|
||||
try {
|
||||
setCounterState(await authService.getUserCounters());
|
||||
applyCountersToMountedToolbars();
|
||||
} catch {
|
||||
// Keep the last known counters; realtime push or the next refresh can recover.
|
||||
}
|
||||
}
|
||||
|
||||
function navigateWithGuestRules(pageId, navigate) {
|
||||
@@ -65,7 +117,8 @@ export function renderToolbar(currentPageId, navigate) {
|
||||
const root = document.createElement('nav');
|
||||
root.className = 'toolbar';
|
||||
const active = resolveToolbarActive(currentPageId);
|
||||
const unreadTotal = getTotalUnreadMessages();
|
||||
ensureCountersPushBound();
|
||||
const counters = normalizeCounters(state.userCounters);
|
||||
|
||||
ITEMS.forEach((item) => {
|
||||
const btn = document.createElement('button');
|
||||
@@ -92,21 +145,9 @@ export function renderToolbar(currentPageId, navigate) {
|
||||
} else {
|
||||
btn.innerHTML = `${iconHtml(item)}<span>${item.label}</span>`;
|
||||
}
|
||||
if (isMessages && unreadTotal > 0) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'toolbar-unread-badge';
|
||||
badge.textContent = unreadTotal > 99 ? '99+' : String(unreadTotal);
|
||||
badge.setAttribute('aria-label', `Непрочитанных сообщений: ${badge.textContent}`);
|
||||
btn.append(badge);
|
||||
}
|
||||
if (isNotifications && Number(state.notificationUnreadTotal || 0) > 0) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'toolbar-unread-badge notification-toolbar-badge';
|
||||
const n = Number(state.notificationUnreadTotal || 0);
|
||||
badge.textContent = n > 99 ? '99+' : String(n);
|
||||
badge.setAttribute('aria-label', `Новых уведомлений: ${n}`);
|
||||
btn.append(badge);
|
||||
}
|
||||
if (isMessages) renderBadge(btn, counters.dmUnreadCount, 'Непрочитанных личных сообщений');
|
||||
if (item.pageId === 'channels-list') renderBadge(btn, counters.channelsUnreadCount, 'Непрочитанных сообщений в каналах');
|
||||
if (isNotifications) renderBadge(btn, counters.notificationsUnreadCount, 'Новых уведомлений', 'notification-toolbar-badge');
|
||||
if (item.pageId === 'channels-list') {
|
||||
btn.addEventListener('click', () => navigate('channels-list'));
|
||||
} else {
|
||||
@@ -115,19 +156,7 @@ export function renderToolbar(currentPageId, navigate) {
|
||||
root.append(btn);
|
||||
});
|
||||
|
||||
if (state.session.isAuthorized && currentPageId !== 'notifications-view') {
|
||||
void authService.getNotifications(true).then((payload) => {
|
||||
const total = Number(payload?.repliesUnseenCount || 0) + Number(payload?.connectionsUnseenCount || 0) + Number(payload?.eventsUnseenCount || 0);
|
||||
state.notificationUnreadTotal = total;
|
||||
const btn = root.querySelector('[data-toolbar-page="notifications-view"]');
|
||||
if (!btn) return;
|
||||
let badge = btn.querySelector('.notification-toolbar-badge');
|
||||
if (total <= 0) { badge?.remove(); return; }
|
||||
if (!badge) { badge = document.createElement('span'); badge.className = 'toolbar-unread-badge notification-toolbar-badge'; btn.append(badge); }
|
||||
badge.textContent = total > 99 ? '99+' : String(total);
|
||||
badge.setAttribute('aria-label', `Новых уведомлений: ${total}`);
|
||||
}).catch(() => {});
|
||||
}
|
||||
if (state.session.isAuthorized) void refreshUserCounters();
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -1094,14 +1041,6 @@ function renderListContent({ screen, container, listState, navigate, refreshFeed
|
||||
container.append(list);
|
||||
}
|
||||
|
||||
function updateBottomCta({ button }) {
|
||||
if (!button) return;
|
||||
button.hidden = true;
|
||||
button.textContent = '';
|
||||
button.className = 'channels-bottom-action';
|
||||
button.onclick = null;
|
||||
}
|
||||
|
||||
async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
|
||||
renderSkeletonList(contentEl, 5);
|
||||
|
||||
@@ -1128,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');
|
||||
@@ -1215,9 +1153,6 @@ export function render({ navigate, route, chrome }) {
|
||||
});
|
||||
const topMenuBtn = topBarEl.querySelector('.channels-top-more-btn');
|
||||
|
||||
const bottomCta = document.createElement('button');
|
||||
bottomCta.type = 'button';
|
||||
|
||||
const reloadFeed = async () => loadFeedAndRender({ screen, listState, contentEl, navigate });
|
||||
|
||||
const rerenderList = () => {
|
||||
@@ -1234,18 +1169,15 @@ export function render({ navigate, route, chrome }) {
|
||||
topTitle.textContent = channelsViewTitle(listState.viewMode);
|
||||
topMenuBtn.style.display = '';
|
||||
|
||||
updateBottomCta({ button: bottomCta });
|
||||
};
|
||||
|
||||
chrome?.setTopbar(topBarEl);
|
||||
screen.append(contentEl, bottomCta);
|
||||
screen.append(contentEl);
|
||||
|
||||
if (createSuccessFlash) {
|
||||
showToast(createSuccessFlash);
|
||||
}
|
||||
|
||||
updateBottomCta({ button: bottomCta });
|
||||
|
||||
// Применяем корректное состояние хедера сразу на первом рендере,
|
||||
// чтобы не показывать лишние кнопки до первой перерисовки.
|
||||
rerenderList();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
position: relative;
|
||||
min-height: 52px;
|
||||
padding: 4px 3px 2px;
|
||||
display: grid;
|
||||
|
||||
Reference in New Issue
Block a user