SHA256
Display new notification count
This commit is contained in:
@@ -36,6 +36,7 @@ public final class DatabaseInitializer {
|
|||||||
public static final int SCHEMA_VERSION_17 = 17;
|
public static final int SCHEMA_VERSION_17 = 17;
|
||||||
public static final int SCHEMA_VERSION_18 = 18;
|
public static final int SCHEMA_VERSION_18 = 18;
|
||||||
public static final int SCHEMA_VERSION_19 = 19;
|
public static final int SCHEMA_VERSION_19 = 19;
|
||||||
|
public static final int SCHEMA_VERSION_20 = 20;
|
||||||
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
public static final String POSTGRES_SCHEMA_RESOURCE = "postgres/schema_v1.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
public static final String POSTGRES_MIGRATION_V2_RESOURCE = "postgres/migration_v2.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
public static final String POSTGRES_MIGRATION_V3_RESOURCE = "postgres/migration_v3.sql";
|
||||||
@@ -55,6 +56,7 @@ public final class DatabaseInitializer {
|
|||||||
public static final String POSTGRES_MIGRATION_V17_RESOURCE = "postgres/migration_v17.sql";
|
public static final String POSTGRES_MIGRATION_V17_RESOURCE = "postgres/migration_v17.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V18_RESOURCE = "postgres/migration_v18.sql";
|
public static final String POSTGRES_MIGRATION_V18_RESOURCE = "postgres/migration_v18.sql";
|
||||||
public static final String POSTGRES_MIGRATION_V19_RESOURCE = "postgres/migration_v19.sql";
|
public static final String POSTGRES_MIGRATION_V19_RESOURCE = "postgres/migration_v19.sql";
|
||||||
|
public static final String POSTGRES_MIGRATION_V20_RESOURCE = "postgres/migration_v20.sql";
|
||||||
|
|
||||||
private DatabaseInitializer() {}
|
private DatabaseInitializer() {}
|
||||||
|
|
||||||
@@ -200,6 +202,10 @@ public final class DatabaseInitializer {
|
|||||||
runSqlScript(conn, POSTGRES_MIGRATION_V19_RESOURCE);
|
runSqlScript(conn, POSTGRES_MIGRATION_V19_RESOURCE);
|
||||||
currentVersion = SCHEMA_VERSION_19;
|
currentVersion = SCHEMA_VERSION_19;
|
||||||
}
|
}
|
||||||
|
if (currentVersion < SCHEMA_VERSION_20) {
|
||||||
|
runSqlScript(conn, POSTGRES_MIGRATION_V20_RESOURCE);
|
||||||
|
currentVersion = SCHEMA_VERSION_20;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package shine.db.dao;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
|
||||||
|
public final class UserNotificationSeenStateDAO {
|
||||||
|
private static final UserNotificationSeenStateDAO INSTANCE = new UserNotificationSeenStateDAO();
|
||||||
|
private UserNotificationSeenStateDAO() {}
|
||||||
|
public static UserNotificationSeenStateDAO getInstance() { return INSTANCE; }
|
||||||
|
|
||||||
|
public long getSeenAt(Connection c, String login, String category) throws Exception {
|
||||||
|
try (PreparedStatement ps = c.prepareStatement("SELECT seen_at_ms FROM user_notification_seen_state WHERE owner_login=? AND category=?")) {
|
||||||
|
ps.setString(1, login); ps.setString(2, category);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) { return rs.next() ? rs.getLong(1) : 0L; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public long advance(Connection c, String login, String category, long seenAtMs, long signedAtMs, byte[] signedBlob) throws Exception {
|
||||||
|
String sql = """
|
||||||
|
INSERT INTO user_notification_seen_state(owner_login, category, seen_at_ms, signed_blob, signed_at_ms, updated_at_ms)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(owner_login, category) DO UPDATE SET
|
||||||
|
seen_at_ms=EXCLUDED.seen_at_ms, signed_blob=EXCLUDED.signed_blob,
|
||||||
|
signed_at_ms=EXCLUDED.signed_at_ms, updated_at_ms=EXCLUDED.updated_at_ms
|
||||||
|
WHERE user_notification_seen_state.seen_at_ms < EXCLUDED.seen_at_ms
|
||||||
|
""";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, login); ps.setString(2, category); ps.setLong(3, seenAtMs);
|
||||||
|
ps.setBytes(4, signedBlob); ps.setLong(5, signedAtMs); ps.setLong(6, System.currentTimeMillis());
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
return getSeenAt(c, login, category);
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -84,6 +84,33 @@ public final class UserNotificationsStateDAO {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<UserNotificationEntry> listVisible(Connection c, String ownerLogin, String kind, long seenAtMs, long cutoffMs) throws SQLException {
|
||||||
|
String sql = """
|
||||||
|
SELECT owner_login, notification_kind, created_at_ms, source_login, source_bch_name,
|
||||||
|
source_block_number, source_block_hash, target_login, target_bch_name,
|
||||||
|
target_block_number, target_block_hash, source_msg_sub_type, source_text
|
||||||
|
FROM user_notifications_state
|
||||||
|
WHERE owner_login = ? AND notification_kind = ?
|
||||||
|
AND (created_at_ms > ? OR created_at_ms >= ?)
|
||||||
|
ORDER BY created_at_ms DESC, source_block_number DESC
|
||||||
|
""";
|
||||||
|
List<UserNotificationEntry> out = new ArrayList<>();
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, ownerLogin); ps.setString(2, kind); ps.setLong(3, Math.max(0, seenAtMs));
|
||||||
|
ps.setLong(4, Math.max(0, cutoffMs));
|
||||||
|
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) out.add(mapRow(rs)); }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long countUnseen(Connection c, String ownerLogin, String kind, long seenAtMs) throws SQLException {
|
||||||
|
String sql = "SELECT COUNT(*) FROM user_notifications_state WHERE owner_login = ? AND notification_kind = ? AND created_at_ms > ?";
|
||||||
|
try (PreparedStatement ps = c.prepareStatement(sql)) {
|
||||||
|
ps.setString(1, ownerLogin); ps.setString(2, kind); ps.setLong(3, Math.max(0, seenAtMs));
|
||||||
|
try (ResultSet rs = ps.executeQuery()) { return rs.next() ? rs.getLong(1) : 0L; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static UserNotificationEntry mapRow(ResultSet rs) throws SQLException {
|
private static UserNotificationEntry mapRow(ResultSet rs) throws SQLException {
|
||||||
UserNotificationEntry e = new UserNotificationEntry();
|
UserNotificationEntry e = new UserNotificationEntry();
|
||||||
e.setOwnerLogin(rs.getString("owner_login"));
|
e.setOwnerLogin(rs.getString("owner_login"));
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Notifications v2: three categories + signed seen watermarks.
|
||||||
|
|
||||||
|
ALTER TABLE user_notifications_state
|
||||||
|
DROP CONSTRAINT IF EXISTS user_notifications_state_notification_kind_check;
|
||||||
|
ALTER TABLE user_notifications_state
|
||||||
|
ADD CONSTRAINT user_notifications_state_notification_kind_check
|
||||||
|
CHECK (notification_kind IN ('reply', 'connection', 'event'));
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_notification_seen_state (
|
||||||
|
owner_login VARCHAR(60) NOT NULL,
|
||||||
|
category TEXT NOT NULL CHECK (category IN ('replies', 'connections', 'events')),
|
||||||
|
seen_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
signed_blob BYTEA NOT NULL,
|
||||||
|
signed_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (owner_login, category)
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE db_schema_version SET schema_version = 20 WHERE id = 1;
|
||||||
@@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version (
|
|||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
INSERT INTO db_schema_version (id, schema_version, updated_at_ms)
|
||||||
VALUES (1, 18, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
VALUES (1, 20, CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT))
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
schema_version = EXCLUDED.schema_version,
|
schema_version = EXCLUDED.schema_version,
|
||||||
updated_at_ms = EXCLUDED.updated_at_ms;
|
updated_at_ms = EXCLUDED.updated_at_ms;
|
||||||
@@ -765,7 +765,7 @@ CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_notifications_state (
|
CREATE TABLE IF NOT EXISTS user_notifications_state (
|
||||||
owner_login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
owner_login TEXT NOT NULL REFERENCES solana_user_pda_current(normalized_login),
|
||||||
notification_kind TEXT NOT NULL CHECK (notification_kind IN ('reply', 'connection')),
|
notification_kind TEXT NOT NULL CHECK (notification_kind IN ('reply', 'connection', 'event')),
|
||||||
created_at_ms BIGINT NOT NULL,
|
created_at_ms BIGINT NOT NULL,
|
||||||
source_login TEXT NOT NULL,
|
source_login TEXT NOT NULL,
|
||||||
source_bch_name TEXT NOT NULL,
|
source_bch_name TEXT NOT NULL,
|
||||||
@@ -1997,8 +1997,20 @@ UPDATE message_stats ms SET
|
|||||||
AND rs.to_login=ms.to_login AND rs.to_bch_name=ms.to_bch_name AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash
|
AND rs.to_login=ms.to_login AND rs.to_bch_name=ms.to_bch_name AND rs.to_block_number=ms.to_block_number AND rs.to_block_hash=ms.to_block_hash
|
||||||
AND shine_is_primary(rs.from_login) AND shine_is_shining(rs.from_login));
|
AND shine_is_primary(rs.from_login) AND shine_is_shining(rs.from_login));
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_notification_seen_state (
|
||||||
|
owner_login VARCHAR(60) NOT NULL,
|
||||||
|
category TEXT NOT NULL CHECK (category IN ('replies', 'connections', 'events')),
|
||||||
|
seen_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
signed_blob BYTEA NOT NULL,
|
||||||
|
signed_at_ms BIGINT NOT NULL,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (owner_login, category)
|
||||||
|
);
|
||||||
|
|
||||||
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
|
INSERT INTO db_schema_version(id,schema_version,updated_at_ms)
|
||||||
VALUES(1,18,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
|
VALUES(1,20,CAST(EXTRACT(EPOCH FROM clock_timestamp())*1000 AS BIGINT))
|
||||||
ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms;
|
ON CONFLICT(id) DO UPDATE SET schema_version=EXCLUDED.schema_version, updated_at_ms=EXCLUDED.updated_at_ms;
|
||||||
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|
||||||
|
|||||||
+4
@@ -95,10 +95,12 @@ import server.logic.ws_protocol.JSON.handlers.profile.entyties.Net_ListUserProfi
|
|||||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler;
|
import server.logic.ws_protocol.JSON.handlers.connections.Net_AddCloseFriend_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler;
|
import server.logic.ws_protocol.JSON.handlers.connections.Net_ListContacts_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.notifications.Net_GetNotifications_Handler;
|
import server.logic.ws_protocol.JSON.handlers.notifications.Net_GetNotifications_Handler;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.notifications.Net_SetNotificationState_Handler;
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Request;
|
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetUserConnectionsGraph_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request;
|
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_AddCloseFriend_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request;
|
import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_ListContacts_Request;
|
||||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request;
|
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request;
|
||||||
|
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_SetNotificationState_Request;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_AckSessionDelivery_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_CallInviteBroadcast_Handler;
|
||||||
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
|
import server.logic.ws_protocol.JSON.messages.Net_CallSignalToSession_Handler;
|
||||||
@@ -215,6 +217,7 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("ListUserProfileChannels", new Net_ListUserProfileChannels_Handler()),
|
Map.entry("ListUserProfileChannels", new Net_ListUserProfileChannels_Handler()),
|
||||||
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
|
Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()),
|
||||||
Map.entry("GetNotifications", new Net_GetNotifications_Handler()),
|
Map.entry("GetNotifications", new Net_GetNotifications_Handler()),
|
||||||
|
Map.entry("SetNotificationState", new Net_SetNotificationState_Handler()),
|
||||||
|
|
||||||
// --- direct messages / push ---
|
// --- direct messages / push ---
|
||||||
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
|
Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()),
|
||||||
@@ -305,6 +308,7 @@ public final class JsonHandlerRegistry {
|
|||||||
Map.entry("ListUserProfileChannels", Net_ListUserProfileChannels_Request.class),
|
Map.entry("ListUserProfileChannels", Net_ListUserProfileChannels_Request.class),
|
||||||
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
|
Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class),
|
||||||
Map.entry("GetNotifications", Net_GetNotifications_Request.class),
|
Map.entry("GetNotifications", Net_GetNotifications_Request.class),
|
||||||
|
Map.entry("SetNotificationState", Net_SetNotificationState_Request.class),
|
||||||
|
|
||||||
// --- direct messages / push ---
|
// --- direct messages / push ---
|
||||||
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
|
Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class),
|
||||||
|
|||||||
+17
-10
@@ -916,16 +916,23 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
|
|||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connection notifications are intentionally modeled as a generic kind.
|
if (msgType == 3 && block.body instanceof ConnectionBody) {
|
||||||
// Current UI surfaces FRIEND and CLOSE_FRIEND here; other reserved relation types stay silent.
|
boolean personalConnection = msgSubType == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
||||||
if (msgType == 3
|
|| msgSubType == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF)
|
||||||
&& (msgSubType == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF)
|
|| msgSubType == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)
|
||||||
|| msgSubType == (MsgSubType.CONNECTION_FRIEND & 0xFFFF))
|
|| msgSubType == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF)
|
||||||
&& block.body instanceof ConnectionBody) {
|
|| msgSubType == (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF)
|
||||||
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
|
|| msgSubType == (MsgSubType.CONNECTION_SHINE_UNCONFIRMED & 0xFFFF)
|
||||||
entry.setNotificationKind("connection");
|
|| msgSubType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF)
|
||||||
entry.setSourceText("");
|
|| msgSubType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF);
|
||||||
return entry;
|
boolean channelEvent = msgSubType == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF)
|
||||||
|
|| msgSubType == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF);
|
||||||
|
if (personalConnection || channelEvent) {
|
||||||
|
UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType);
|
||||||
|
entry.setNotificationKind(channelEvent ? "event" : "connection");
|
||||||
|
entry.setSourceText("");
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+22
-89
@@ -1,94 +1,27 @@
|
|||||||
package server.logic.ws_protocol.JSON.handlers.notifications;
|
package server.logic.ws_protocol.JSON.handlers.notifications;
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger; import org.slf4j.LoggerFactory;
|
||||||
import org.slf4j.LoggerFactory;
|
import server.logic.ws_protocol.JSON.ConnectionContext; import server.logic.ws_protocol.JSON.entyties.*; import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
||||||
import server.logic.ws_protocol.JSON.ConnectionContext;
|
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.*; import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; import server.logic.ws_protocol.WireCodes;
|
||||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
import shine.db.DbController; import shine.db.dao.*; import shine.db.entities.UserNotificationEntry;
|
||||||
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
import java.sql.Connection; import java.util.*;
|
||||||
import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler;
|
|
||||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Request;
|
|
||||||
import server.logic.ws_protocol.JSON.handlers.notifications.entyties.Net_GetNotifications_Response;
|
|
||||||
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
|
||||||
import server.logic.ws_protocol.WireCodes;
|
|
||||||
import shine.db.DbController;
|
|
||||||
import shine.db.dao.UserNotificationsStateDAO;
|
|
||||||
import shine.db.entities.UserNotificationEntry;
|
|
||||||
|
|
||||||
import java.sql.Connection;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public final class Net_GetNotifications_Handler implements JsonMessageHandler {
|
public final class Net_GetNotifications_Handler implements JsonMessageHandler {
|
||||||
private static final Logger log = LoggerFactory.getLogger(Net_GetNotifications_Handler.class);
|
private static final Logger log=LoggerFactory.getLogger(Net_GetNotifications_Handler.class); private static final long HISTORY_MS=60L*24*60*60*1000;
|
||||||
|
public Net_Response handle(Net_Request base, ConnectionContext ctx){
|
||||||
@Override
|
Net_GetNotifications_Request req=(Net_GetNotifications_Request)base; if(ctx==null||!ctx.isAuthenticatedUser()||ctx.getCurrentUser()==null) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"NOT_AUTHENTICATED","Операция доступна только для авторизованных пользователей");
|
||||||
public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) {
|
String login=String.valueOf(ctx.getCurrentUser().getLogin()).trim();
|
||||||
Net_GetNotifications_Request req = (Net_GetNotifications_Request) baseRequest;
|
try(Connection c=DbController.getInstance().getConnection()){
|
||||||
if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getCurrentUser() == null) {
|
UserNotificationSeenStateDAO sd=UserNotificationSeenStateDAO.getInstance(); UserNotificationsStateDAO nd=UserNotificationsStateDAO.getInstance(); long cutoff=System.currentTimeMillis()-HISTORY_MS;
|
||||||
return NetExceptionResponseFactory.error(
|
long rs=sd.getSeenAt(c,login,"replies"), cs=sd.getSeenAt(c,login,"connections"), es=sd.getSeenAt(c,login,"events");
|
||||||
req,
|
Net_GetNotifications_Response r=new Net_GetNotifications_Response(); r.setOp(req.getOp());r.setRequestId(req.getRequestId());r.setStatus(WireCodes.Status.OK);r.setLogin(login);
|
||||||
WireCodes.Status.UNVERIFIED,
|
if (!Boolean.TRUE.equals(req.getCountsOnly())) {
|
||||||
"NOT_AUTHENTICATED",
|
r.setReplies(map(nd.listVisible(c,login,"reply",rs,cutoff))); r.setConnections(map(nd.listVisible(c,login,"connection",cs,cutoff))); r.setEvents(map(nd.listVisible(c,login,"event",es,cutoff)));
|
||||||
"Операция доступна только для авторизованных пользователей"
|
}
|
||||||
);
|
r.setRepliesSeenAtMs(rs);r.setConnectionsSeenAtMs(cs);r.setEventsSeenAtMs(es); r.setRepliesUnseenCount(nd.countUnseen(c,login,"reply",rs)); r.setConnectionsUnseenCount(nd.countUnseen(c,login,"connection",cs)); r.setEventsUnseenCount(nd.countUnseen(c,login,"event",es));
|
||||||
}
|
return r;
|
||||||
|
}catch(Exception e){log.error("GetNotifications failed",e);return NetExceptionResponseFactory.error(req,WireCodes.Status.INTERNAL_ERROR,"internal_error","Внутренняя ошибка сервера");}
|
||||||
String login = String.valueOf(ctx.getCurrentUser().getLogin() == null ? "" : ctx.getCurrentUser().getLogin()).trim();
|
}
|
||||||
if (login.isBlank()) {
|
private List<Net_GetNotifications_Response.NotificationItem> map(List<UserNotificationEntry> rows){ List<Net_GetNotifications_Response.NotificationItem> out=new ArrayList<>(); for(UserNotificationEntry x:rows){ Net_GetNotifications_Response.NotificationItem i=new Net_GetNotifications_Response.NotificationItem(); i.setKind(x.getNotificationKind());i.setCreatedAtMs(x.getCreatedAtMs());i.setSourceLogin(x.getSourceLogin());i.setSourceBlockchainName(x.getSourceBchName());i.setSourceBlockNumber(x.getSourceBlockNumber());i.setSourceBlockHash(hex(x.getSourceBlockHash()));i.setSourceMsgSubType(x.getSourceMsgSubType());i.setConnectionTypeCode("connection".equals(x.getNotificationKind())?x.getSourceMsgSubType():null);i.setSourceText(x.getSourceText());i.setTargetLogin(x.getTargetLogin());i.setTargetBlockchainName(x.getTargetBchName());i.setTargetBlockNumber(x.getTargetBlockNumber());i.setTargetBlockHash(hex(x.getTargetBlockHash()));out.add(i);} return out; }
|
||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Не удалось определить авторизованного пользователя");
|
private static String hex(byte[] b){if(b==null)return null;StringBuilder s=new StringBuilder();for(byte x:b)s.append(String.format("%02x",x));return s.toString();}
|
||||||
}
|
|
||||||
|
|
||||||
int limit = req.getLimit() == null ? 50 : Math.max(1, Math.min(200, req.getLimit()));
|
|
||||||
|
|
||||||
try (Connection c = DbController.getInstance().getConnection()) {
|
|
||||||
List<UserNotificationEntry> replyRows = UserNotificationsStateDAO.getInstance().listByOwnerAndKind(c, login, "reply", limit);
|
|
||||||
List<UserNotificationEntry> eventRows = UserNotificationsStateDAO.getInstance().listByOwnerAndKind(c, login, "connection", limit);
|
|
||||||
|
|
||||||
Net_GetNotifications_Response resp = new Net_GetNotifications_Response();
|
|
||||||
resp.setOp(req.getOp());
|
|
||||||
resp.setRequestId(req.getRequestId());
|
|
||||||
resp.setStatus(WireCodes.Status.OK);
|
|
||||||
resp.setLogin(login);
|
|
||||||
resp.setReplies(mapRows(replyRows));
|
|
||||||
resp.setEvents(mapRows(eventRows));
|
|
||||||
return resp;
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("GetNotifications failed", e);
|
|
||||||
return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<Net_GetNotifications_Response.NotificationItem> mapRows(List<UserNotificationEntry> rows) {
|
|
||||||
List<Net_GetNotifications_Response.NotificationItem> out = new ArrayList<>();
|
|
||||||
for (UserNotificationEntry row : rows) {
|
|
||||||
Net_GetNotifications_Response.NotificationItem item = new Net_GetNotifications_Response.NotificationItem();
|
|
||||||
item.setKind(row.getNotificationKind());
|
|
||||||
item.setCreatedAtMs(row.getCreatedAtMs());
|
|
||||||
item.setSourceLogin(row.getSourceLogin());
|
|
||||||
item.setSourceBlockchainName(row.getSourceBchName());
|
|
||||||
item.setSourceBlockNumber(row.getSourceBlockNumber());
|
|
||||||
item.setSourceBlockHash(bytesToHex(row.getSourceBlockHash()));
|
|
||||||
item.setSourceMsgSubType(row.getSourceMsgSubType());
|
|
||||||
item.setConnectionTypeCode("connection".equals(row.getNotificationKind()) ? row.getSourceMsgSubType() : null);
|
|
||||||
item.setSourceText(row.getSourceText());
|
|
||||||
item.setTargetLogin(row.getTargetLogin());
|
|
||||||
item.setTargetBlockchainName(row.getTargetBchName());
|
|
||||||
item.setTargetBlockNumber(row.getTargetBlockNumber());
|
|
||||||
item.setTargetBlockHash(bytesToHex(row.getTargetBlockHash()));
|
|
||||||
out.add(item);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String bytesToHex(byte[] bytes) {
|
|
||||||
if (bytes == null) return null;
|
|
||||||
char[] HEX = "0123456789abcdef".toCharArray();
|
|
||||||
char[] out = new char[bytes.length * 2];
|
|
||||||
for (int i = 0; i < bytes.length; i++) {
|
|
||||||
int v = bytes[i] & 0xff;
|
|
||||||
out[i * 2] = HEX[v >>> 4];
|
|
||||||
out[i * 2 + 1] = HEX[v & 0x0f];
|
|
||||||
}
|
|
||||||
return new String(out);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.notifications;
|
||||||
|
|
||||||
|
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.notifications.entyties.*;
|
||||||
|
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
|
||||||
|
import server.logic.ws_protocol.WireCodes;
|
||||||
|
import shine.db.DbController;
|
||||||
|
import shine.db.dao.UserNotificationSeenStateDAO;
|
||||||
|
import utils.crypto.Ed25519Util;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
public final class Net_SetNotificationState_Handler implements JsonMessageHandler {
|
||||||
|
public Net_Response handle(Net_Request base, ConnectionContext ctx){
|
||||||
|
Net_SetNotificationState_Request req=(Net_SetNotificationState_Request)base;
|
||||||
|
if(ctx==null||!ctx.isAuthenticatedUser()||ctx.getCurrentUser()==null) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"NOT_AUTHENTICATED","Требуется авторизация");
|
||||||
|
try{
|
||||||
|
byte[] raw=Base64.getDecoder().decode(String.valueOf(req.getBlobB64()).trim()); NotificationStatePacket p=NotificationStatePacket.parse(raw);
|
||||||
|
String login=String.valueOf(ctx.getCurrentUser().getLogin()).trim(); if(!login.equalsIgnoreCase(p.login)) return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"LOGIN_MISMATCH","Подпись принадлежит другому пользователю");
|
||||||
|
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;
|
||||||
|
}catch(Exception e){ return NetExceptionResponseFactory.error(req,WireCodes.Status.UNVERIFIED,"BAD_NOTIFICATION_STATE",e.getMessage()==null?"Некорректное состояние уведомлений":e.getMessage()); }
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.notifications;
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
import java.nio.ByteOrder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
final class NotificationStatePacket {
|
||||||
|
static final byte[] PREFIX = "SHiNE_NTF".getBytes(StandardCharsets.US_ASCII);
|
||||||
|
static final int STATE_SEEN_WATERMARK = 1;
|
||||||
|
static final int CATEGORY_REPLIES = 1;
|
||||||
|
static final int CATEGORY_CONNECTIONS = 2;
|
||||||
|
static final int CATEGORY_EVENTS = 3;
|
||||||
|
|
||||||
|
final String login; final long timeMs; final long nonce; final int stateType; final int category; final long seenAtMs;
|
||||||
|
final byte[] signedBody; final byte[] signature64; final byte[] rawPacket;
|
||||||
|
private NotificationStatePacket(String login,long timeMs,long nonce,int stateType,int category,long seenAtMs,byte[] signedBody,byte[] signature64,byte[] rawPacket){
|
||||||
|
this.login=login;this.timeMs=timeMs;this.nonce=nonce;this.stateType=stateType;this.category=category;this.seenAtMs=seenAtMs;this.signedBody=signedBody;this.signature64=signature64;this.rawPacket=rawPacket;
|
||||||
|
}
|
||||||
|
static NotificationStatePacket parse(byte[] raw) {
|
||||||
|
if(raw==null||raw.length<PREFIX.length+2+1+1+8+4+1+1+8+64) throw new IllegalArgumentException("BAD_LEN");
|
||||||
|
for(int i=0;i<PREFIX.length;i++) if(raw[i]!=PREFIX[i]) throw new IllegalArgumentException("BAD_PREFIX");
|
||||||
|
ByteBuffer bb=ByteBuffer.wrap(raw).order(ByteOrder.BIG_ENDIAN); bb.position(PREFIX.length);
|
||||||
|
int major=Byte.toUnsignedInt(bb.get()), minor=Byte.toUnsignedInt(bb.get());
|
||||||
|
if(major!=1||minor!=0) throw new IllegalArgumentException("BAD_FORMAT_VERSION");
|
||||||
|
int len=Byte.toUnsignedInt(bb.get()); if(len<1||len>60||bb.remaining()<len+8+4+1+1+8+64) throw new IllegalArgumentException("BAD_LOGIN");
|
||||||
|
byte[] lb=new byte[len]; bb.get(lb); for(byte b:lb) if(b<0x20||b>0x7e) throw new IllegalArgumentException("BAD_LOGIN");
|
||||||
|
String login=new String(lb,StandardCharsets.US_ASCII); long timeMs=bb.getLong(); if(timeMs<0) throw new IllegalArgumentException("BAD_TIME");
|
||||||
|
long nonce=Integer.toUnsignedLong(bb.getInt()); int stateType=Byte.toUnsignedInt(bb.get()); if(stateType!=STATE_SEEN_WATERMARK) throw new IllegalArgumentException("BAD_STATE_TYPE");
|
||||||
|
int category=Byte.toUnsignedInt(bb.get()); if(category<1||category>3) throw new IllegalArgumentException("BAD_CATEGORY");
|
||||||
|
long seenAtMs=bb.getLong(); if(seenAtMs<0||bb.remaining()!=64) throw new IllegalArgumentException("BAD_SEEN_TIME");
|
||||||
|
byte[] sig=new byte[64]; bb.get(sig); return new NotificationStatePacket(login,timeMs,nonce,stateType,category,seenAtMs,Arrays.copyOf(raw,raw.length-64),sig,raw);
|
||||||
|
}
|
||||||
|
String categoryName(){ return category==CATEGORY_REPLIES?"replies":category==CATEGORY_CONNECTIONS?"connections":"events"; }
|
||||||
|
}
|
||||||
+4
-2
@@ -3,8 +3,10 @@ package server.logic.ws_protocol.JSON.handlers.notifications.entyties;
|
|||||||
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
|
||||||
public class Net_GetNotifications_Request extends Net_Request {
|
public class Net_GetNotifications_Request extends Net_Request {
|
||||||
private Integer limit;
|
private Integer limit; // legacy: поле принимается для совместимости, но в v2 не ограничивает выдачу
|
||||||
|
private Boolean countsOnly;
|
||||||
public Integer getLimit() { return limit; }
|
public Integer getLimit() { return limit; }
|
||||||
public void setLimit(Integer limit) { this.limit = limit; }
|
public void setLimit(Integer limit) { this.limit = limit; }
|
||||||
|
public Boolean getCountsOnly() { return countsOnly; }
|
||||||
|
public void setCountsOnly(Boolean countsOnly) { this.countsOnly = countsOnly; }
|
||||||
}
|
}
|
||||||
|
|||||||
+11
@@ -8,12 +8,23 @@ import java.util.List;
|
|||||||
public class Net_GetNotifications_Response extends Net_Response {
|
public class Net_GetNotifications_Response extends Net_Response {
|
||||||
private String login;
|
private String login;
|
||||||
private List<NotificationItem> replies = new ArrayList<>();
|
private List<NotificationItem> replies = new ArrayList<>();
|
||||||
|
private List<NotificationItem> connections = new ArrayList<>();
|
||||||
private List<NotificationItem> events = new ArrayList<>();
|
private List<NotificationItem> events = new ArrayList<>();
|
||||||
|
private long repliesSeenAtMs, connectionsSeenAtMs, eventsSeenAtMs;
|
||||||
|
private long repliesUnseenCount, connectionsUnseenCount, eventsUnseenCount;
|
||||||
|
|
||||||
public String getLogin() { return login; }
|
public String getLogin() { return login; }
|
||||||
public void setLogin(String login) { this.login = login; }
|
public void setLogin(String login) { this.login = login; }
|
||||||
public List<NotificationItem> getReplies() { return replies; }
|
public List<NotificationItem> getReplies() { return replies; }
|
||||||
public void setReplies(List<NotificationItem> replies) { this.replies = replies; }
|
public void setReplies(List<NotificationItem> replies) { this.replies = replies; }
|
||||||
|
public List<NotificationItem> getConnections() { return connections; }
|
||||||
|
public void setConnections(List<NotificationItem> v) { connections = v; }
|
||||||
|
public long getRepliesSeenAtMs(){return repliesSeenAtMs;} public void setRepliesSeenAtMs(long v){repliesSeenAtMs=v;}
|
||||||
|
public long getConnectionsSeenAtMs(){return connectionsSeenAtMs;} public void setConnectionsSeenAtMs(long v){connectionsSeenAtMs=v;}
|
||||||
|
public long getEventsSeenAtMs(){return eventsSeenAtMs;} public void setEventsSeenAtMs(long v){eventsSeenAtMs=v;}
|
||||||
|
public long getRepliesUnseenCount(){return repliesUnseenCount;} public void setRepliesUnseenCount(long v){repliesUnseenCount=v;}
|
||||||
|
public long getConnectionsUnseenCount(){return connectionsUnseenCount;} public void setConnectionsUnseenCount(long v){connectionsUnseenCount=v;}
|
||||||
|
public long getEventsUnseenCount(){return eventsUnseenCount;} public void setEventsUnseenCount(long v){eventsUnseenCount=v;}
|
||||||
public List<NotificationItem> getEvents() { return events; }
|
public List<NotificationItem> getEvents() { return events; }
|
||||||
public void setEvents(List<NotificationItem> events) { this.events = events; }
|
public void setEvents(List<NotificationItem> events) { this.events = events; }
|
||||||
|
|
||||||
|
|||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.notifications.entyties;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Request;
|
||||||
|
public class Net_SetNotificationState_Request extends Net_Request { private String blobB64; public String getBlobB64(){return blobB64;} public void setBlobB64(String v){blobB64=v;} }
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
package server.logic.ws_protocol.JSON.handlers.notifications.entyties;
|
||||||
|
import server.logic.ws_protocol.JSON.entyties.Net_Response;
|
||||||
|
public class Net_SetNotificationState_Response extends Net_Response { private String category; private long seenAtMs; public String getCategory(){return category;} public void setCategory(String v){category=v;} public long getSeenAtMs(){return seenAtMs;} public void setSeenAtMs(long v){seenAtMs=v;} }
|
||||||
@@ -60,7 +60,8 @@
|
|||||||
| `ListContacts` | `11_Connections_API.md` | контакты текущего пользователя |
|
| `ListContacts` | `11_Connections_API.md` | контакты текущего пользователя |
|
||||||
| `GetUserConnectionsGraph` | `11_Connections_API.md` | граф связей пользователя |
|
| `GetUserConnectionsGraph` | `11_Connections_API.md` | граф связей пользователя |
|
||||||
| `AddCloseFriend` | `11_Connections_API.md` | добавить близкого друга |
|
| `AddCloseFriend` | `11_Connections_API.md` | добавить близкого друга |
|
||||||
| `GetNotifications` | `15_Notifications_API.md` | ответы и события уведомлений |
|
| `GetNotifications` | `15_Notifications_API.md` | ответы, связи, события и unread-watermark |
|
||||||
|
| `SetNotificationState` | `15_Notifications_API.md` | подписанное состояние просмотра уведомлений |
|
||||||
| `UpsertPushToken` | `12_Direct_Messages_Push_Calls_API.md` | регистрация WebPush-токена |
|
| `UpsertPushToken` | `12_Direct_Messages_Push_Calls_API.md` | регистрация WebPush-токена |
|
||||||
| `SendTestWebPush` | `12_Direct_Messages_Push_Calls_API.md` | тестовая push-доставка |
|
| `SendTestWebPush` | `12_Direct_Messages_Push_Calls_API.md` | тестовая push-доставка |
|
||||||
| `SendMessagePair` | `12_Direct_Messages_Push_Calls_API.md` | отправка пары входящий/исходящий DM |
|
| `SendMessagePair` | `12_Direct_Messages_Push_Calls_API.md` | отправка пары входящий/исходящий DM |
|
||||||
|
|||||||
@@ -1,81 +1,42 @@
|
|||||||
# API для разработчиков: уведомления
|
# API для разработчиков: уведомления
|
||||||
|
|
||||||
Документ описывает чтение пользовательских уведомлений, которые сервер индексирует при `AddBlock`.
|
Уведомления являются серверной проекцией событий блокчейна. Сервер возвращает все непросмотренные записи независимо от возраста и просмотренные записи не старше 60 дней. Пагинации нет: выдача содержит все непросмотренные и всю доступную 60-дневную просмотренную историю.
|
||||||
|
|
||||||
Текущая операция:
|
## GetNotifications
|
||||||
|
|
||||||
- `GetNotifications`
|
Авторизация обязательна. Обычно payload пустой. Legacy-поле `limit` принимается для совместимости, но в v2 игнорируется. Для обновления badge без загрузки карточек можно передать `{"countsOnly":true}`; тогда массивы лент остаются пустыми, но watermark и `*UnseenCount` возвращаются.
|
||||||
|
|
||||||
## 1. `GetNotifications`
|
Ответ содержит три ленты: `replies`, `connections`, `events`, а также `*SeenAtMs` и `*UnseenCount` для каждой категории.
|
||||||
|
|
||||||
Метод читает уведомления текущего пользователя. В `payload.login` можно передать логин явно, но обычно клиент использует авторизованную сессию.
|
- `replies`: TEXT_REPLY.
|
||||||
|
- `connections`: friend/unfriend, close_friend/unclose_friend, shine confirmed/unconfirmed, official confirmed/unconfirmed. Контакты не создают уведомлений.
|
||||||
|
- `events`: FOLLOW/UNFOLLOW каналов.
|
||||||
|
|
||||||
Возвращаются две отдельные ленты:
|
Фильтр каждой категории: `created_at_ms > seenAtMs OR created_at_ms >= now - 60 days`.
|
||||||
|
|
||||||
- `replies` — ответы на сообщения пользователя в каналах и тредах;
|
## SetNotificationState
|
||||||
- `events` — события добавления в `close_friend`.
|
|
||||||
|
|
||||||
### Запрос
|
Сохраняет подписанный watermark просмотра. Сервер принимает только монотонное движение `seenAtMs` вперёд.
|
||||||
|
|
||||||
|
Запрос:
|
||||||
```json
|
```json
|
||||||
{
|
{"op":"SetNotificationState","requestId":"ntf-seen-1","payload":{"blobB64":"..."}}
|
||||||
"op": "GetNotifications",
|
|
||||||
"requestId": "notif-001",
|
|
||||||
"payload": {
|
|
||||||
"login": "alice",
|
|
||||||
"limit": 50
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Успешный ответ
|
Бинарный контейнер `SHiNE_NTF` v1.0 (big-endian):
|
||||||
|
|
||||||
```json
|
```text
|
||||||
{
|
'SHiNE_NTF' 9 bytes ASCII
|
||||||
"op": "GetNotifications",
|
formatVersionMajor u8 = 1
|
||||||
"requestId": "notif-001",
|
formatVersionMinor u8 = 0
|
||||||
"status": 200,
|
loginLen u8
|
||||||
"ok": true,
|
login ASCII[loginLen]
|
||||||
"payload": {
|
timeMs u64
|
||||||
"login": "Alice",
|
nonce u32
|
||||||
"replies": [
|
stateType u8 = 1 (SEEN_WATERMARK)
|
||||||
{
|
category u8 (1 replies, 2 connections, 3 events)
|
||||||
"kind": "reply",
|
seenAtMs u64
|
||||||
"createdAtMs": 1755673200000,
|
signature Ed25519[64]
|
||||||
"sourceLogin": "Bob",
|
|
||||||
"sourceBlockchainName": "bob-001",
|
|
||||||
"sourceBlockNumber": 42,
|
|
||||||
"sourceBlockHash": "ab12...",
|
|
||||||
"sourceMsgSubType": 20,
|
|
||||||
"sourceText": "Спасибо!",
|
|
||||||
"targetLogin": "Alice",
|
|
||||||
"targetBlockchainName": "alice-001",
|
|
||||||
"targetBlockNumber": 18,
|
|
||||||
"targetBlockHash": "cd34..."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"events": [
|
|
||||||
{
|
|
||||||
"kind": "close_friend",
|
|
||||||
"createdAtMs": 1755673300000,
|
|
||||||
"sourceLogin": "Kate",
|
|
||||||
"sourceBlockchainName": "kate-001",
|
|
||||||
"sourceBlockNumber": 7,
|
|
||||||
"sourceBlockHash": "ef56...",
|
|
||||||
"sourceMsgSubType": 10,
|
|
||||||
"sourceText": "close_friend",
|
|
||||||
"targetLogin": "Alice",
|
|
||||||
"targetBlockchainName": "alice-001",
|
|
||||||
"targetBlockNumber": 0,
|
|
||||||
"targetBlockHash": "0000..."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Примечание
|
Подпись `clientKey` вычисляется над всеми байтами контейнера до `signature`, по тому же принципу, что подписанный контейнер `SHiNE_DM`. Сервер проверяет, что `login` совпадает с авторизованным пользователем, проверяет Ed25519-подпись и сохраняет также исходный signed blob для будущей переносимой синхронизации состояния.
|
||||||
|
|
||||||
- `replies` заполняется только для `TEXT_REPLY`.
|
|
||||||
- `events` заполняется только для входящего `CONNECTION_FRIEND` / `close_friend`.
|
|
||||||
- Другие типы связей в эту ленту не попадают.
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { resolveToolbarActive } from '../router.js';
|
import { resolveToolbarActive } from '../router.js';
|
||||||
import { state } from '../state.js';
|
import { state, authService } from '../state.js';
|
||||||
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
||||||
import { SHINE_CONNECTIONS_LOGO_SRC } from './shine-logo.js';
|
import { SHINE_CONNECTIONS_LOGO_SRC } from './shine-logo.js';
|
||||||
|
|
||||||
@@ -72,6 +72,8 @@ export function renderToolbar(currentPageId, navigate) {
|
|||||||
const isProfile = item.pageId === 'profile-view';
|
const isProfile = item.pageId === 'profile-view';
|
||||||
const isMessages = item.pageId === 'messages-list';
|
const isMessages = item.pageId === 'messages-list';
|
||||||
const isNetwork = item.pageId === 'network-view';
|
const isNetwork = item.pageId === 'network-view';
|
||||||
|
const isNotifications = item.pageId === 'notifications-view';
|
||||||
|
btn.dataset.toolbarPage = item.pageId;
|
||||||
btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}${isProfile ? ' toolbar-btn-profile' : ''}${isMessages ? ' toolbar-btn-messages' : ''}${isNetwork ? ' toolbar-btn-network' : ''}${item.hero ? ' toolbar-btn-hero' : ''}`;
|
btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}${isProfile ? ' toolbar-btn-profile' : ''}${isMessages ? ' toolbar-btn-messages' : ''}${isNetwork ? ' toolbar-btn-network' : ''}${item.hero ? ' toolbar-btn-hero' : ''}`;
|
||||||
if (isProfile) {
|
if (isProfile) {
|
||||||
btn.innerHTML = `
|
btn.innerHTML = `
|
||||||
@@ -97,6 +99,14 @@ export function renderToolbar(currentPageId, navigate) {
|
|||||||
badge.setAttribute('aria-label', `Непрочитанных сообщений: ${badge.textContent}`);
|
badge.setAttribute('aria-label', `Непрочитанных сообщений: ${badge.textContent}`);
|
||||||
btn.append(badge);
|
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 (item.pageId === 'channels-list') {
|
if (item.pageId === 'channels-list') {
|
||||||
btn.addEventListener('click', () => navigate('channels-list'));
|
btn.addEventListener('click', () => navigate('channels-list'));
|
||||||
} else {
|
} else {
|
||||||
@@ -105,5 +115,19 @@ export function renderToolbar(currentPageId, navigate) {
|
|||||||
root.append(btn);
|
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(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2266,9 +2266,12 @@ export function render({ navigate, route, chrome }) {
|
|||||||
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
leftAction: { label: '<', onClick: () => navigate('channels-list') },
|
||||||
rightActions: [
|
rightActions: [
|
||||||
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
|
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
|
||||||
|
{ label: '⋯', className: 'channel-header-more-btn', onClick: () => {} },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
header.classList.add('channel-view-topbar');
|
||||||
const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-btn');
|
const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-btn');
|
||||||
|
const channelMoreButton = header.querySelector('.header-actions .channel-header-more-btn');
|
||||||
if (channelEntrypointButton) {
|
if (channelEntrypointButton) {
|
||||||
channelEntrypointButton.disabled = true;
|
channelEntrypointButton.disabled = true;
|
||||||
channelEntrypointButton.hidden = true;
|
channelEntrypointButton.hidden = true;
|
||||||
@@ -2544,6 +2547,36 @@ export function render({ navigate, route, chrome }) {
|
|||||||
if (aboutRoute) navigate(aboutRoute);
|
if (aboutRoute) navigate(aboutRoute);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (channelMoreButton) {
|
||||||
|
channelMoreButton.disabled = false;
|
||||||
|
channelMoreButton.onclick = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
header.querySelector('.channel-header-more-menu')?.remove();
|
||||||
|
const menu = document.createElement('div');
|
||||||
|
menu.className = 'channel-header-more-menu';
|
||||||
|
const about = document.createElement('button'); about.type='button'; about.textContent='О канале';
|
||||||
|
about.onclick = () => {
|
||||||
|
const aboutRoute = makeShineChannelAboutRoute({ ownerBlockchainName: apiData?.channel?.ownerBlockchainName || apiData?.selector?.ownerBlockchainName || '', channelRootBlockNumber: apiData?.selector?.channelRootBlockNumber ?? '', channelRootBlockHash: apiData?.selector?.channelRootBlockHash ?? '' });
|
||||||
|
menu.remove(); if (aboutRoute) navigate(aboutRoute);
|
||||||
|
};
|
||||||
|
menu.append(about);
|
||||||
|
if (apiData?.isSubscribed && !apiData?.isOwnChannel) {
|
||||||
|
const unfollow = document.createElement('button'); unfollow.type='button'; unfollow.className='is-danger'; unfollow.textContent='Отписаться от канала';
|
||||||
|
unfollow.onclick = async () => {
|
||||||
|
menu.remove();
|
||||||
|
try {
|
||||||
|
const { login, storagePwd } = requireSigningSession();
|
||||||
|
await authService.addBlockFollowChannel({ login, storagePwd, targetBlockchainName: apiData.selector.ownerBlockchainName, targetBlockNumber: apiData.selector.channelRootBlockNumber, targetBlockHashHex: apiData.selector.channelRootBlockHash, unfollow: true });
|
||||||
|
const feed = await authService.listSubscriptionsFeed(login, 200); setChannelsFeed(feed, state.channelsIndex); showToast('Вы отписались от канала'); rerender();
|
||||||
|
} catch (error) { showStatus(toUserMessage(error, 'Не удалось отписаться от канала.')); }
|
||||||
|
};
|
||||||
|
menu.append(unfollow);
|
||||||
|
}
|
||||||
|
header.append(menu);
|
||||||
|
const close = (e) => { if (!menu.contains(e.target) && e.target !== channelMoreButton) { menu.remove(); document.removeEventListener('click', close, true); } };
|
||||||
|
setTimeout(() => document.addEventListener('click', close, true), 0);
|
||||||
|
};
|
||||||
|
}
|
||||||
if (channelEntrypointButton) {
|
if (channelEntrypointButton) {
|
||||||
const canShowEntrypointButton = !apiData?.isDiary && entrypointPosts.length > 0;
|
const canShowEntrypointButton = !apiData?.isDiary && entrypointPosts.length > 0;
|
||||||
channelEntrypointButton.hidden = !canShowEntrypointButton;
|
channelEntrypointButton.hidden = !canShowEntrypointButton;
|
||||||
|
|||||||
@@ -5,18 +5,37 @@ import { loadProfileSnapshot } from '../services/user-profile-params.js';
|
|||||||
import { makeProfileRoute, makeShineMessageRoute } from '../services/shine-routes.js';
|
import { makeProfileRoute, makeShineMessageRoute } from '../services/shine-routes.js';
|
||||||
|
|
||||||
const CONNECTION_CLOSE_FRIEND = 10;
|
const CONNECTION_CLOSE_FRIEND = 10;
|
||||||
|
const CONNECTION_UNCLOSE_FRIEND = 11;
|
||||||
|
const CONNECTION_FRIEND = 14;
|
||||||
|
const CONNECTION_UNFRIEND = 15;
|
||||||
|
const CONNECTION_FOLLOW = 30;
|
||||||
|
const CONNECTION_UNFOLLOW = 31;
|
||||||
|
const CONNECTION_SHINE_CONFIRMED = 70;
|
||||||
|
const CONNECTION_SHINE_UNCONFIRMED = 71;
|
||||||
|
const CONNECTION_OFFICIAL_CONFIRMED = 80;
|
||||||
|
const CONNECTION_OFFICIAL_UNCONFIRMED = 81;
|
||||||
const profileSnapshotCache = new Map();
|
const profileSnapshotCache = new Map();
|
||||||
const profileSnapshotPending = new Map();
|
const profileSnapshotPending = new Map();
|
||||||
|
|
||||||
function connectionTypeLabel(typeCode) {
|
function connectionActionLabel(typeCode) {
|
||||||
switch (Number(typeCode)) {
|
switch (Number(typeCode)) {
|
||||||
case CONNECTION_CLOSE_FRIEND:
|
case CONNECTION_CLOSE_FRIEND: return 'Добавил(а) вас в близкие друзья.';
|
||||||
return 'близкие друзья';
|
case CONNECTION_UNCLOSE_FRIEND: return 'Удалил(а) вас из близких друзей.';
|
||||||
default:
|
case CONNECTION_FRIEND: return 'Добавил(а) вас в друзья.';
|
||||||
return 'новую связь';
|
case CONNECTION_UNFRIEND: return 'Удалил(а) вас из друзей.';
|
||||||
|
case CONNECTION_SHINE_CONFIRMED: return 'Подтвердил(а), что вы Сияющий.';
|
||||||
|
case CONNECTION_SHINE_UNCONFIRMED: return 'Снял(а) подтверждение «Сияющий».';
|
||||||
|
case CONNECTION_OFFICIAL_CONFIRMED: return 'Подтвердил(а) официальный статус аккаунта.';
|
||||||
|
case CONNECTION_OFFICIAL_UNCONFIRMED: return 'Снял(а) подтверждение официального статуса.';
|
||||||
|
default: return 'Изменил(а) связь с вами.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function eventActionLabel(typeCode) {
|
||||||
|
if (Number(typeCode) === CONNECTION_UNFOLLOW) return 'Отписался(-ась) от вашего канала.';
|
||||||
|
return 'Подписался(-ась) на ваш канал.';
|
||||||
|
}
|
||||||
|
|
||||||
export const pageMeta = { id: 'notifications-view', title: 'Уведомления' };
|
export const pageMeta = { id: 'notifications-view', title: 'Уведомления' };
|
||||||
|
|
||||||
function normalizeItem(item) {
|
function normalizeItem(item) {
|
||||||
@@ -136,12 +155,10 @@ function renderEmpty(activeTab) {
|
|||||||
const card = document.createElement('article');
|
const card = document.createElement('article');
|
||||||
card.className = 'card stack notification-empty-state';
|
card.className = 'card stack notification-empty-state';
|
||||||
const title = document.createElement('strong');
|
const title = document.createElement('strong');
|
||||||
title.textContent = activeTab === 'events' ? 'Пока нет событий' : 'Пока нет ответов';
|
title.textContent = activeTab === 'events' ? 'Пока нет событий' : activeTab === 'connections' ? 'Пока нет связей' : 'Пока нет ответов';
|
||||||
const text = document.createElement('p');
|
const text = document.createElement('p');
|
||||||
text.className = 'meta-muted';
|
text.className = 'meta-muted';
|
||||||
text.textContent = activeTab === 'events'
|
text.textContent = activeTab === 'events' ? 'Подписки и отписки от ваших каналов появятся здесь.' : activeTab === 'connections' ? 'Изменения дружбы и подтверждений появятся здесь.' : 'Когда кто-то ответит на ваше сообщение в канале или треде, ответ появится здесь.';
|
||||||
? 'Когда кто-то добавит новую связь с вами, событие появится здесь.'
|
|
||||||
: 'Когда кто-то ответит на ваше сообщение в канале или треде, ответ появится здесь.';
|
|
||||||
card.append(title, text);
|
card.append(title, text);
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
@@ -239,7 +256,7 @@ function renderEngagement(engagement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function notificationRoute(item, activeTab) {
|
function notificationRoute(item, activeTab) {
|
||||||
if (activeTab === 'events') {
|
if (activeTab === 'events' || activeTab === 'connections') {
|
||||||
const login = String(item?.sourceLogin || '').trim();
|
const login = String(item?.sourceLogin || '').trim();
|
||||||
return login ? makeProfileRoute(login) : '';
|
return login ? makeProfileRoute(login) : '';
|
||||||
}
|
}
|
||||||
@@ -278,8 +295,10 @@ function renderItem(item, activeTab, navigate) {
|
|||||||
|
|
||||||
const action = document.createElement('p');
|
const action = document.createElement('p');
|
||||||
action.className = 'notification-action';
|
action.className = 'notification-action';
|
||||||
if (activeTab === 'events') {
|
if (activeTab === 'connections') {
|
||||||
action.textContent = `Добавил(а) вас в ${connectionTypeLabel(item.connectionTypeCode)}.`;
|
action.textContent = connectionActionLabel(item.connectionTypeCode ?? item.sourceMsgSubType);
|
||||||
|
} else if (activeTab === 'events') {
|
||||||
|
action.textContent = eventActionLabel(item.sourceMsgSubType, item);
|
||||||
} else {
|
} else {
|
||||||
action.textContent = 'Ответил(а) на ваше сообщение.';
|
action.textContent = 'Ответил(а) на ваше сообщение.';
|
||||||
}
|
}
|
||||||
@@ -305,90 +324,98 @@ export function render({ navigate, chrome } = {}) {
|
|||||||
|
|
||||||
const tabs = document.createElement('div');
|
const tabs = document.createElement('div');
|
||||||
tabs.className = 'notification-feed-tabs app-top-tabs';
|
tabs.className = 'notification-feed-tabs app-top-tabs';
|
||||||
tabs.innerHTML = `
|
const tabDefs = [['replies','Ответы'],['connections','Связи'],['events','События']];
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="fg-filter-chip notification-tab-btn ${state.notificationsTab === 'replies' ? 'is-active' : ''}"
|
|
||||||
data-tab="replies"
|
|
||||||
data-selected="${state.notificationsTab === 'replies' ? 'true' : 'false'}"
|
|
||||||
aria-selected="${state.notificationsTab === 'replies' ? 'true' : 'false'}"
|
|
||||||
>Ответы</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="fg-filter-chip notification-tab-btn ${state.notificationsTab === 'events' ? 'is-active' : ''}"
|
|
||||||
data-tab="events"
|
|
||||||
data-selected="${state.notificationsTab === 'events' ? 'true' : 'false'}"
|
|
||||||
aria-selected="${state.notificationsTab === 'events' ? 'true' : 'false'}"
|
|
||||||
>События</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const list = document.createElement('div');
|
const list = document.createElement('div');
|
||||||
list.className = 'stack notifications-list';
|
list.className = 'stack notifications-list';
|
||||||
|
let payloadCache = null;
|
||||||
let requestSeq = 0;
|
let requestSeq = 0;
|
||||||
|
let observer = null;
|
||||||
|
const pendingSeenTimers = { replies: null, connections: null, events: null };
|
||||||
|
const localSeen = { replies: 0, connections: 0, events: 0 };
|
||||||
|
|
||||||
async function load() {
|
function countsFromPayload(payload) {
|
||||||
const seq = ++requestSeq;
|
return {
|
||||||
const activeTab = state.notificationsTab;
|
replies: Number(payload?.repliesUnseenCount || 0),
|
||||||
list.replaceChildren(renderEmpty(activeTab));
|
connections: Number(payload?.connectionsUnseenCount || 0),
|
||||||
|
events: Number(payload?.eventsUnseenCount || 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
function updateToolbarBadge(payload) {
|
||||||
const payload = await authService.getNotifications(50);
|
const c = countsFromPayload(payload);
|
||||||
if (seq !== requestSeq) return;
|
state.notificationUnreadTotal = c.replies + c.connections + c.events;
|
||||||
const baseItems = (activeTab === 'events' ? payload.events : payload.replies)
|
const btn = document.querySelector('[data-toolbar-page="notifications-view"]');
|
||||||
.map(normalizeItem);
|
if (!btn) return;
|
||||||
if (!baseItems.length) {
|
let badge = btn.querySelector('.notification-toolbar-badge');
|
||||||
list.replaceChildren(renderEmpty(activeTab));
|
if (state.notificationUnreadTotal <= 0) { badge?.remove(); return; }
|
||||||
return;
|
if (!badge) { badge = document.createElement('span'); badge.className = 'toolbar-unread-badge notification-toolbar-badge'; btn.append(badge); }
|
||||||
}
|
badge.textContent = state.notificationUnreadTotal > 99 ? '99+' : String(state.notificationUnreadTotal);
|
||||||
|
}
|
||||||
|
|
||||||
const items = await Promise.all(baseItems.map((item) => enrichItem(item, activeTab)));
|
function renderTabs(payload) {
|
||||||
if (seq !== requestSeq) return;
|
const counts = countsFromPayload(payload);
|
||||||
list.replaceChildren(...items.map((item) => renderItem(item, activeTab, navigate)));
|
tabs.replaceChildren(...tabDefs.map(([key,label]) => {
|
||||||
} catch (error) {
|
const b=document.createElement('button'); b.type='button'; b.className=`fg-filter-chip notification-tab-btn ${state.notificationsTab===key?'is-active':''}`; b.dataset.tab=key; b.setAttribute('aria-selected',state.notificationsTab===key?'true':'false');
|
||||||
if (seq !== requestSeq) return;
|
b.textContent = counts[key] > 0 ? `${label} ${counts[key]}` : label;
|
||||||
const card = document.createElement('article');
|
b.addEventListener('click',()=>{ if(state.notificationsTab===key)return; state.notificationsTab=key; renderCurrent(); });
|
||||||
card.className = 'card stack';
|
return b;
|
||||||
const title = document.createElement('strong');
|
}));
|
||||||
title.textContent = 'Не удалось загрузить уведомления';
|
}
|
||||||
const text = document.createElement('p');
|
|
||||||
text.className = 'meta-muted';
|
function categoryData(payload, tab) {
|
||||||
text.textContent = error?.message || 'Ошибка запроса к серверу';
|
if (tab === 'connections') return { items: payload?.connections || [], seenAt: Number(payload?.connectionsSeenAtMs || 0) };
|
||||||
card.append(title, text);
|
if (tab === 'events') return { items: payload?.events || [], seenAt: Number(payload?.eventsSeenAtMs || 0) };
|
||||||
list.replaceChildren(card);
|
return { items: payload?.replies || [], seenAt: Number(payload?.repliesSeenAtMs || 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSeen(category, seenAtMs) {
|
||||||
|
if (seenAtMs <= Number(localSeen[category] || 0)) return;
|
||||||
|
localSeen[category] = seenAtMs;
|
||||||
|
clearTimeout(pendingSeenTimers[category]);
|
||||||
|
pendingSeenTimers[category] = setTimeout(async () => {
|
||||||
|
const target = Number(localSeen[category] || 0);
|
||||||
|
try {
|
||||||
|
await authService.setNotificationSeen({ login: state.session.login, category, seenAtMs: target, storagePwd: state.session.storagePwdInMemory });
|
||||||
|
if (!payloadCache) return;
|
||||||
|
const key = category === 'connections' ? 'connectionsSeenAtMs' : category === 'events' ? 'eventsSeenAtMs' : 'repliesSeenAtMs';
|
||||||
|
const countKey = category === 'connections' ? 'connectionsUnseenCount' : category === 'events' ? 'eventsUnseenCount' : 'repliesUnseenCount';
|
||||||
|
payloadCache[key] = Math.max(Number(payloadCache[key] || 0), target);
|
||||||
|
payloadCache[countKey] = (payloadCache[category] || []).filter(x => Number(x?.createdAtMs || 0) > payloadCache[key]).length;
|
||||||
|
renderTabs(payloadCache); updateToolbarBadge(payloadCache);
|
||||||
|
} catch (e) { console.warn('Не удалось подписать watermark уведомлений', e); }
|
||||||
|
}, 350);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderCurrent() {
|
||||||
|
observer?.disconnect(); observer=null; renderTabs(payloadCache || {});
|
||||||
|
const tab=state.notificationsTab; const {items:raw,seenAt}=categoryData(payloadCache || {},tab); localSeen[tab]=Math.max(localSeen[tab]||0,seenAt);
|
||||||
|
const base=raw.map(normalizeItem); if(!base.length){list.replaceChildren(renderEmpty(tab));return;}
|
||||||
|
const items=await Promise.all(base.map(x=>enrichItem(x,tab)));
|
||||||
|
const unread=items.filter(x=>x.createdAtMs>seenAt); const old=items.filter(x=>x.createdAtMs<=seenAt);
|
||||||
|
const nodes=[];
|
||||||
|
unread.forEach(x=>{const n=renderItem(x,tab,navigate);n.classList.add('notification-card--new');n.dataset.createdAtMs=String(x.createdAtMs);nodes.push(n);});
|
||||||
|
let divider=null;
|
||||||
|
if(unread.length){divider=document.createElement('div');divider.className='notification-new-divider';divider.textContent=`НОВЫЕ · ${unread.length}`;nodes.push(divider);}
|
||||||
|
old.forEach(x=>nodes.push(renderItem(x,tab,navigate))); list.replaceChildren(...nodes);
|
||||||
|
if(unread.length && 'IntersectionObserver' in window){
|
||||||
|
observer=new IntersectionObserver(entries=>{ entries.forEach(e=>{ if(e.isIntersecting && e.intersectionRatio>=0.5){ const ts=Number(e.target.dataset.createdAtMs||0); if(ts>0){e.target.classList.remove('notification-card--new');scheduleSeen(tab,ts);} } }); },{threshold:[0.5]});
|
||||||
|
list.querySelectorAll('.notification-card--new').forEach(n=>observer.observe(n));
|
||||||
|
requestAnimationFrame(()=>divider?.scrollIntoView({block:'end'}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setActiveNotificationTab(nextTab) {
|
async function load() {
|
||||||
const normalizedTab = nextTab === 'events' ? 'events' : 'replies';
|
const seq=++requestSeq; list.replaceChildren(renderEmpty(state.notificationsTab));
|
||||||
state.notificationsTab = normalizedTab;
|
try { payloadCache=await authService.getNotifications(); if(seq!==requestSeq)return; updateToolbarBadge(payloadCache); await renderCurrent(); }
|
||||||
|
catch(error){ if(seq!==requestSeq)return; const card=document.createElement('article');card.className='card stack';card.innerHTML='<strong>Не удалось загрузить уведомления</strong>';const t=document.createElement('p');t.className='meta-muted';t.textContent=error?.message||'Ошибка запроса к серверу';card.append(t);list.replaceChildren(card);}
|
||||||
tabs.querySelectorAll('.notification-tab-btn').forEach((node) => {
|
|
||||||
const selected = node.dataset.tab === normalizedTab;
|
|
||||||
node.classList.toggle('is-active', selected);
|
|
||||||
node.dataset.selected = selected ? 'true' : 'false';
|
|
||||||
node.setAttribute('aria-selected', selected ? 'true' : 'false');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Активная кнопка остаётся визуально нажатой до выбора второй вкладки.
|
if (!['replies','connections','events'].includes(state.notificationsTab)) state.notificationsTab='replies';
|
||||||
// При переключении класс active и aria-selected синхронно переходят на новую кнопку.
|
screen.cleanup = () => {
|
||||||
setActiveNotificationTab(state.notificationsTab);
|
observer?.disconnect();
|
||||||
|
Object.values(pendingSeenTimers).forEach((timer) => clearTimeout(timer));
|
||||||
tabs.querySelectorAll('.notification-tab-btn').forEach((btn) => {
|
};
|
||||||
btn.addEventListener('click', () => {
|
screen.append(tabs,list);
|
||||||
const nextTab = btn.dataset.tab === 'events' ? 'events' : 'replies';
|
|
||||||
if (state.notificationsTab === nextTab) {
|
|
||||||
setActiveNotificationTab(nextTab);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveNotificationTab(nextTab);
|
|
||||||
void load();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
screen.append(tabs, list);
|
|
||||||
void load();
|
void load();
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -249,6 +249,11 @@ function uint8Bytes(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DM_PREFIX_V1 = utf8Bytes('SHiNE_DM');
|
const DM_PREFIX_V1 = utf8Bytes('SHiNE_DM');
|
||||||
|
const NTF_PREFIX_V1 = utf8Bytes('SHiNE_NTF');
|
||||||
|
const NTF_FORMAT_VERSION_MAJOR = 1;
|
||||||
|
const NTF_FORMAT_VERSION_MINOR = 0;
|
||||||
|
const NTF_STATE_SEEN_WATERMARK = 1;
|
||||||
|
const NTF_CATEGORY = { replies: 1, connections: 2, events: 3 };
|
||||||
const DM_TYPE_INCOMING = 1;
|
const DM_TYPE_INCOMING = 1;
|
||||||
const DM_TYPE_OUTGOING_COPY = 2;
|
const DM_TYPE_OUTGOING_COPY = 2;
|
||||||
const DM_TYPE_READ_INCOMING = 3;
|
const DM_TYPE_READ_INCOMING = 3;
|
||||||
@@ -2891,14 +2896,40 @@ export class AuthService {
|
|||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getNotifications(limit = 50) {
|
async getNotifications(countsOnly = false) {
|
||||||
const payload = {};
|
const response = await this.ws.request('GetNotifications', countsOnly ? { countsOnly: true } : {});
|
||||||
if (Number.isFinite(Number(limit))) payload.limit = Math.max(1, Math.min(200, Number(limit)));
|
|
||||||
const response = await this.ws.request('GetNotifications', payload);
|
|
||||||
if (response.status !== 200) throw opError('GetNotifications', response);
|
if (response.status !== 200) throw opError('GetNotifications', response);
|
||||||
return response.payload || {};
|
return response.payload || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setNotificationSeen({ login, category, seenAtMs, storagePwd }) {
|
||||||
|
const cleanLogin = this.normalizeDmLogin(login);
|
||||||
|
const cleanCategory = String(category || '').trim().toLowerCase();
|
||||||
|
const categoryCode = NTF_CATEGORY[cleanCategory];
|
||||||
|
if (!cleanLogin || !categoryCode) throw new Error('Некорректный login/category уведомлений');
|
||||||
|
if (!storagePwd) throw new Error('Не передан storagePwd для подписи состояния уведомлений');
|
||||||
|
const normalizedSeenAtMs = Math.max(0, Math.trunc(Number(seenAtMs || 0)));
|
||||||
|
const timeMs = Date.now();
|
||||||
|
const nonce = Math.floor(Math.random() * 0x100000000);
|
||||||
|
const secrets = await loadEncryptedUserSecrets(cleanLogin, storagePwd);
|
||||||
|
const clientPriv = secrets?.clientKey;
|
||||||
|
if (!clientPriv) throw new Error('Не найден приватный clientKey');
|
||||||
|
const privateKey = await importPkcs8Ed25519(clientPriv);
|
||||||
|
const loginBytes = ensureAsciiBytes(cleanLogin, 'login');
|
||||||
|
const preimage = concatBytes(
|
||||||
|
NTF_PREFIX_V1,
|
||||||
|
uint8Bytes(NTF_FORMAT_VERSION_MAJOR), uint8Bytes(NTF_FORMAT_VERSION_MINOR),
|
||||||
|
uint8Bytes(loginBytes.length), loginBytes,
|
||||||
|
uint64Bytes(timeMs), uint32Bytes(nonce),
|
||||||
|
uint8Bytes(NTF_STATE_SEEN_WATERMARK), uint8Bytes(categoryCode),
|
||||||
|
uint64Bytes(normalizedSeenAtMs),
|
||||||
|
);
|
||||||
|
const signature = await signBytes(privateKey, preimage);
|
||||||
|
const response = await this.ws.request('SetNotificationState', { blobB64: bytesToBase64(concatBytes(preimage, signature)) });
|
||||||
|
if (response.status !== 200) throw opError('SetNotificationState', response);
|
||||||
|
return response.payload || {};
|
||||||
|
}
|
||||||
|
|
||||||
async getUserConnectionsGraph(login) {
|
async getUserConnectionsGraph(login) {
|
||||||
const response = await this.ws.request('GetUserConnectionsGraph', { login });
|
const response = await this.ws.request('GetUserConnectionsGraph', { login });
|
||||||
if (response.status !== 200) throw opError('GetUserConnectionsGraph', response);
|
if (response.status !== 200) throw opError('GetUserConnectionsGraph', response);
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ function createInitialState({ withStoredSession = true } = {}) {
|
|||||||
pendingIncomingReadByBaseKey: {},
|
pendingIncomingReadByBaseKey: {},
|
||||||
outgoingTempSeq: 1,
|
outgoingTempSeq: 1,
|
||||||
notificationsTab: 'replies',
|
notificationsTab: 'replies',
|
||||||
|
notificationUnreadTotal: 0,
|
||||||
pageLabelCollapsed: false,
|
pageLabelCollapsed: false,
|
||||||
session: {
|
session: {
|
||||||
isAuthorized: storedLocalDemo,
|
isAuthorized: storedLocalDemo,
|
||||||
|
|||||||
@@ -62,3 +62,14 @@ a {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notification-card--new { background: rgba(108, 92, 231, .10); border-color: rgba(143, 126, 255, .42); }
|
||||||
|
.notification-card--new::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: currentColor; box-shadow: 0 0 10px currentColor; position: absolute; right: 12px; top: 12px; opacity: .9; }
|
||||||
|
.notification-card { position: relative; }
|
||||||
|
.notification-new-divider { text-align: center; font-size: 11px; letter-spacing: .12em; opacity: .72; padding: 6px 0; }
|
||||||
|
|
||||||
|
.channel-view-topbar { position: relative; }
|
||||||
|
.channel-header-more-menu { position: absolute; right: 10px; top: calc(100% - 4px); z-index: 80; min-width: 190px; padding: 7px; border: 1px solid rgba(255,255,255,.16); border-radius: 14px; background: rgba(18,18,28,.96); backdrop-filter: blur(18px); box-shadow: 0 14px 34px rgba(0,0,0,.35); }
|
||||||
|
.channel-header-more-menu button { width: 100%; border: 0; background: transparent; color: inherit; text-align: left; padding: 10px 12px; border-radius: 10px; }
|
||||||
|
.channel-header-more-menu button:hover { background: rgba(255,255,255,.08); }
|
||||||
|
.channel-header-more-menu button.is-danger { color: #ff8c9b; }
|
||||||
|
|||||||
Reference in New Issue
Block a user