diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index fc9f2b92..eb9bb3f6 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -36,6 +36,7 @@ public final class DatabaseInitializer { public static final int SCHEMA_VERSION_17 = 17; public static final int SCHEMA_VERSION_18 = 18; 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_MIGRATION_V2_RESOURCE = "postgres/migration_v2.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_V18_RESOURCE = "postgres/migration_v18.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() {} @@ -200,6 +202,10 @@ public final class DatabaseInitializer { runSqlScript(conn, POSTGRES_MIGRATION_V19_RESOURCE); currentVersion = SCHEMA_VERSION_19; } + if (currentVersion < SCHEMA_VERSION_20) { + runSqlScript(conn, POSTGRES_MIGRATION_V20_RESOURCE); + currentVersion = SCHEMA_VERSION_20; + } } } diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserNotificationSeenStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserNotificationSeenStateDAO.java new file mode 100644 index 00000000..c195809b --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserNotificationSeenStateDAO.java @@ -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); + } +} diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserNotificationsStateDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserNotificationsStateDAO.java index 21c800d5..0bdd7702 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserNotificationsStateDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/UserNotificationsStateDAO.java @@ -84,6 +84,33 @@ public final class UserNotificationsStateDAO { return out; } + public List 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 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 { UserNotificationEntry e = new UserNotificationEntry(); e.setOwnerLogin(rs.getString("owner_login")); diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v20.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v20.sql new file mode 100644 index 00000000..1e9fe7df --- /dev/null +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/migration_v20.sql @@ -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; diff --git a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql index 2a6cf07f..08330371 100644 --- a/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql +++ b/SHiNE-server/shine-server-db/src/main/resources/postgres/schema_v1.sql @@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS db_schema_version ( ); 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; @@ -765,7 +765,7 @@ CREATE INDEX IF NOT EXISTS idx_signed_message_delivery_session CREATE TABLE IF NOT EXISTS user_notifications_state ( 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, source_login 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 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) -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; COMMIT; + diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index d9986b93..18861805 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -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_ListContacts_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_AddCloseFriend_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_SetNotificationState_Request; 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_CallSignalToSession_Handler; @@ -215,6 +217,7 @@ public final class JsonHandlerRegistry { Map.entry("ListUserProfileChannels", new Net_ListUserProfileChannels_Handler()), Map.entry("AddCloseFriend", new Net_AddCloseFriend_Handler()), Map.entry("GetNotifications", new Net_GetNotifications_Handler()), + Map.entry("SetNotificationState", new Net_SetNotificationState_Handler()), // --- direct messages / push --- Map.entry("UpsertPushToken", new Net_UpsertPushToken_Handler()), @@ -305,6 +308,7 @@ public final class JsonHandlerRegistry { Map.entry("ListUserProfileChannels", Net_ListUserProfileChannels_Request.class), Map.entry("AddCloseFriend", Net_AddCloseFriend_Request.class), Map.entry("GetNotifications", Net_GetNotifications_Request.class), + Map.entry("SetNotificationState", Net_SetNotificationState_Request.class), // --- direct messages / push --- Map.entry("UpsertPushToken", Net_UpsertPushToken_Request.class), diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java index c074dcbd..da343d11 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/blockchain/Net_AddBlock_Handler.java @@ -916,16 +916,23 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { return entry; } - // Connection notifications are intentionally modeled as a generic kind. - // Current UI surfaces FRIEND and CLOSE_FRIEND here; other reserved relation types stay silent. - if (msgType == 3 - && (msgSubType == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF) - || msgSubType == (MsgSubType.CONNECTION_FRIEND & 0xFFFF)) - && block.body instanceof ConnectionBody) { - UserNotificationEntry entry = baseNotificationEntry(storedEntry, ownerLogin, createdAtMs, msgSubType); - entry.setNotificationKind("connection"); - entry.setSourceText(""); - return entry; + if (msgType == 3 && block.body instanceof ConnectionBody) { + boolean personalConnection = msgSubType == (MsgSubType.CONNECTION_CLOSE_FRIEND & 0xFFFF) + || msgSubType == (MsgSubType.CONNECTION_UNCLOSE_FRIEND & 0xFFFF) + || msgSubType == (MsgSubType.CONNECTION_FRIEND & 0xFFFF) + || msgSubType == (MsgSubType.CONNECTION_UNFRIEND & 0xFFFF) + || msgSubType == (MsgSubType.CONNECTION_SHINE_CONFIRMED & 0xFFFF) + || msgSubType == (MsgSubType.CONNECTION_SHINE_UNCONFIRMED & 0xFFFF) + || msgSubType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_CONFIRMED & 0xFFFF) + || msgSubType == (MsgSubType.CONNECTION_OFFICIAL_ACCOUNT_UNCONFIRMED & 0xFFFF); + 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; diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/Net_GetNotifications_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/Net_GetNotifications_Handler.java index 5daaaba8..80ff1274 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/Net_GetNotifications_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/Net_GetNotifications_Handler.java @@ -1,94 +1,27 @@ package server.logic.ws_protocol.JSON.handlers.notifications; -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.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; +import org.slf4j.Logger; 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.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.*; import shine.db.entities.UserNotificationEntry; +import java.sql.Connection; import java.util.*; public final class Net_GetNotifications_Handler implements JsonMessageHandler { - private static final Logger log = LoggerFactory.getLogger(Net_GetNotifications_Handler.class); - - @Override - public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { - Net_GetNotifications_Request req = (Net_GetNotifications_Request) baseRequest; - if (ctx == null || !ctx.isAuthenticatedUser() || ctx.getCurrentUser() == null) { - return NetExceptionResponseFactory.error( - req, - WireCodes.Status.UNVERIFIED, - "NOT_AUTHENTICATED", - "Операция доступна только для авторизованных пользователей" - ); - } - - String login = String.valueOf(ctx.getCurrentUser().getLogin() == null ? "" : ctx.getCurrentUser().getLogin()).trim(); - if (login.isBlank()) { - return NetExceptionResponseFactory.error(req, WireCodes.Status.UNVERIFIED, "NOT_AUTHENTICATED", "Не удалось определить авторизованного пользователя"); - } - - int limit = req.getLimit() == null ? 50 : Math.max(1, Math.min(200, req.getLimit())); - - try (Connection c = DbController.getInstance().getConnection()) { - List replyRows = UserNotificationsStateDAO.getInstance().listByOwnerAndKind(c, login, "reply", limit); - List 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 mapRows(List rows) { - List 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); - } + 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){ + 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","Операция доступна только для авторизованных пользователей"); + String login=String.valueOf(ctx.getCurrentUser().getLogin()).trim(); + try(Connection c=DbController.getInstance().getConnection()){ + UserNotificationSeenStateDAO sd=UserNotificationSeenStateDAO.getInstance(); UserNotificationsStateDAO nd=UserNotificationsStateDAO.getInstance(); long cutoff=System.currentTimeMillis()-HISTORY_MS; + long rs=sd.getSeenAt(c,login,"replies"), cs=sd.getSeenAt(c,login,"connections"), es=sd.getSeenAt(c,login,"events"); + Net_GetNotifications_Response r=new Net_GetNotifications_Response(); r.setOp(req.getOp());r.setRequestId(req.getRequestId());r.setStatus(WireCodes.Status.OK);r.setLogin(login); + if (!Boolean.TRUE.equals(req.getCountsOnly())) { + 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","Внутренняя ошибка сервера");} + } + private List map(List rows){ List 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; } + 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();} } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/Net_SetNotificationState_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/Net_SetNotificationState_Handler.java new file mode 100644 index 00000000..f795b472 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/Net_SetNotificationState_Handler.java @@ -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()); } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/NotificationStatePacket.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/NotificationStatePacket.java new file mode 100644 index 00000000..1dfc4f8d --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/NotificationStatePacket.java @@ -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.length60||bb.remaining()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"; } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/entyties/Net_GetNotifications_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/entyties/Net_GetNotifications_Request.java index 4642f08f..2c44dd2a 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/entyties/Net_GetNotifications_Request.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/entyties/Net_GetNotifications_Request.java @@ -3,8 +3,10 @@ package server.logic.ws_protocol.JSON.handlers.notifications.entyties; import server.logic.ws_protocol.JSON.entyties.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 void setLimit(Integer limit) { this.limit = limit; } + public Boolean getCountsOnly() { return countsOnly; } + public void setCountsOnly(Boolean countsOnly) { this.countsOnly = countsOnly; } } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/entyties/Net_GetNotifications_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/entyties/Net_GetNotifications_Response.java index bb0581a2..0c1f6fe0 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/entyties/Net_GetNotifications_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/entyties/Net_GetNotifications_Response.java @@ -8,12 +8,23 @@ import java.util.List; public class Net_GetNotifications_Response extends Net_Response { private String login; private List replies = new ArrayList<>(); + private List connections = new ArrayList<>(); private List events = new ArrayList<>(); + private long repliesSeenAtMs, connectionsSeenAtMs, eventsSeenAtMs; + private long repliesUnseenCount, connectionsUnseenCount, eventsUnseenCount; public String getLogin() { return login; } public void setLogin(String login) { this.login = login; } public List getReplies() { return replies; } public void setReplies(List replies) { this.replies = replies; } + public List getConnections() { return connections; } + public void setConnections(List 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 getEvents() { return events; } public void setEvents(List events) { this.events = events; } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/entyties/Net_SetNotificationState_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/entyties/Net_SetNotificationState_Request.java new file mode 100644 index 00000000..5b4805a9 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/entyties/Net_SetNotificationState_Request.java @@ -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;} } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/entyties/Net_SetNotificationState_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/entyties/Net_SetNotificationState_Response.java new file mode 100644 index 00000000..4e666412 --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/notifications/entyties/Net_SetNotificationState_Response.java @@ -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;} } diff --git a/docs/API/09_Operations_Index.md b/docs/API/09_Operations_Index.md index 8cf2e7d1..b961bcfc 100644 --- a/docs/API/09_Operations_Index.md +++ b/docs/API/09_Operations_Index.md @@ -60,7 +60,8 @@ | `ListContacts` | `11_Connections_API.md` | контакты текущего пользователя | | `GetUserConnectionsGraph` | `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-токена | | `SendTestWebPush` | `12_Direct_Messages_Push_Calls_API.md` | тестовая push-доставка | | `SendMessagePair` | `12_Direct_Messages_Push_Calls_API.md` | отправка пары входящий/исходящий DM | diff --git a/docs/API/15_Notifications_API.md b/docs/API/15_Notifications_API.md index c0c40c8a..d477c786 100644 --- a/docs/API/15_Notifications_API.md +++ b/docs/API/15_Notifications_API.md @@ -1,81 +1,42 @@ # 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` — ответы на сообщения пользователя в каналах и тредах; -- `events` — события добавления в `close_friend`. +## SetNotificationState -### Запрос +Сохраняет подписанный watermark просмотра. Сервер принимает только монотонное движение `seenAtMs` вперёд. +Запрос: ```json -{ - "op": "GetNotifications", - "requestId": "notif-001", - "payload": { - "login": "alice", - "limit": 50 - } -} +{"op":"SetNotificationState","requestId":"ntf-seen-1","payload":{"blobB64":"..."}} ``` -### Успешный ответ +Бинарный контейнер `SHiNE_NTF` v1.0 (big-endian): -```json -{ - "op": "GetNotifications", - "requestId": "notif-001", - "status": 200, - "ok": true, - "payload": { - "login": "Alice", - "replies": [ - { - "kind": "reply", - "createdAtMs": 1755673200000, - "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..." - } - ] - } -} +```text +'SHiNE_NTF' 9 bytes ASCII +formatVersionMajor u8 = 1 +formatVersionMinor u8 = 0 +loginLen u8 +login ASCII[loginLen] +timeMs u64 +nonce u32 +stateType u8 = 1 (SEEN_WATERMARK) +category u8 (1 replies, 2 connections, 3 events) +seenAtMs u64 +signature Ed25519[64] ``` -### Примечание - -- `replies` заполняется только для `TEXT_REPLY`. -- `events` заполняется только для входящего `CONNECTION_FRIEND` / `close_friend`. -- Другие типы связей в эту ленту не попадают. +Подпись `clientKey` вычисляется над всеми байтами контейнера до `signature`, по тому же принципу, что подписанный контейнер `SHiNE_DM`. Сервер проверяет, что `login` совпадает с авторизованным пользователем, проверяет Ed25519-подпись и сохраняет также исходный signed blob для будущей переносимой синхронизации состояния. diff --git a/shine-UI/js/components/toolbar.js b/shine-UI/js/components/toolbar.js index ca57f43b..0ad7e397 100644 --- a/shine-UI/js/components/toolbar.js +++ b/shine-UI/js/components/toolbar.js @@ -1,5 +1,5 @@ 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 { SHINE_CONNECTIONS_LOGO_SRC } from './shine-logo.js'; @@ -72,6 +72,8 @@ export function renderToolbar(currentPageId, navigate) { const isProfile = item.pageId === 'profile-view'; const isMessages = item.pageId === 'messages-list'; 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' : ''}`; if (isProfile) { btn.innerHTML = ` @@ -97,6 +99,14 @@ export function renderToolbar(currentPageId, navigate) { 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 (item.pageId === 'channels-list') { btn.addEventListener('click', () => navigate('channels-list')); } else { @@ -105,5 +115,19 @@ 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(() => {}); + } + return root; } diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 79dfc5f8..4dd19819 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -2266,9 +2266,12 @@ export function render({ navigate, route, chrome }) { leftAction: { label: '<', onClick: () => navigate('channels-list') }, rightActions: [ { 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 channelMoreButton = header.querySelector('.header-actions .channel-header-more-btn'); if (channelEntrypointButton) { channelEntrypointButton.disabled = true; channelEntrypointButton.hidden = true; @@ -2544,6 +2547,36 @@ export function render({ navigate, route, chrome }) { 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) { const canShowEntrypointButton = !apiData?.isDiary && entrypointPosts.length > 0; channelEntrypointButton.hidden = !canShowEntrypointButton; diff --git a/shine-UI/js/pages/notifications-view.js b/shine-UI/js/pages/notifications-view.js index 31a73941..17bdc58b 100644 --- a/shine-UI/js/pages/notifications-view.js +++ b/shine-UI/js/pages/notifications-view.js @@ -5,18 +5,37 @@ import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { makeProfileRoute, makeShineMessageRoute } from '../services/shine-routes.js'; 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 profileSnapshotPending = new Map(); -function connectionTypeLabel(typeCode) { +function connectionActionLabel(typeCode) { switch (Number(typeCode)) { - case CONNECTION_CLOSE_FRIEND: - return 'близкие друзья'; - default: - return 'новую связь'; + case CONNECTION_CLOSE_FRIEND: return 'Добавил(а) вас в близкие друзья.'; + case CONNECTION_UNCLOSE_FRIEND: return 'Удалил(а) вас из близких друзей.'; + case CONNECTION_FRIEND: 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: 'Уведомления' }; function normalizeItem(item) { @@ -136,12 +155,10 @@ function renderEmpty(activeTab) { const card = document.createElement('article'); card.className = 'card stack notification-empty-state'; const title = document.createElement('strong'); - title.textContent = activeTab === 'events' ? 'Пока нет событий' : 'Пока нет ответов'; + title.textContent = activeTab === 'events' ? 'Пока нет событий' : activeTab === 'connections' ? 'Пока нет связей' : 'Пока нет ответов'; const text = document.createElement('p'); text.className = 'meta-muted'; - text.textContent = activeTab === 'events' - ? 'Когда кто-то добавит новую связь с вами, событие появится здесь.' - : 'Когда кто-то ответит на ваше сообщение в канале или треде, ответ появится здесь.'; + text.textContent = activeTab === 'events' ? 'Подписки и отписки от ваших каналов появятся здесь.' : activeTab === 'connections' ? 'Изменения дружбы и подтверждений появятся здесь.' : 'Когда кто-то ответит на ваше сообщение в канале или треде, ответ появится здесь.'; card.append(title, text); return card; } @@ -239,7 +256,7 @@ function renderEngagement(engagement) { } function notificationRoute(item, activeTab) { - if (activeTab === 'events') { + if (activeTab === 'events' || activeTab === 'connections') { const login = String(item?.sourceLogin || '').trim(); return login ? makeProfileRoute(login) : ''; } @@ -278,8 +295,10 @@ function renderItem(item, activeTab, navigate) { const action = document.createElement('p'); action.className = 'notification-action'; - if (activeTab === 'events') { - action.textContent = `Добавил(а) вас в ${connectionTypeLabel(item.connectionTypeCode)}.`; + if (activeTab === 'connections') { + action.textContent = connectionActionLabel(item.connectionTypeCode ?? item.sourceMsgSubType); + } else if (activeTab === 'events') { + action.textContent = eventActionLabel(item.sourceMsgSubType, item); } else { action.textContent = 'Ответил(а) на ваше сообщение.'; } @@ -305,90 +324,98 @@ export function render({ navigate, chrome } = {}) { const tabs = document.createElement('div'); tabs.className = 'notification-feed-tabs app-top-tabs'; - tabs.innerHTML = ` - - - `; - + const tabDefs = [['replies','Ответы'],['connections','Связи'],['events','События']]; const list = document.createElement('div'); list.className = 'stack notifications-list'; - + let payloadCache = null; 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() { - const seq = ++requestSeq; - const activeTab = state.notificationsTab; - list.replaceChildren(renderEmpty(activeTab)); + function countsFromPayload(payload) { + return { + replies: Number(payload?.repliesUnseenCount || 0), + connections: Number(payload?.connectionsUnseenCount || 0), + events: Number(payload?.eventsUnseenCount || 0), + }; + } - try { - const payload = await authService.getNotifications(50); - if (seq !== requestSeq) return; - const baseItems = (activeTab === 'events' ? payload.events : payload.replies) - .map(normalizeItem); - if (!baseItems.length) { - list.replaceChildren(renderEmpty(activeTab)); - return; - } + function updateToolbarBadge(payload) { + const c = countsFromPayload(payload); + state.notificationUnreadTotal = c.replies + c.connections + c.events; + const btn = document.querySelector('[data-toolbar-page="notifications-view"]'); + if (!btn) return; + let badge = btn.querySelector('.notification-toolbar-badge'); + if (state.notificationUnreadTotal <= 0) { badge?.remove(); 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))); - if (seq !== requestSeq) return; - list.replaceChildren(...items.map((item) => renderItem(item, activeTab, navigate))); - } catch (error) { - if (seq !== requestSeq) return; - const card = document.createElement('article'); - card.className = 'card stack'; - const title = document.createElement('strong'); - title.textContent = 'Не удалось загрузить уведомления'; - const text = document.createElement('p'); - text.className = 'meta-muted'; - text.textContent = error?.message || 'Ошибка запроса к серверу'; - card.append(title, text); - list.replaceChildren(card); + function renderTabs(payload) { + const counts = countsFromPayload(payload); + tabs.replaceChildren(...tabDefs.map(([key,label]) => { + 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'); + b.textContent = counts[key] > 0 ? `${label} ${counts[key]}` : label; + b.addEventListener('click',()=>{ if(state.notificationsTab===key)return; state.notificationsTab=key; renderCurrent(); }); + return b; + })); + } + + function categoryData(payload, tab) { + if (tab === 'connections') return { items: payload?.connections || [], seenAt: Number(payload?.connectionsSeenAtMs || 0) }; + if (tab === 'events') return { items: payload?.events || [], seenAt: Number(payload?.eventsSeenAtMs || 0) }; + 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) { - const normalizedTab = nextTab === 'events' ? 'events' : 'replies'; - state.notificationsTab = normalizedTab; - - 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'); - }); + async function load() { + const seq=++requestSeq; list.replaceChildren(renderEmpty(state.notificationsTab)); + 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='Не удалось загрузить уведомления';const t=document.createElement('p');t.className='meta-muted';t.textContent=error?.message||'Ошибка запроса к серверу';card.append(t);list.replaceChildren(card);} } - // Активная кнопка остаётся визуально нажатой до выбора второй вкладки. - // При переключении класс active и aria-selected синхронно переходят на новую кнопку. - setActiveNotificationTab(state.notificationsTab); - - tabs.querySelectorAll('.notification-tab-btn').forEach((btn) => { - btn.addEventListener('click', () => { - const nextTab = btn.dataset.tab === 'events' ? 'events' : 'replies'; - if (state.notificationsTab === nextTab) { - setActiveNotificationTab(nextTab); - return; - } - - setActiveNotificationTab(nextTab); - void load(); - }); - }); - - screen.append(tabs, list); + if (!['replies','connections','events'].includes(state.notificationsTab)) state.notificationsTab='replies'; + screen.cleanup = () => { + observer?.disconnect(); + Object.values(pendingSeenTimers).forEach((timer) => clearTimeout(timer)); + }; + screen.append(tabs,list); void load(); return screen; } diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index bec842a4..7a5ab7be 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -249,6 +249,11 @@ function uint8Bytes(value) { } 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_OUTGOING_COPY = 2; const DM_TYPE_READ_INCOMING = 3; @@ -2891,14 +2896,40 @@ export class AuthService { return response.payload || {}; } - async getNotifications(limit = 50) { - const payload = {}; - if (Number.isFinite(Number(limit))) payload.limit = Math.max(1, Math.min(200, Number(limit))); - const response = await this.ws.request('GetNotifications', payload); + async getNotifications(countsOnly = false) { + const response = await this.ws.request('GetNotifications', countsOnly ? { countsOnly: true } : {}); if (response.status !== 200) throw opError('GetNotifications', response); 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) { const response = await this.ws.request('GetUserConnectionsGraph', { login }); if (response.status !== 200) throw opError('GetUserConnectionsGraph', response); diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index 60c341bb..661c7c0e 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -244,6 +244,7 @@ function createInitialState({ withStoredSession = true } = {}) { pendingIncomingReadByBaseKey: {}, outgoingTempSeq: 1, notificationsTab: 'replies', + notificationUnreadTotal: 0, pageLabelCollapsed: false, session: { isAuthorized: storedLocalDemo, diff --git a/shine-UI/styles/main.css b/shine-UI/styles/main.css index 2fd8cc4b..cd992092 100644 --- a/shine-UI/styles/main.css +++ b/shine-UI/styles/main.css @@ -62,3 +62,14 @@ a { color: inherit; 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; }