diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java index 4588d1b8..eb780c99 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java @@ -160,11 +160,12 @@ public final class MsgSubType { /* ===================== STATUS_ACTION (msg_type=5) ===================== */ public static final short STATUS_DONE_ONCE = 10; - public static final short STATUS_INTERESTED = 20; - public static final short STATUS_STARTED = 30; - public static final short STATUS_IN_STUDY = 40; - public static final short STATUS_COMPLETED = 50; - public static final short STATUS_ABANDONED = 60; - public static final short STATUS_LEARNED = 70; - public static final short STATUS_CONFIRMED = 80; + public static final short STATUS_LEARNED = 20; + public static final short STATUS_SERVICE_PASSED = 30; + public static final short STATUS_CONFIRMED = 100; + public static final short STATUS_INTERESTED = 110; + public static final short STATUS_STARTED = 120; + public static final short STATUS_IN_STUDY = 130; + public static final short STATUS_ABANDONED = 140; + public static final short STATUS_COMPLETED = 150; } diff --git a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/StatusActionBody.java b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/StatusActionBody.java index d9979386..3a3bd0b9 100644 --- a/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/StatusActionBody.java +++ b/SHiNE-server/shine-server-blockchain/src/main/java/blockchain/body/StatusActionBody.java @@ -127,13 +127,14 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget { private static boolean isSupportedSubType(short subType) { int st = subType & 0xFFFF; return st == (MsgSubType.STATUS_DONE_ONCE & 0xFFFF) + || st == (MsgSubType.STATUS_LEARNED & 0xFFFF) + || st == (MsgSubType.STATUS_SERVICE_PASSED & 0xFFFF) + || st == (MsgSubType.STATUS_CONFIRMED & 0xFFFF) || st == (MsgSubType.STATUS_INTERESTED & 0xFFFF) || st == (MsgSubType.STATUS_STARTED & 0xFFFF) || st == (MsgSubType.STATUS_IN_STUDY & 0xFFFF) - || st == (MsgSubType.STATUS_COMPLETED & 0xFFFF) || st == (MsgSubType.STATUS_ABANDONED & 0xFFFF) - || st == (MsgSubType.STATUS_LEARNED & 0xFFFF) - || st == (MsgSubType.STATUS_CONFIRMED & 0xFFFF); + || st == (MsgSubType.STATUS_COMPLETED & 0xFFFF); } private static String readStrictUtf8Len16AllowEmpty(ByteBuffer bb, String fieldName) { diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java index 11d3b91b..3cd7456c 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/MsgSubType.java @@ -138,13 +138,14 @@ public final class MsgSubType { /* ===================== STATUS_ACTION (msg_type=5) ===================== */ public static final short STATUS_DONE_ONCE = 10; - public static final short STATUS_INTERESTED = 20; - public static final short STATUS_STARTED = 30; - public static final short STATUS_IN_STUDY = 40; - public static final short STATUS_COMPLETED = 50; - public static final short STATUS_ABANDONED = 60; - public static final short STATUS_LEARNED = 70; - public static final short STATUS_CONFIRMED = 80; + public static final short STATUS_LEARNED = 20; + public static final short STATUS_SERVICE_PASSED = 30; + public static final short STATUS_CONFIRMED = 100; + public static final short STATUS_INTERESTED = 110; + public static final short STATUS_STARTED = 120; + public static final short STATUS_IN_STUDY = 130; + public static final short STATUS_ABANDONED = 140; + public static final short STATUS_COMPLETED = 150; /* ===================== РЕЗЕРВ НА БУДУЩЕЕ ===================== */ // Если позже захочешь BLOCK/UNBLOCK — лучше добавить новые значения, 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 787efd20..65719bf4 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 @@ -67,6 +67,7 @@ import server.logic.ws_protocol.JSON.handlers.connections.entyties.Net_GetFriend import server.logic.ws_protocol.JSON.handlers.channels.ChannelNamesStateBootstrapper; import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_GetMessageThread_Handler; +import server.logic.ws_protocol.JSON.handlers.channels.Net_GetPersonalDiary_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_GetGroupDialog_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelsCounters_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_ListGroupChats200_Handler; @@ -75,6 +76,7 @@ import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelsC import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDialog_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetMessageThread_Request; +import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalDiary_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListGroupChats200_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request; import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler; @@ -184,6 +186,7 @@ public final class JsonHandlerRegistry { Map.entry("GetFriendsLists", new Net_GetFriendsLists_Handler()), Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()), Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()), + Map.entry("GetPersonalDiary", new Net_GetPersonalDiary_Handler()), Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()), Map.entry("GetGroupDialog", new Net_GetGroupDialog_Handler()), Map.entry("ListGroupChats200", new Net_ListGroupChats200_Handler()), @@ -265,6 +268,7 @@ public final class JsonHandlerRegistry { Map.entry("GetFriendsLists", Net_GetFriendsLists_Request.class), Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class), Map.entry("GetChannelMessages", Net_GetChannelMessages_Request.class), + Map.entry("GetPersonalDiary", Net_GetPersonalDiary_Request.class), Map.entry("GetMessageThread", Net_GetMessageThread_Request.class), Map.entry("GetGroupDialog", Net_GetGroupDialog_Request.class), Map.entry("ListGroupChats200", Net_ListGroupChats200_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 c487fd4f..6843ef11 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 @@ -170,6 +170,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { case "repost_disabled" -> "Репосты временно отключены до будущей реализации"; case "entrypoint_edit_forbidden" -> "TEXT_ENTRYPOINT нельзя редактировать через TEXT_EDIT_POST"; case "status_confirmed_target_must_be_status_action" -> "STATUS_CONFIRMED должен ссылаться на STATUS_ACTION"; + case "status_action_target_not_allowed" -> "Этот STATUS_ACTION нельзя ставить на выбранный тип материала"; case "internal_error" -> "Внутренняя ошибка сервера при записи блока"; case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется"; default -> "Ошибка: " + code; @@ -405,15 +406,14 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { } } - if ((block.type & 0xFFFF) == 5 - && (block.subType & 0xFFFF) == (MsgSubType.STATUS_CONFIRMED & 0xFFFF)) { + if ((block.type & 0xFFFF) == 5) { try { - String statusError = validateStatusConfirmedTarget(block); + String statusError = validateStatusActionTarget(block); if (statusError != null) { return new AddBlockResult(WireCodes.Status.BAD_REQUEST, statusError, serverLastNum, serverLastHashHex); } } catch (Exception e) { - log.error("AddBlock: status_confirmed_target_check_failed (login={}, blockchainName={}, blockNumber={})", + log.error("AddBlock: status_action_target_check_failed (login={}, blockchainName={}, blockNumber={})", login, blockchainName, block.blockNumber, e); return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex); } @@ -653,7 +653,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { return null; } - private String validateStatusConfirmedTarget(BchBlockEntry block) throws Exception { + private String validateStatusActionTarget(BchBlockEntry block) throws Exception { if (!(block.body instanceof StatusActionBody statusBody)) return "bad_block_body"; String targetBch = statusBody.toBchName(); Integer targetBlockNumber = statusBody.toBlockGlobalNumber(); @@ -666,10 +666,31 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler { if (target == null || target.getBlockHash() == null || !Arrays.equals(target.getBlockHash(), targetHash)) { return null; } - if (target.getMsgType() != 5) { - return "status_confirmed_target_must_be_status_action"; + int statusSubType = block.subType & 0xFFFF; + if (statusSubType == (MsgSubType.STATUS_CONFIRMED & 0xFFFF)) { + if (target.getMsgType() != 5) { + return "status_confirmed_target_must_be_status_action"; + } + return null; } - return null; + if (target.getMsgType() != 1) return "status_action_target_not_allowed"; + + int targetSubType = target.getMsgSubType(); + if (statusSubType == (MsgSubType.STATUS_DONE_ONCE & 0xFFFF) + || statusSubType == (MsgSubType.STATUS_LEARNED & 0xFFFF)) { + return targetSubType == (MsgSubType.TEXT_EXERCISE & 0xFFFF) ? null : "status_action_target_not_allowed"; + } + if (statusSubType == (MsgSubType.STATUS_SERVICE_PASSED & 0xFFFF)) { + return targetSubType == (MsgSubType.TEXT_SERVICE & 0xFFFF) ? null : "status_action_target_not_allowed"; + } + if (statusSubType == (MsgSubType.STATUS_INTERESTED & 0xFFFF) + || statusSubType == (MsgSubType.STATUS_STARTED & 0xFFFF) + || statusSubType == (MsgSubType.STATUS_IN_STUDY & 0xFFFF) + || statusSubType == (MsgSubType.STATUS_ABANDONED & 0xFFFF) + || statusSubType == (MsgSubType.STATUS_COMPLETED & 0xFFFF)) { + return targetSubType == (MsgSubType.TEXT_COURSE & 0xFFFF) ? null : "status_action_target_not_allowed"; + } + return "status_action_target_not_allowed"; } private ExistingChannelState loadExistingChannelState(String ownerBch, int rootBlockNumber) throws Exception { diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetPersonalDiary_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetPersonalDiary_Handler.java new file mode 100644 index 00000000..0f9fc5cd --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/Net_GetPersonalDiary_Handler.java @@ -0,0 +1,179 @@ +package server.logic.ws_protocol.JSON.handlers.channels; + +import blockchain.BchBlockEntry; +import blockchain.body.StatusActionBody; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetChannelMessages_Response; +import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetPersonalDiary_Request; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.DbController; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.List; + +public class Net_GetPersonalDiary_Handler implements JsonMessageHandler { + private static final Logger log = LoggerFactory.getLogger(Net_GetPersonalDiary_Handler.class); + private static final String DIARY_CHANNEL_NAME = "diary"; + private static final String DIARY_DISPLAY_NAME = "Личный дневник"; + private static final String DIARY_DESCRIPTION = "История ваших действий по упражнениям, услугам и курсам"; + + @Override + public Net_Response handle(Net_Request baseRequest, ConnectionContext ctx) { + Net_GetPersonalDiary_Request req = (Net_GetPersonalDiary_Request) baseRequest; + String requestedLogin = String.valueOf(req.getLogin() == null ? "" : req.getLogin()).trim(); + if (requestedLogin.isBlank() && (ctx == null || ctx.getLogin() == null || ctx.getLogin().isBlank())) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "bad_fields", "Некорректные поля: login"); + } + + int limit = req.getLimit() == null ? 200 : req.getLimit(); + if (limit <= 0 || limit > 1000) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "limit_too_large", "Некорректный limit"); + } + boolean asc = req.getSort() == null || !"desc".equalsIgnoreCase(req.getSort()); + + try (Connection c = DbController.getInstance().getConnection()) { + String viewerLogin = ctx != null ? String.valueOf(ctx.getLogin() == null ? "" : ctx.getLogin()).trim() : ""; + String canonicalLogin = !viewerLogin.isBlank() + ? viewerLogin + : ChannelsReadSupport.canonicalLogin(c, requestedLogin); + if (canonicalLogin == null || canonicalLogin.isBlank()) { + return NetExceptionResponseFactory.error(req, 404, "user_not_found", "Пользователь не найден"); + } + if (!viewerLogin.isBlank() && !viewerLogin.equalsIgnoreCase(canonicalLogin)) { + return NetExceptionResponseFactory.error(req, 403, "forbidden", "Личный дневник доступен только владельцу"); + } + + String ownerBch = loadPrimaryBlockchainName(c, canonicalLogin); + if (ownerBch == null || ownerBch.isBlank()) { + return NetExceptionResponseFactory.error(req, 404, "blockchain_not_found", "Не найден blockchain пользователя"); + } + + Net_GetChannelMessages_Response resp = new Net_GetChannelMessages_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + + Net_GetChannelMessages_Response.Channel channel = new Net_GetChannelMessages_Response.Channel(); + channel.setOwnerLogin(canonicalLogin); + channel.setOwnerBlockchainName(ownerBch); + channel.setChannelName(DIARY_CHANNEL_NAME); + channel.setDisplayName(DIARY_DISPLAY_NAME); + channel.setChannelDescription(DIARY_DESCRIPTION); + channel.setChannelTypeCode(900); + channel.setChannelTypeVersion(1); + Net_GetChannelMessages_Response.BlockRef rootRef = new Net_GetChannelMessages_Response.BlockRef(); + rootRef.setBlockNumber(0); + rootRef.setBlockHash(ChannelsReadSupport.toHex(new byte[32])); + channel.setChannelRoot(rootRef); + resp.setChannel(channel); + resp.setMetaEvents(new ArrayList<>()); + + List items = loadDiaryItems(c, canonicalLogin, limit, asc); + resp.setMessages(items); + return resp; + } catch (Exception e) { + log.error("GetPersonalDiary failed", e); + return NetExceptionResponseFactory.error(req, WireCodes.Status.INTERNAL_ERROR, "internal_error", "Внутренняя ошибка сервера"); + } + } + + private String loadPrimaryBlockchainName(Connection c, String canonicalLogin) throws Exception { + try (PreparedStatement ps = c.prepareStatement(""" + SELECT blockchain_name + FROM blockchain_state + WHERE login = ? + ORDER BY blockchain_name + LIMIT 1 + """)) { + ps.setString(1, canonicalLogin); + try (ResultSet rs = ps.executeQuery()) { + return rs.next() ? rs.getString("blockchain_name") : null; + } + } + } + + private List loadDiaryItems(Connection c, String canonicalLogin, int limit, boolean asc) throws Exception { + String order = asc ? "ASC" : "DESC"; + List out = new ArrayList<>(); + try (PreparedStatement ps = c.prepareStatement(""" + SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type + FROM blocks + WHERE login = ? AND msg_type = ? + ORDER BY block_number + """ + order + " LIMIT ?")) { + ps.setString(1, canonicalLogin); + ps.setInt(2, ChannelsReadSupport.MSG_TYPE_STATUS_ACTION); + ps.setInt(3, limit); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + byte[] blockBytes = rs.getBytes("block_bytes"); + BchBlockEntry entry = new BchBlockEntry(blockBytes); + if (!(entry.body instanceof StatusActionBody statusBody)) continue; + + Net_GetChannelMessages_Response.MessageItem item = new Net_GetChannelMessages_Response.MessageItem(); + Net_GetChannelMessages_Response.BlockRef ref = new Net_GetChannelMessages_Response.BlockRef(); + ref.setBlockNumber(rs.getInt("block_number")); + ref.setBlockHash(ChannelsReadSupport.toHex(rs.getBytes("block_hash"))); + item.setMessageRef(ref); + item.setMsgSubType(rs.getInt("msg_sub_type")); + item.setAuthorLogin(rs.getString("login")); + item.setAuthorBlockchainName(rs.getString("bch_name")); + item.setCreatedAtMs(entry.timestamp * 1000L); + item.setText(statusBody.message == null ? "" : statusBody.message); + item.setLikesCount(0); + item.setLikedByMe(false); + item.setRepliesCount(0); + item.setRatingsCount(0); + item.setVersionsTotal(1); + item.setVersions(new ArrayList<>()); + item.setTargetBlockchainName(statusBody.toBchName()); + item.setTargetBlockNumber(statusBody.toBlockGlobalNumber()); + item.setTargetBlockHash(ChannelsReadSupport.toHex(statusBody.toBlockHashBytes())); + + fillTargetDetails(c, item, statusBody.toBchName(), statusBody.toBlockGlobalNumber(), statusBody.toBlockHashBytes()); + out.add(item); + } + } + } + return out; + } + + private void fillTargetDetails(Connection c, + Net_GetChannelMessages_Response.MessageItem item, + String targetBch, + Integer targetBlockNumber, + byte[] targetHash) throws Exception { + if (targetBch == null || targetBch.isBlank() || targetBlockNumber == null || targetHash == null || targetHash.length != 32) { + return; + } + try (PreparedStatement ps = c.prepareStatement(""" + SELECT login, bch_name, block_number, block_hash, block_bytes, msg_sub_type + FROM blocks + WHERE bch_name = ? AND block_number = ? + LIMIT 1 + """)) { + ps.setString(1, targetBch); + ps.setInt(2, targetBlockNumber); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return; + byte[] actualHash = rs.getBytes("block_hash"); + if (actualHash == null || !java.util.Arrays.equals(actualHash, targetHash)) return; + item.setTargetMsgSubType(rs.getInt("msg_sub_type")); + item.setTargetAuthorLogin(rs.getString("login")); + item.setTargetAuthorBlockchainName(rs.getString("bch_name")); + ChannelsReadSupport.TextInfo textInfo = ChannelsReadSupport.parseTextAndTime(rs.getBytes("block_bytes")); + item.setTargetText(textInfo.text); + item.setTargetCreatedAtMs(textInfo.createdAtMs); + } + } + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java index 35116490..7d5f0a92 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetChannelMessages_Response.java @@ -127,6 +127,11 @@ public class Net_GetChannelMessages_Response extends Net_Response { private String targetBlockchainName; private Integer targetBlockNumber; private String targetBlockHash; + private Integer targetMsgSubType; + private String targetText; + private String targetAuthorLogin; + private String targetAuthorBlockchainName; + private Long targetCreatedAtMs; private long createdAtMs; private String text; private int likesCount; @@ -159,6 +164,21 @@ public class Net_GetChannelMessages_Response extends Net_Response { public String getTargetBlockHash() { return targetBlockHash; } public void setTargetBlockHash(String targetBlockHash) { this.targetBlockHash = targetBlockHash; } + public Integer getTargetMsgSubType() { return targetMsgSubType; } + public void setTargetMsgSubType(Integer targetMsgSubType) { this.targetMsgSubType = targetMsgSubType; } + + public String getTargetText() { return targetText; } + public void setTargetText(String targetText) { this.targetText = targetText; } + + public String getTargetAuthorLogin() { return targetAuthorLogin; } + public void setTargetAuthorLogin(String targetAuthorLogin) { this.targetAuthorLogin = targetAuthorLogin; } + + public String getTargetAuthorBlockchainName() { return targetAuthorBlockchainName; } + public void setTargetAuthorBlockchainName(String targetAuthorBlockchainName) { this.targetAuthorBlockchainName = targetAuthorBlockchainName; } + + public Long getTargetCreatedAtMs() { return targetCreatedAtMs; } + public void setTargetCreatedAtMs(Long targetCreatedAtMs) { this.targetCreatedAtMs = targetCreatedAtMs; } + public long getCreatedAtMs() { return createdAtMs; } public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; } diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetPersonalDiary_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetPersonalDiary_Request.java new file mode 100644 index 00000000..67d63e4d --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/channels/entyties/Net_GetPersonalDiary_Request.java @@ -0,0 +1,18 @@ +package server.logic.ws_protocol.JSON.handlers.channels.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_GetPersonalDiary_Request extends Net_Request { + private String login; + private Integer limit; + private String sort; + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + + public Integer getLimit() { return limit; } + public void setLimit(Integer limit) { this.limit = limit; } + + public String getSort() { return sort; } + public void setSort(String sort) { this.sort = sort; } +} diff --git a/VERSION.properties b/VERSION.properties index 3ec10981..06de6164 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.5.10 -server.version=1.4.8 +client.version=1.5.11 +server.version=1.4.9 diff --git a/docs/API/04_Add_Block_to_Blockchain_API.md b/docs/API/04_Add_Block_to_Blockchain_API.md index 64584640..88c6857e 100644 --- a/docs/API/04_Add_Block_to_Blockchain_API.md +++ b/docs/API/04_Add_Block_to_Blockchain_API.md @@ -90,6 +90,7 @@ - `repost_disabled` — репосты временно отключены до будущей реализации - `entrypoint_edit_forbidden` — `TEXT_ENTRYPOINT` нельзя редактировать через `TEXT_EDIT_POST` - `status_confirmed_target_must_be_status_action` — `STATUS_CONFIRMED` должен ссылаться на статусный блок +- `status_action_target_not_allowed` — выбранный `STATUS_ACTION` нельзя ставить на этот тип материала - `bad_channel_meta_line`, `channel_not_found`, `bad_channel_meta_*`, `channel_meta_*_too_long` — ошибки `TEXT_CHANNEL_META` - `internal_error` @@ -144,13 +145,14 @@ 6. **STATUS_ACTION (type=5)** - `STATUS_DONE_ONCE (10)` - - `STATUS_INTERESTED (20)` - - `STATUS_STARTED (30)` - - `STATUS_IN_STUDY (40)` - - `STATUS_COMPLETED (50)` - - `STATUS_ABANDONED (60)` - - `STATUS_LEARNED (70)` - - `STATUS_CONFIRMED (80)` + - `STATUS_LEARNED (20)` + - `STATUS_SERVICE_PASSED (30)` + - `STATUS_CONFIRMED (100)` + - `STATUS_INTERESTED (110)` + - `STATUS_STARTED (120)` + - `STATUS_IN_STUDY (130)` + - `STATUS_ABANDONED (140)` + - `STATUS_COMPLETED (150)` ## 6. Практические payload-форматы для каналов и вложений diff --git a/docs/API/06_Channels_Read_API.md b/docs/API/06_Channels_Read_API.md index 5526458d..b2dc3d93 100644 --- a/docs/API/06_Channels_Read_API.md +++ b/docs/API/06_Channels_Read_API.md @@ -14,11 +14,13 @@ 3. `GetMessageThread` — отдает дерево обсуждения вокруг конкретного сообщения: предки, фокус-сообщение, потомки. -4. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя. +4. `GetPersonalDiary` — отдает виртуальную ленту `Личный дневник`, собранную из `STATUS_ACTION` текущего пользователя. -5. `ListGroupChats200` — отдает список групповых чатов типа `200`. +5. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя. -6. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`. +6. `ListGroupChats200` — отдает список групповых чатов типа `200`. + +7. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`. > На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки. @@ -259,6 +261,29 @@ --- +## 4) GetPersonalDiary + +Возвращает виртуальный канал `Личный дневник` для самого пользователя. + +- Вызов доступен только владельцу дневника. +- Сообщения в ответе строятся из блоков `STATUS_ACTION`. +- Поля `targetMsgSubType`, `targetText`, `targetAuthorLogin`, `targetAuthorBlockchainName`, `targetCreatedAtMs` описывают исходный материал, к которому относится действие. + +### Request +```json +{ + "op": "GetPersonalDiary", + "requestId": "req-4", + "payload": { + "login": "Alice", + "limit": 200, + "sort": "asc" + } +} +``` + +--- + ## 4) GetChannelsCounters ### Request diff --git a/docs/API/09_Operations_Index.md b/docs/API/09_Operations_Index.md index 69a01682..95826e81 100644 --- a/docs/API/09_Operations_Index.md +++ b/docs/API/09_Operations_Index.md @@ -44,6 +44,7 @@ | `ListSubscriptionsFeed` | `06_Channels_Read_API.md` | лента каналов/подписок | | `GetChannelMessages` | `06_Channels_Read_API.md` | сообщения канала | | `GetMessageThread` | `06_Channels_Read_API.md` | тред сообщения | +| `GetPersonalDiary` | `06_Channels_Read_API.md` | виртуальный канал `Личный дневник` из STATUS_ACTION | | `GetChannelsCounters` | `06_Channels_Read_API.md` | счетчики разделов каналов | | `ListGroupChats200` | `06_Channels_Read_API.md` | список групповых чатов типа `200` | | `GetGroupDialog` | `06_Channels_Read_API.md` | сообщения группового чата типа `200` | diff --git a/docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md b/docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md index af0b71d2..d4336ffa 100644 --- a/docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md +++ b/docs/Blockchain/00_Blockchain_Formats_and_Block_Types.md @@ -20,7 +20,7 @@ - `type=2` — REACTION: LIKE/UNLIKE. - `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции. - `type=4` — USER_PARAM: key/value-параметры пользователя. -- `type=5` — STATUS_ACTION: DONE_ONCE/INTERESTED/STARTED/IN_STUDY/COMPLETED/ABANDONED/LEARNED/CONFIRMED. +- `type=5` — STATUS_ACTION: DONE_ONCE/LEARNED/SERVICE_PASSED/CONFIRMED/INTERESTED/STARTED/IN_STUDY/ABANDONED/COMPLETED. ## Примечание diff --git a/docs/Blockchain/15_STATUS_ACTION_Blocks.md b/docs/Blockchain/15_STATUS_ACTION_Blocks.md index f683be56..1a330569 100644 --- a/docs/Blockchain/15_STATUS_ACTION_Blocks.md +++ b/docs/Blockchain/15_STATUS_ACTION_Blocks.md @@ -5,29 +5,32 @@ ## Подтипы 1. `subType=10` — `STATUS_DONE_ONCE` - - выполнил / прошёл один раз. + - выполнил упражнение один раз. -2. `subType=20` — `STATUS_INTERESTED` - - заинтересовался материалом. +2. `subType=20` — `STATUS_LEARNED` + - изучил упражнение / комплекс. -3. `subType=30` — `STATUS_STARTED` - - начал. +3. `subType=30` — `STATUS_SERVICE_PASSED` + - прошёл услугу / процедуру. -4. `subType=40` — `STATUS_IN_STUDY` - - находится в процессе полноценного изучения. - -5. `subType=50` — `STATUS_COMPLETED` - - завершил. - -6. `subType=60` — `STATUS_ABANDONED` - - бросил. - -7. `subType=70` — `STATUS_LEARNED` - - выучил упражнение / комплекс. - -8. `subType=80` — `STATUS_CONFIRMED` +4. `subType=100` — `STATUS_CONFIRMED` - подтвердил чужой status-блок. +5. `subType=110` — `STATUS_INTERESTED` + - заинтересовался курсом. + +6. `subType=120` — `STATUS_STARTED` + - начал курс. + +7. `subType=130` — `STATUS_IN_STUDY` + - находится в процессе полноценного изучения курса. + +8. `subType=140` — `STATUS_ABANDONED` + - бросил курс. + +9. `subType=150` — `STATUS_COMPLETED` + - завершил курс. + ## Формат body Все `STATUS_ACTION` используют один и тот же бинарный body-формат: @@ -55,6 +58,10 @@ - Текст может быть пустым: основной смысл задаётся самим `subType`. - Базовые статусы пользователь ставит сам за себя. - `STATUS_CONFIRMED` ставится другим человеком на конкретный status-блок. +- Допустимые target-типы: + - `STATUS_DONE_ONCE` и `STATUS_LEARNED` только для `TEXT_EXERCISE`; + - `STATUS_SERVICE_PASSED` только для `TEXT_SERVICE`; + - `STATUS_INTERESTED`, `STATUS_STARTED`, `STATUS_IN_STUDY`, `STATUS_ABANDONED`, `STATUS_COMPLETED` только для `TEXT_COURSE`. ## Что не поддерживается diff --git a/docs/Blockchain/CHANGELOG.md b/docs/Blockchain/CHANGELOG.md index 77694b8c..e88c277e 100644 --- a/docs/Blockchain/CHANGELOG.md +++ b/docs/Blockchain/CHANGELOG.md @@ -1,5 +1,19 @@ # История изменений документации блокчейна +## 2026-08-09 23:30:06 +0400 +- Базовый коммит-ориентир: `ee185cf`. +- Нумерация `STATUS_ACTION` уточнена под дневник действий: + - `10` — `STATUS_DONE_ONCE`; + - `20` — `STATUS_LEARNED`; + - `30` — `STATUS_SERVICE_PASSED`; + - `100` — `STATUS_CONFIRMED`; + - `110/120/130/140/150` — курсные статусы `INTERESTED/STARTED/IN_STUDY/ABANDONED/COMPLETED`. +- Зафиксированы допустимые связи `STATUS_ACTION -> target`: + - упражнение: `DONE_ONCE`, `LEARNED`; + - услуга: `SERVICE_PASSED`; + - курс: `INTERESTED`, `STARTED`, `IN_STUDY`, `ABANDONED`, `COMPLETED`. +- Добавлен серверный read API `GetPersonalDiary` для виртуальной ленты личного дневника из `STATUS_ACTION`. + ## 2026-08-09 19:40:00 +0400 - Базовый коммит-ориентир: `3552e05`. - Уточнено серверное чтение каналов и тредов для `TEXT_RATING`: diff --git a/shine-UI/js/pages/channel-thread-view.js b/shine-UI/js/pages/channel-thread-view.js index 1a4c63fd..918e99bb 100644 --- a/shine-UI/js/pages/channel-thread-view.js +++ b/shine-UI/js/pages/channel-thread-view.js @@ -235,11 +235,11 @@ function resolveChannelHeadingFromNode(node) { function getChannelMessageTypeMeta(msgSubType) { switch (Number(msgSubType || 0)) { case MSG_SUBTYPE_TEXT_EXERCISE: - return { label: 'Упражнение' }; + return { label: 'Упражнение', actionable: true }; case MSG_SUBTYPE_TEXT_SERVICE: - return { label: 'Процедура' }; + return { label: 'Услуга', actionable: true }; case MSG_SUBTYPE_TEXT_COURSE: - return { label: 'Курс' }; + return { label: 'Курс', actionable: true }; case MSG_SUBTYPE_TEXT_ENTRYPOINT: return { label: 'Оглавление' }; default: @@ -770,6 +770,8 @@ function renderNodeCard(node, heading, handlers, localNumber) { const authorTile = document.createElement('button'); authorTile.type = 'button'; authorTile.className = 'channel-message-author-tile'; + const headRow = document.createElement('div'); + headRow.className = 'channel-message-head-row'; const avatar = createThreadAvatar(author); @@ -787,13 +789,6 @@ function renderNodeCard(node, heading, handlers, localNumber) { numberEl.textContent = `· #${localNumber}`; titleMain.append(loginEl, numberEl); title.append(titleMain); - const typeMeta = getChannelMessageTypeMeta(node?.msgSubType); - if (typeMeta) { - const typeChip = document.createElement('span'); - typeChip.className = 'channel-message-type-chip'; - typeChip.textContent = typeMeta.label; - title.append(typeChip); - } if (versionsTotal > 1) { const editedMarker = document.createElement('button'); editedMarker.type = 'button'; @@ -815,6 +810,25 @@ function renderNodeCard(node, heading, handlers, localNumber) { timestamp.textContent = node?.createdAtMs ? new Date(node.createdAtMs).toLocaleString() : '—'; authorBlock.append(title, timestamp); authorTile.append(avatar, authorBlock); + headRow.append(authorTile); + const typeMeta = getChannelMessageTypeMeta(node?.msgSubType); + if (typeMeta) { + const typeButton = document.createElement('button'); + typeButton.type = 'button'; + typeButton.className = 'channel-message-type-button'; + typeButton.textContent = typeMeta.label; + if (typeMeta.actionable && typeof handlers?.onStatusAction === 'function') { + typeButton.addEventListener('click', (event) => { + event.stopPropagation(); + animatePress(event.currentTarget); + handlers.onStatusAction(node); + }); + } else { + typeButton.classList.add('is-static'); + typeButton.disabled = true; + } + headRow.append(typeButton); + } const isDeletedMessage = String(text || '').trim().toLowerCase() === 'удалено'; @@ -835,7 +849,7 @@ function renderNodeCard(node, heading, handlers, localNumber) { card.append(deleted); return card; } else { - card.append(authorTile); + card.append(headRow); if (parsedText.attachments.length > 0) { card.append(createAttachmentCarouselElement(parsedText.attachments, { gateway: state.entrySettings.arweaveServer, diff --git a/shine-UI/js/pages/channel-view.js b/shine-UI/js/pages/channel-view.js index 30d301f6..1e23888e 100644 --- a/shine-UI/js/pages/channel-view.js +++ b/shine-UI/js/pages/channel-view.js @@ -43,6 +43,17 @@ const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100; const MSG_SUBTYPE_TEXT_EXERCISE = 110; const MSG_SUBTYPE_TEXT_SERVICE = 120; const MSG_SUBTYPE_TEXT_COURSE = 130; +const MSG_SUBTYPE_STATUS_DONE_ONCE = 10; +const MSG_SUBTYPE_STATUS_LEARNED = 20; +const MSG_SUBTYPE_STATUS_SERVICE_PASSED = 30; +const MSG_SUBTYPE_STATUS_CONFIRMED = 100; +const MSG_SUBTYPE_STATUS_INTERESTED = 110; +const MSG_SUBTYPE_STATUS_STARTED = 120; +const MSG_SUBTYPE_STATUS_IN_STUDY = 130; +const MSG_SUBTYPE_STATUS_ABANDONED = 140; +const MSG_SUBTYPE_STATUS_COMPLETED = 150; +const DIARY_CHANNEL_NAME = 'diary'; +const DIARY_CHANNEL_DISPLAY_NAME = 'Личный дневник'; const pendingReactionActions = new Set(); const pendingScrollByRoute = new Map(); @@ -296,11 +307,11 @@ function resolveMessageTimestampMs(message) { function getChannelMessageTypeMeta(msgSubType) { switch (Number(msgSubType || 0)) { case MSG_SUBTYPE_TEXT_EXERCISE: - return { label: 'Упражнение' }; + return { label: 'Упражнение', actionable: true }; case MSG_SUBTYPE_TEXT_SERVICE: - return { label: 'Процедура' }; + return { label: 'Услуга', actionable: true }; case MSG_SUBTYPE_TEXT_COURSE: - return { label: 'Курс' }; + return { label: 'Курс', actionable: true }; case MSG_SUBTYPE_TEXT_ENTRYPOINT: return { label: 'Оглавление' }; default: @@ -308,6 +319,63 @@ function getChannelMessageTypeMeta(msgSubType) { } } +function isDiarySelector(selector) { + return String(selector?.channelName || '').trim().toLowerCase() === DIARY_CHANNEL_NAME; +} + +function isStatusActionSubType(msgSubType) { + return new Set([ + MSG_SUBTYPE_STATUS_DONE_ONCE, + MSG_SUBTYPE_STATUS_LEARNED, + MSG_SUBTYPE_STATUS_SERVICE_PASSED, + MSG_SUBTYPE_STATUS_CONFIRMED, + MSG_SUBTYPE_STATUS_INTERESTED, + MSG_SUBTYPE_STATUS_STARTED, + MSG_SUBTYPE_STATUS_IN_STUDY, + MSG_SUBTYPE_STATUS_ABANDONED, + MSG_SUBTYPE_STATUS_COMPLETED, + ]).has(Number(msgSubType || 0)); +} + +function getStatusActionTypeMeta(statusSubType, targetMsgSubType = 0) { + const status = Number(statusSubType || 0); + const target = Number(targetMsgSubType || 0); + if (status === MSG_SUBTYPE_STATUS_DONE_ONCE && target === MSG_SUBTYPE_TEXT_EXERCISE) return { label: 'Выполнено' }; + if (status === MSG_SUBTYPE_STATUS_LEARNED && target === MSG_SUBTYPE_TEXT_EXERCISE) return { label: 'Изучено' }; + if (status === MSG_SUBTYPE_STATUS_SERVICE_PASSED && target === MSG_SUBTYPE_TEXT_SERVICE) return { label: 'Пройдено' }; + if (status === MSG_SUBTYPE_STATUS_INTERESTED) return { label: 'Заинтересовался' }; + if (status === MSG_SUBTYPE_STATUS_STARTED) return { label: 'Начал' }; + if (status === MSG_SUBTYPE_STATUS_IN_STUDY) return { label: 'Изучаю' }; + if (status === MSG_SUBTYPE_STATUS_ABANDONED) return { label: 'Бросил' }; + if (status === MSG_SUBTYPE_STATUS_COMPLETED) return { label: 'Завершил' }; + if (status === MSG_SUBTYPE_STATUS_CONFIRMED) return { label: 'Подтверждено' }; + return { label: 'Действие' }; +} + +function getStatusActionOptionsForTarget(targetMsgSubType) { + switch (Number(targetMsgSubType || 0)) { + case MSG_SUBTYPE_TEXT_EXERCISE: + return [ + { subType: MSG_SUBTYPE_STATUS_DONE_ONCE, label: 'Выполнено', modalTitle: 'Упражнение выполнено' }, + { subType: MSG_SUBTYPE_STATUS_LEARNED, label: 'Изучено', modalTitle: 'Упражнение изучено' }, + ]; + case MSG_SUBTYPE_TEXT_SERVICE: + return [ + { subType: MSG_SUBTYPE_STATUS_SERVICE_PASSED, label: 'Пройдено', modalTitle: 'Услуга пройдена' }, + ]; + case MSG_SUBTYPE_TEXT_COURSE: + return [ + { subType: MSG_SUBTYPE_STATUS_INTERESTED, label: 'Заинтересовался', modalTitle: 'Курс заинтересовал' }, + { subType: MSG_SUBTYPE_STATUS_STARTED, label: 'Начал', modalTitle: 'Курс начат' }, + { subType: MSG_SUBTYPE_STATUS_IN_STUDY, label: 'Изучаю', modalTitle: 'Курс изучается' }, + { subType: MSG_SUBTYPE_STATUS_COMPLETED, label: 'Завершил', modalTitle: 'Курс завершён' }, + { subType: MSG_SUBTYPE_STATUS_ABANDONED, label: 'Бросил', modalTitle: 'Курс брошен' }, + ]; + default: + return []; + } +} + function createChannelAvatarElement(channel, size = 72) { const wrap = document.createElement('div'); wrap.className = 'channel-profile-avatar'; @@ -674,6 +742,97 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply' }) { if (textEl) textEl.focus(); } +function openStatusActionCommentModal({ title, submitLabel, onSubmit }) { + const root = document.getElementById('modal-root'); + root.innerHTML = ` + + `; + + const textEl = root.querySelector('#channel-status-action-text'); + const errorEl = root.querySelector('#channel-status-action-error'); + const submitEl = root.querySelector('#channel-status-action-submit'); + let inFlight = false; + + const setBusy = (busy) => { + inFlight = !!busy; + if (textEl) textEl.disabled = inFlight; + if (submitEl) { + submitEl.disabled = inFlight; + submitEl.textContent = inFlight ? 'Сохраняем...' : (submitLabel || 'Сохранить'); + } + }; + + const close = () => { + root.innerHTML = ''; + }; + + root.querySelector('#channel-status-action-cancel')?.addEventListener('click', close); + submitEl?.addEventListener('click', async () => { + if (inFlight) return; + setBusy(true); + errorEl.textContent = ''; + try { + await onSubmit(String(textEl?.value || '').trim()); + close(); + } catch (error) { + setBusy(false); + errorEl.textContent = toUserMessage(error, 'Не удалось сохранить действие.'); + } + }); + + bindSubmitOnPlainEnter(textEl, () => submitEl?.click()); + if (textEl) textEl.focus(); +} + +function openStatusActionMenuModal({ targetLabel, options = [], onSelect }) { + const root = document.getElementById('modal-root'); + const rows = (Array.isArray(options) ? options : []) + .map((item, index) => ` + + `) + .join(''); + + root.innerHTML = ` + + `; + + const close = () => { + root.innerHTML = ''; + }; + + root.querySelector('#channel-status-menu-cancel')?.addEventListener('click', close); + root.querySelectorAll('[data-status-index]').forEach((button) => { + button.addEventListener('click', async (event) => { + const idx = Number(event.currentTarget?.dataset?.statusIndex || -1); + const option = options[idx]; + if (!option) return; + close(); + await onSelect(option); + }); + }); +} + function openRepostModal({ navigate, channels = [], onSubmit }) { const root = document.getElementById('modal-root'); const options = (Array.isArray(channels) ? channels : []) @@ -761,7 +920,7 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) { @@ -943,6 +1102,8 @@ function mapApiMessageToPost(message, selector, localNumber) { const hasRef = !!(messageBch && blockNumber != null && blockHash); const resolvedText = resolveMessageText(message); + const msgSubType = Number(message?.msgSubType || 0); + const isStatusAction = isStatusActionSubType(msgSubType); const messageRef = hasRef ? { blockchainName: messageBch, @@ -958,7 +1119,7 @@ function mapApiMessageToPost(message, selector, localNumber) { return { localNumber, authorLogin: message?.authorLogin || 'автор', - body: resolvedText || (Number(message?.versionsTotal || 1) > 1 ? 'удалено' : '(пусто)'), + body: resolvedText || (isStatusAction ? '' : (Number(message?.versionsTotal || 1) > 1 ? 'удалено' : '(пусто)')), versionsTotal: Number(message?.versionsTotal || 1), versions: Array.isArray(message?.versions) ? message.versions : [], likesCount: Number(message?.likesCount || 0), @@ -967,8 +1128,9 @@ function mapApiMessageToPost(message, selector, localNumber) { timestampMs: resolveMessageTimestampMs(message), messageRef, rawMessage: message, - msgSubType: Number(message?.msgSubType || 0), - isRating: Number(message?.msgSubType || 0) === MSG_SUBTYPE_TEXT_RATING, + msgSubType, + isRating: msgSubType === MSG_SUBTYPE_TEXT_RATING, + isStatusAction, targetRef: message?.targetBlockchainName && Number.isFinite(Number(message?.targetBlockNumber)) ? { blockchainName: String(message.targetBlockchainName).trim(), @@ -976,6 +1138,11 @@ function mapApiMessageToPost(message, selector, localNumber) { blockHash: normalizeMessageHash(message?.targetBlockHash), } : null, + targetMsgSubType: Number(message?.targetMsgSubType || 0), + targetText: String(message?.targetText || '').trim(), + targetAuthorLogin: String(message?.targetAuthorLogin || '').trim(), + targetAuthorBlockchainName: String(message?.targetAuthorBlockchainName || '').trim(), + targetCreatedAtMs: Number(message?.targetCreatedAtMs || 0), reactionState: messageRef ? getMessageReactionState(messageRef) : '', isOwnMessage: String(message?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase(), }; @@ -1004,6 +1171,45 @@ async function loadFromApi(route, channelId) { }; let selector = buildSelectorFromRoute(route, channelId); + if (selector?.ownerBlockchainName && selector?.channelName && isDiarySelector(selector)) { + if (!isAuthorized) { + throw new Error('Личный дневник доступен только после входа.'); + } + const diaryPayload = await authService.getPersonalDiary(currentSessionLogin, 400, 'asc'); + const diaryMessages = Array.isArray(diaryPayload?.messages) ? diaryPayload.messages : []; + const posts = diaryMessages + .map((message, index) => mapApiMessageToPost(message, selector, index + 1)) + .sort((a, b) => { + const byTime = Number(a?.timestampMs || 0) - Number(b?.timestampMs || 0); + if (byTime !== 0) return byTime; + const aNum = Number(a?.messageRef?.blockNumber || 0); + const bNum = Number(b?.messageRef?.blockNumber || 0); + return aNum - bNum; + }) + .map((post, index) => ({ ...post, localNumber: index + 1 })); + + return { + channel: { + name: diaryPayload?.channel?.channelName || DIARY_CHANNEL_NAME, + displayTitle: String(diaryPayload?.channel?.displayName || DIARY_CHANNEL_DISPLAY_NAME).trim(), + displayName: DIARY_CHANNEL_DISPLAY_NAME, + description: String(diaryPayload?.channel?.channelDescription || '').trim(), + avaAr: '', + avaSha256: '', + avaSize: 0, + metaUpdatedAtMs: 0, + ownerName: currentSessionLogin, + }, + posts, + metaEvents: [], + reverseChannelMissingWarning: '', + isOwnChannel: true, + isSubscribed: true, + isDiary: true, + selector, + }; + } + if (selector?.ownerBlockchainName && selector?.channelName) { const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim(); const routeOwnerNormalized = routeOwnerRaw.toLowerCase(); @@ -1275,6 +1481,7 @@ function renderPostCard(post, { onToggleLike, onReply, onRating, + onStatusAction, onRepost, onShare, onEdit, @@ -1293,6 +1500,8 @@ function renderPostCard(post, { const authorBlock = document.createElement('div'); authorBlock.className = 'channel-message-author'; + const headRow = document.createElement('div'); + headRow.className = 'channel-message-head-row'; const title = document.createElement('div'); title.className = 'channel-message-title author-line'; @@ -1307,13 +1516,6 @@ function renderPostCard(post, { numberEl.textContent = `· #${post.localNumber}`; titleMain.append(loginEl, numberEl); title.append(titleMain); - const typeMeta = getChannelMessageTypeMeta(post.msgSubType); - if (typeMeta) { - const typeChip = document.createElement('span'); - typeChip.className = 'channel-message-type-chip'; - typeChip.textContent = typeMeta.label; - title.append(typeChip); - } const timestamp = document.createElement('div'); timestamp.className = 'channel-message-time'; @@ -1336,6 +1538,25 @@ function renderPostCard(post, { } authorBlock.append(title, timestamp); authorTile.append(avatar, authorBlock); + headRow.append(authorTile); + const typeMeta = getChannelMessageTypeMeta(post.msgSubType); + if (typeMeta) { + const typeButton = document.createElement('button'); + typeButton.type = 'button'; + typeButton.className = 'channel-message-type-button'; + if (!typeMeta.actionable) typeButton.classList.add('is-static'); + typeButton.textContent = typeMeta.label; + if (typeMeta.actionable && typeof onStatusAction === 'function') { + typeButton.addEventListener('click', (event) => { + event.stopPropagation(); + animatePress(event.currentTarget); + onStatusAction(post); + }); + } else { + typeButton.disabled = true; + } + headRow.append(typeButton); + } authorTile.addEventListener('click', (event) => { event.stopPropagation(); const cleanLogin = String(post.authorLogin || '').trim(); @@ -1363,7 +1584,7 @@ function renderPostCard(post, { card.append(deleted); return card; } else { - card.append(authorTile); + card.append(headRow); if (parsedBody.attachments.length > 0) { card.append(createAttachmentCarouselElement(parsedBody.attachments, { gateway: state.entrySettings.arweaveServer, @@ -1376,6 +1597,25 @@ function renderPostCard(post, { ratingBadge.textContent = 'Оценка'; card.append(ratingBadge); } + if (post.isStatusAction) { + const statusBadge = document.createElement('span'); + statusBadge.className = 'channel-message-kind-badge channel-message-kind-badge--status'; + statusBadge.textContent = getStatusActionTypeMeta(post.msgSubType, post.targetMsgSubType).label; + card.append(statusBadge); + if (post.targetText || post.targetAuthorLogin) { + const targetPreview = document.createElement('div'); + targetPreview.className = 'channel-message-target-preview'; + const targetType = getChannelMessageTypeMeta(post.targetMsgSubType)?.label || 'Материал'; + const targetText = String(post.targetText || '').trim(); + const targetAuthor = String(post.targetAuthorLogin || '').trim(); + targetPreview.innerHTML = ` + ${escapeHtml(targetType)} + ${escapeHtml(targetAuthor || 'автор')} +

${escapeHtml(targetText || 'Без текста')}

+ `; + card.append(targetPreview); + } + } const body = document.createElement('p'); body.className = 'channel-message-body'; body.textContent = parsedBody.text; @@ -1607,6 +1847,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) { onToggleLike: handlers.onToggleLike, onReply: handlers.onReply, onRating: handlers.onRating, + onStatusAction: handlers.onStatusAction, onRepost: handlers.onRepost, onShare: handlers.onShare, onEdit: handlers.onEdit, @@ -1620,7 +1861,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) { } else { const empty = document.createElement('div'); empty.className = 'card meta-muted'; - empty.textContent = 'Ждем ваших начинаний'; + empty.textContent = channelData.isDiary + ? 'К сожалению, у вас пока еще ничего нет в личном дневнике.' + : 'Ждем ваших начинаний'; feed.append(empty); } @@ -1634,7 +1877,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) { backButton.textContent = 'Назад к каналам'; backButton.addEventListener('click', () => navigate('channels-list')); - if (channelData.isOwnChannel) { + if (channelData.isDiary) { + screen.append(feed, backButton); + } else if (channelData.isOwnChannel) { screen.append(feed, addMessageButton); } else if (!channelData.isSubscribed && !isStoriesChannel(channelData.channel)) { screen.append(actionButton, feed, backButton); @@ -1642,7 +1887,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) { screen.append(feed, backButton); } - applyPendingScroll(screen, routeKey, channelData.isOwnChannel); + applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary); return () => { // noop }; @@ -1764,6 +2009,35 @@ export function render({ navigate, route }) { rerender(); }; + const onStatusAction = async (post) => { + const options = getStatusActionOptionsForTarget(post?.msgSubType); + const typeMeta = getChannelMessageTypeMeta(post?.msgSubType); + if (!options.length || !typeMeta || !post?.messageRef) return; + openStatusActionMenuModal({ + targetLabel: typeMeta.label, + options, + onSelect: async (option) => { + openStatusActionCommentModal({ + title: option.modalTitle, + submitLabel: option.label, + onSubmit: async (text) => { + const { login, storagePwd } = requireSigningSession(); + await authService.addBlockStatusAction({ + login, + storagePwd, + message: post.messageRef, + text, + statusSubType: option.subType, + }); + softHaptic(14); + showToast(`${option.label} сохранено`); + rerender(); + }, + }); + }, + }); + }; + const loadOwnedChannelsForRepost = async (login) => { const feed = await authService.listSubscriptionsFeed(login, 1000); const rows = Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : []; @@ -1911,7 +2185,7 @@ export function render({ navigate, route }) { channelHeaderButton.onclick = (event) => { animatePress(event.currentTarget); openAboutChannelModal(apiData.channel, { - canEdit: apiData?.isOwnChannel === true && !isStoriesChannel(apiData?.channel), + canEdit: apiData?.isOwnChannel === true && !apiData?.isDiary && !isStoriesChannel(apiData?.channel), onEdit: () => openEditChannelModal({ channel: apiData.channel, onSave: onEditChannelMeta, @@ -1959,6 +2233,14 @@ export function render({ navigate, route }) { throw new Error(toUserMessage(error, 'Не удалось отправить оценку.')); } }, + onStatusAction: async (post) => { + try { + await onStatusAction(post); + showStatus(''); + } catch (error) { + showStatus(toUserMessage(error, 'Не удалось записать действие.')); + } + }, onRepost: async (messageRef) => { try { await onRepost(messageRef); diff --git a/shine-UI/js/pages/channels-list.js b/shine-UI/js/pages/channels-list.js index e0606692..43271ddc 100644 --- a/shine-UI/js/pages/channels-list.js +++ b/shine-UI/js/pages/channels-list.js @@ -21,6 +21,8 @@ const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success'; const MENU_OVERLAY_ID = 'channels-context-menu-overlay'; const CHANNEL_TYPE_STORIES = 0; const CHANNEL_TYPE_PERSONAL = 100; +const DIARY_CHANNEL_NAME = 'diary'; +const DIARY_DISPLAY_NAME = 'Личный дневник'; function cleanChannelMessagePreview(text) { const parsed = parseMessageAttachments(text); @@ -698,11 +700,64 @@ function pullCreateSuccessFlash() { } } -function mapApiFeed(feed, notificationsState) { +function buildDiaryChannelRow(diaryPayload, ownRows = [], notificationsState = {}, index = {}) { + const messages = Array.isArray(diaryPayload?.messages) ? diaryPayload.messages : []; + if (!messages.length) return null; + + const ownerBlockchainName = String( + diaryPayload?.channel?.ownerBlockchainName + || ownRows[0]?.channel?.ownerBlockchainName + || '' + ).trim(); + if (!ownerBlockchainName) return null; + + const ownerLogin = String(diaryPayload?.channel?.ownerLogin || state.session.login || '').trim(); + const lastMessage = messages[messages.length - 1] || null; + const rowId = 'own-diary'; + index[rowId] = { diary: true, payload: diaryPayload }; + + return { + id: rowId, + route: makeShineChannelRoute({ + ownerLogin, + ownerBlockchainName, + channelName: DIARY_CHANNEL_NAME, + }), + ownerName: ownerLogin || 'я', + ownerBlockchainName, + channelRootBlockNumber: 0, + channelRootBlockHash: '0', + avatar: 'Д', + avaAr: '', + title: DIARY_DISPLAY_NAME, + technicalLabel: 'Виртуальная лента ваших действий', + channelName: DIARY_CHANNEL_NAME, + displayTitle: DIARY_DISPLAY_NAME, + channelDescription: 'История действий по упражнениям, услугам и курсам', + channelTypeCode: 900, + channelTypeVersion: 1, + messagePreview: String(lastMessage?.text || '').trim() + ? cleanChannelMessagePreview(lastMessage?.text) + : 'Новая запись в личном дневнике', + messagesCount: messages.length, + unreadCount: 0, + lastMessageAt: Number(lastMessage?.createdAtMs || 0), + isOwnChannel: true, + isSubscribed: false, + notificationsEnabled: notificationsState[rowId] === true, + pending: false, + }; +} + +function mapApiFeed(feed, notificationsState, diaryPayload = null) { const index = {}; const ownChannels = (feed?.ownedChannels || []) .filter(isVisibleChannelSummary) .map((it, idx) => mapApiChannelRow(it, 'own', idx, index, notificationsState)); + const diaryChannel = diaryPayload + ? buildDiaryChannelRow(diaryPayload, feed?.ownedChannels || [], notificationsState, index) + : null; + if (diaryChannel) ownChannels.unshift(diaryChannel); const followedUserChannels = (feed?.followedUsersChannels || []) .filter(isVisibleChannelSummary) .map((it, idx) => mapApiChannelRow(it, 'followedUsers', idx, index, notificationsState)); @@ -1126,7 +1181,13 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) { try { const feed = await authService.listSubscriptionsFeed(state.session.login, 200); - const groups = mapApiFeed(feed, listState.notificationsState); + let diaryPayload = null; + try { + diaryPayload = await authService.getPersonalDiary(state.session.login, 200, 'asc'); + } catch { + diaryPayload = null; + } + const groups = mapApiFeed(feed, listState.notificationsState, diaryPayload); listState.channels = toListModel(groups); setChannelsFeed(feed, groups.index); diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 0f533ba2..b53c860c 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -47,6 +47,7 @@ const MSG_TYPE_TECH = 0; const MSG_TYPE_TEXT = 1; const MSG_TYPE_REACTION = 2; const MSG_TYPE_CONNECTION = 3; +const MSG_TYPE_STATUS_ACTION = 5; const MSG_SUBTYPE_TECH_CREATE_CHANNEL = 1; const MSG_SUBTYPE_TEXT_POST = 10; @@ -60,6 +61,15 @@ const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100; const MSG_SUBTYPE_TEXT_EXERCISE = 110; const MSG_SUBTYPE_TEXT_SERVICE = 120; const MSG_SUBTYPE_TEXT_COURSE = 130; +const MSG_SUBTYPE_STATUS_DONE_ONCE = 10; +const MSG_SUBTYPE_STATUS_LEARNED = 20; +const MSG_SUBTYPE_STATUS_SERVICE_PASSED = 30; +const MSG_SUBTYPE_STATUS_CONFIRMED = 100; +const MSG_SUBTYPE_STATUS_INTERESTED = 110; +const MSG_SUBTYPE_STATUS_STARTED = 120; +const MSG_SUBTYPE_STATUS_IN_STUDY = 130; +const MSG_SUBTYPE_STATUS_ABANDONED = 140; +const MSG_SUBTYPE_STATUS_COMPLETED = 150; const MSG_SUBTYPE_REACTION_LIKE = 1; const MSG_SUBTYPE_REACTION_UNLIKE = 2; const MSG_SUBTYPE_CONNECTION_FOLLOW = 30; @@ -600,6 +610,35 @@ function makeTextRatingBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashH ); } +function makeStatusActionBodyBytes({ toBlockchainName, toBlockNumber, toBlockHashHex, text = '' }) { + const cleanBch = String(toBlockchainName || '').trim(); + if (!cleanBch) throw new Error('toBlockchainName is required for status action'); + + const blockNumber = Number(toBlockNumber); + if (!Number.isFinite(blockNumber) || blockNumber < 0) { + throw new Error('Invalid toBlockNumber for status action'); + } + + const bchBytes = utf8Bytes(cleanBch); + if (bchBytes.length < 1 || bchBytes.length > 255) { + throw new Error('toBlockchainName must be 1..255 bytes'); + } + + const textBytes = utf8Bytes(String(text || '').trim()); + if (textBytes.length > 65535) { + throw new Error('Status action text must be 0..65535 UTF-8 bytes'); + } + + return concatBytes( + int8Byte(bchBytes.length), + bchBytes, + int32Bytes(blockNumber), + hexToBytes(normalizeHex32(toBlockHashHex)), + int16Bytes(textBytes.length), + textBytes + ); +} + function makeTextRepostBodyBytes({ lineCode, prevLineNumber, @@ -1464,6 +1503,13 @@ export class AuthService { return response.payload || {}; } + async getPersonalDiary(login, limit = 200, sort = 'asc') { + const payload = { login: String(login || '').trim(), limit, sort }; + const response = await this.ws.request('GetPersonalDiary', payload); + if (response.status !== 200) throw opError('GetPersonalDiary', response); + return response.payload || {}; + } + async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50, login = '') { const normalizedMessage = { blockchainName: String(message?.blockchainName || '').trim(), @@ -1830,6 +1876,46 @@ export class AuthService { }); } + async addBlockStatusAction({ login, message, text = '', statusSubType, storagePwd }) { + const cleanLogin = String(login || '').trim(); + const cleanText = String(text || '').trim(); + const cleanSubType = Number(statusSubType); + const target = normalizeMessageRefTarget(message, 'status action'); + const allowedStatusSubTypes = new Set([ + MSG_SUBTYPE_STATUS_DONE_ONCE, + MSG_SUBTYPE_STATUS_LEARNED, + MSG_SUBTYPE_STATUS_SERVICE_PASSED, + MSG_SUBTYPE_STATUS_CONFIRMED, + MSG_SUBTYPE_STATUS_INTERESTED, + MSG_SUBTYPE_STATUS_STARTED, + MSG_SUBTYPE_STATUS_IN_STUDY, + MSG_SUBTYPE_STATUS_ABANDONED, + MSG_SUBTYPE_STATUS_COMPLETED, + ]); + if (!allowedStatusSubTypes.has(cleanSubType)) { + throw new Error('Unsupported status action subtype'); + } + const key = `status-action:${cleanLogin}:${cleanSubType}:${target.blockchainName}:${target.blockNumber}:${target.blockHash}:${cleanText}`; + + return this.runWriteLocked(key, async () => { + const bodyBytes = makeStatusActionBodyBytes({ + toBlockchainName: target.blockchainName, + toBlockNumber: target.blockNumber, + toBlockHashHex: target.blockHash, + text: cleanText, + }); + + return this.addBlockSigned({ + login: cleanLogin, + storagePwd, + msgType: MSG_TYPE_STATUS_ACTION, + msgSubType: cleanSubType, + msgVersion: 1, + bodyBytes, + }); + }); + } + async addBlockRepost({ login, channel, message, text, storagePwd }) { const cleanLogin = String(login || '').trim(); if (!cleanLogin) throw new Error('Missing login'); diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index b22c6aec..59dcf394 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -3972,7 +3972,9 @@ textarea.input { display: flex; align-items: center; gap: 12px; - width: 100%; + flex: 1 1 auto; + width: auto; + min-width: 0; padding: 8px 10px; border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.12); @@ -4013,6 +4015,12 @@ textarea.input { min-width: 0; } +.channel-message-head-row { + display: flex; + align-items: stretch; + gap: 8px; +} + .channel-message-title { font-size: 15px; color: #f5f8ff; @@ -4035,6 +4043,38 @@ textarea.input { border: 1px solid rgba(255, 214, 117, 0.28); } +.channel-message-kind-badge--status { + color: #bfe9d1; + background: rgba(38, 92, 62, 0.32); + border: 1px solid rgba(137, 223, 176, 0.26); +} + +.channel-message-target-preview { + display: grid; + gap: 4px; + padding: 9px 11px; + border-radius: 10px; + border: 1px solid rgba(154, 178, 224, 0.16); + background: rgba(13, 22, 39, 0.56); +} + +.channel-message-target-preview strong { + color: #f0d99c; + font-size: 12px; +} + +.channel-message-target-preview span { + color: rgba(188, 208, 244, 0.82); + font-size: 12px; +} + +.channel-message-target-preview p { + margin: 0; + color: #d9e6ff; + font-size: 13px; + line-height: 1.35; +} + .channels-screen .channel-message-card.is-rating, .thread-node-card.is-rating { border-color: rgba(255, 214, 117, 0.34); @@ -4964,7 +5004,8 @@ textarea.input { flex-wrap: wrap; } -.channel-message-type-chip { +.channel-message-type-chip, +.channel-message-type-button { flex: 0 0 auto; display: inline-flex; align-items: center; @@ -4981,6 +5022,19 @@ textarea.input { text-transform: uppercase; } +.channel-message-type-button { + min-width: 82px; + padding: 6px 10px; + border-radius: 10px; + cursor: pointer; +} + +.channel-message-type-button.is-static, +.channel-message-type-button:disabled { + opacity: 1; + cursor: default; +} + .channel-message-tools { display: grid; grid-template-columns: minmax(0, 1fr) auto;