Добавить дневник действий и статусы материалов

This commit is contained in:
AidarKC
2026-08-10 01:34:17 +04:00
parent ee185cf20a
commit 67d63256c2
20 changed files with 881 additions and 90 deletions
@@ -160,11 +160,12 @@ public final class MsgSubType {
/* ===================== STATUS_ACTION (msg_type=5) ===================== */ /* ===================== STATUS_ACTION (msg_type=5) ===================== */
public static final short STATUS_DONE_ONCE = 10; public static final short STATUS_DONE_ONCE = 10;
public static final short STATUS_INTERESTED = 20; public static final short STATUS_LEARNED = 20;
public static final short STATUS_STARTED = 30; public static final short STATUS_SERVICE_PASSED = 30;
public static final short STATUS_IN_STUDY = 40; public static final short STATUS_CONFIRMED = 100;
public static final short STATUS_COMPLETED = 50; public static final short STATUS_INTERESTED = 110;
public static final short STATUS_ABANDONED = 60; public static final short STATUS_STARTED = 120;
public static final short STATUS_LEARNED = 70; public static final short STATUS_IN_STUDY = 130;
public static final short STATUS_CONFIRMED = 80; public static final short STATUS_ABANDONED = 140;
public static final short STATUS_COMPLETED = 150;
} }
@@ -127,13 +127,14 @@ public final class StatusActionBody implements BodyRecord, BodyHasTarget {
private static boolean isSupportedSubType(short subType) { private static boolean isSupportedSubType(short subType) {
int st = subType & 0xFFFF; int st = subType & 0xFFFF;
return st == (MsgSubType.STATUS_DONE_ONCE & 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_INTERESTED & 0xFFFF)
|| st == (MsgSubType.STATUS_STARTED & 0xFFFF) || st == (MsgSubType.STATUS_STARTED & 0xFFFF)
|| st == (MsgSubType.STATUS_IN_STUDY & 0xFFFF) || st == (MsgSubType.STATUS_IN_STUDY & 0xFFFF)
|| st == (MsgSubType.STATUS_COMPLETED & 0xFFFF)
|| st == (MsgSubType.STATUS_ABANDONED & 0xFFFF) || st == (MsgSubType.STATUS_ABANDONED & 0xFFFF)
|| st == (MsgSubType.STATUS_LEARNED & 0xFFFF) || st == (MsgSubType.STATUS_COMPLETED & 0xFFFF);
|| st == (MsgSubType.STATUS_CONFIRMED & 0xFFFF);
} }
private static String readStrictUtf8Len16AllowEmpty(ByteBuffer bb, String fieldName) { private static String readStrictUtf8Len16AllowEmpty(ByteBuffer bb, String fieldName) {
@@ -138,13 +138,14 @@ public final class MsgSubType {
/* ===================== STATUS_ACTION (msg_type=5) ===================== */ /* ===================== STATUS_ACTION (msg_type=5) ===================== */
public static final short STATUS_DONE_ONCE = 10; public static final short STATUS_DONE_ONCE = 10;
public static final short STATUS_INTERESTED = 20; public static final short STATUS_LEARNED = 20;
public static final short STATUS_STARTED = 30; public static final short STATUS_SERVICE_PASSED = 30;
public static final short STATUS_IN_STUDY = 40; public static final short STATUS_CONFIRMED = 100;
public static final short STATUS_COMPLETED = 50; public static final short STATUS_INTERESTED = 110;
public static final short STATUS_ABANDONED = 60; public static final short STATUS_STARTED = 120;
public static final short STATUS_LEARNED = 70; public static final short STATUS_IN_STUDY = 130;
public static final short STATUS_CONFIRMED = 80; public static final short STATUS_ABANDONED = 140;
public static final short STATUS_COMPLETED = 150;
/* ===================== РЕЗЕРВ НА БУДУЩЕЕ ===================== */ /* ===================== РЕЗЕРВ НА БУДУЩЕЕ ===================== */
// Если позже захочешь BLOCK/UNBLOCK — лучше добавить новые значения, // Если позже захочешь BLOCK/UNBLOCK — лучше добавить новые значения,
@@ -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.ChannelNamesStateBootstrapper;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelMessages_Handler; 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_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_GetGroupDialog_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelsCounters_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_GetChannelsCounters_Handler;
import server.logic.ws_protocol.JSON.handlers.channels.Net_ListGroupChats200_Handler; import server.logic.ws_protocol.JSON.handlers.channels.Net_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_GetChannelMessages_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_GetGroupDialog_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_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_ListGroupChats200_Request;
import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request; import server.logic.ws_protocol.JSON.handlers.channels.entyties.Net_ListSubscriptionsFeed_Request;
import server.logic.ws_protocol.JSON.handlers.connections.Net_GetUserConnectionsGraph_Handler; 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("GetFriendsLists", new Net_GetFriendsLists_Handler()),
Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()), Map.entry("ListSubscriptionsFeed", new Net_ListSubscriptionsFeed_Handler()),
Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()), Map.entry("GetChannelMessages", new Net_GetChannelMessages_Handler()),
Map.entry("GetPersonalDiary", new Net_GetPersonalDiary_Handler()),
Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()), Map.entry("GetMessageThread", new Net_GetMessageThread_Handler()),
Map.entry("GetGroupDialog", new Net_GetGroupDialog_Handler()), Map.entry("GetGroupDialog", new Net_GetGroupDialog_Handler()),
Map.entry("ListGroupChats200", new Net_ListGroupChats200_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("GetFriendsLists", Net_GetFriendsLists_Request.class),
Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class), Map.entry("ListSubscriptionsFeed", Net_ListSubscriptionsFeed_Request.class),
Map.entry("GetChannelMessages", Net_GetChannelMessages_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("GetMessageThread", Net_GetMessageThread_Request.class),
Map.entry("GetGroupDialog", Net_GetGroupDialog_Request.class), Map.entry("GetGroupDialog", Net_GetGroupDialog_Request.class),
Map.entry("ListGroupChats200", Net_ListGroupChats200_Request.class), Map.entry("ListGroupChats200", Net_ListGroupChats200_Request.class),
@@ -170,6 +170,7 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
case "repost_disabled" -> "Репосты временно отключены до будущей реализации"; case "repost_disabled" -> "Репосты временно отключены до будущей реализации";
case "entrypoint_edit_forbidden" -> "TEXT_ENTRYPOINT нельзя редактировать через TEXT_EDIT_POST"; case "entrypoint_edit_forbidden" -> "TEXT_ENTRYPOINT нельзя редактировать через TEXT_EDIT_POST";
case "status_confirmed_target_must_be_status_action" -> "STATUS_CONFIRMED должен ссылаться на STATUS_ACTION"; case "status_confirmed_target_must_be_status_action" -> "STATUS_CONFIRMED должен ссылаться на STATUS_ACTION";
case "status_action_target_not_allowed" -> "Этот STATUS_ACTION нельзя ставить на выбранный тип материала";
case "internal_error" -> "Внутренняя ошибка сервера при записи блока"; case "internal_error" -> "Внутренняя ошибка сервера при записи блока";
case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется"; case "chain_resync_in_progress" -> "Цепочка сейчас пересинхронизируется";
default -> "Ошибка: " + code; default -> "Ошибка: " + code;
@@ -405,15 +406,14 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
} }
} }
if ((block.type & 0xFFFF) == 5 if ((block.type & 0xFFFF) == 5) {
&& (block.subType & 0xFFFF) == (MsgSubType.STATUS_CONFIRMED & 0xFFFF)) {
try { try {
String statusError = validateStatusConfirmedTarget(block); String statusError = validateStatusActionTarget(block);
if (statusError != null) { if (statusError != null) {
return new AddBlockResult(WireCodes.Status.BAD_REQUEST, statusError, serverLastNum, serverLastHashHex); return new AddBlockResult(WireCodes.Status.BAD_REQUEST, statusError, serverLastNum, serverLastHashHex);
} }
} catch (Exception e) { } 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); login, blockchainName, block.blockNumber, e);
return new AddBlockResult(WireCodes.Status.INTERNAL_ERROR, "internal_error", serverLastNum, serverLastHashHex); 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; 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"; if (!(block.body instanceof StatusActionBody statusBody)) return "bad_block_body";
String targetBch = statusBody.toBchName(); String targetBch = statusBody.toBchName();
Integer targetBlockNumber = statusBody.toBlockGlobalNumber(); Integer targetBlockNumber = statusBody.toBlockGlobalNumber();
@@ -666,11 +666,32 @@ public final class Net_AddBlock_Handler implements JsonMessageHandler {
if (target == null || target.getBlockHash() == null || !Arrays.equals(target.getBlockHash(), targetHash)) { if (target == null || target.getBlockHash() == null || !Arrays.equals(target.getBlockHash(), targetHash)) {
return null; return null;
} }
int statusSubType = block.subType & 0xFFFF;
if (statusSubType == (MsgSubType.STATUS_CONFIRMED & 0xFFFF)) {
if (target.getMsgType() != 5) { if (target.getMsgType() != 5) {
return "status_confirmed_target_must_be_status_action"; 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 { private ExistingChannelState loadExistingChannelState(String ownerBch, int rootBlockNumber) throws Exception {
try (Connection c = shine.db.DbController.getInstance().getConnection(); try (Connection c = shine.db.DbController.getInstance().getConnection();
@@ -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<Net_GetChannelMessages_Response.MessageItem> 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<Net_GetChannelMessages_Response.MessageItem> loadDiaryItems(Connection c, String canonicalLogin, int limit, boolean asc) throws Exception {
String order = asc ? "ASC" : "DESC";
List<Net_GetChannelMessages_Response.MessageItem> 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);
}
}
}
}
@@ -127,6 +127,11 @@ public class Net_GetChannelMessages_Response extends Net_Response {
private String targetBlockchainName; private String targetBlockchainName;
private Integer targetBlockNumber; private Integer targetBlockNumber;
private String targetBlockHash; private String targetBlockHash;
private Integer targetMsgSubType;
private String targetText;
private String targetAuthorLogin;
private String targetAuthorBlockchainName;
private Long targetCreatedAtMs;
private long createdAtMs; private long createdAtMs;
private String text; private String text;
private int likesCount; private int likesCount;
@@ -159,6 +164,21 @@ public class Net_GetChannelMessages_Response extends Net_Response {
public String getTargetBlockHash() { return targetBlockHash; } public String getTargetBlockHash() { return targetBlockHash; }
public void setTargetBlockHash(String targetBlockHash) { this.targetBlockHash = 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 long getCreatedAtMs() { return createdAtMs; }
public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; } public void setCreatedAtMs(long createdAtMs) { this.createdAtMs = createdAtMs; }
@@ -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; }
}
+2 -2
View File
@@ -1,2 +1,2 @@
client.version=1.5.10 client.version=1.5.11
server.version=1.4.8 server.version=1.4.9
+9 -7
View File
@@ -90,6 +90,7 @@
- `repost_disabled` — репосты временно отключены до будущей реализации - `repost_disabled` — репосты временно отключены до будущей реализации
- `entrypoint_edit_forbidden``TEXT_ENTRYPOINT` нельзя редактировать через `TEXT_EDIT_POST` - `entrypoint_edit_forbidden``TEXT_ENTRYPOINT` нельзя редактировать через `TEXT_EDIT_POST`
- `status_confirmed_target_must_be_status_action``STATUS_CONFIRMED` должен ссылаться на статусный блок - `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` - `bad_channel_meta_line`, `channel_not_found`, `bad_channel_meta_*`, `channel_meta_*_too_long` — ошибки `TEXT_CHANNEL_META`
- `internal_error` - `internal_error`
@@ -144,13 +145,14 @@
6. **STATUS_ACTION (type=5)** 6. **STATUS_ACTION (type=5)**
- `STATUS_DONE_ONCE (10)` - `STATUS_DONE_ONCE (10)`
- `STATUS_INTERESTED (20)` - `STATUS_LEARNED (20)`
- `STATUS_STARTED (30)` - `STATUS_SERVICE_PASSED (30)`
- `STATUS_IN_STUDY (40)` - `STATUS_CONFIRMED (100)`
- `STATUS_COMPLETED (50)` - `STATUS_INTERESTED (110)`
- `STATUS_ABANDONED (60)` - `STATUS_STARTED (120)`
- `STATUS_LEARNED (70)` - `STATUS_IN_STUDY (130)`
- `STATUS_CONFIRMED (80)` - `STATUS_ABANDONED (140)`
- `STATUS_COMPLETED (150)`
## 6. Практические payload-форматы для каналов и вложений ## 6. Практические payload-форматы для каналов и вложений
+28 -3
View File
@@ -14,11 +14,13 @@
3. `GetMessageThread` — отдает дерево обсуждения вокруг конкретного сообщения: 3. `GetMessageThread` — отдает дерево обсуждения вокруг конкретного сообщения:
предки, фокус-сообщение, потомки. предки, фокус-сообщение, потомки.
4. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя. 4. `GetPersonalDiary` — отдает виртуальную ленту `Личный дневник`, собранную из `STATUS_ACTION` текущего пользователя.
5. `ListGroupChats200` — отдает список групповых чатов типа `200`. 5. `GetChannelsCounters` — отдает счетчики разделов каналов для пользователя.
6. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`. 6. `ListGroupChats200` — отдает список групповых чатов типа `200`.
7. `GetGroupDialog` — отдает сообщения конкретного группового чата типа `200`.
> На первом этапе мы **не используем курсоры** (`nextCursor`) и загружаем полные списки. > На первом этапе мы **не используем курсоры** (`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 ## 4) GetChannelsCounters
### Request ### Request
+1
View File
@@ -44,6 +44,7 @@
| `ListSubscriptionsFeed` | `06_Channels_Read_API.md` | лента каналов/подписок | | `ListSubscriptionsFeed` | `06_Channels_Read_API.md` | лента каналов/подписок |
| `GetChannelMessages` | `06_Channels_Read_API.md` | сообщения канала | | `GetChannelMessages` | `06_Channels_Read_API.md` | сообщения канала |
| `GetMessageThread` | `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` | счетчики разделов каналов | | `GetChannelsCounters` | `06_Channels_Read_API.md` | счетчики разделов каналов |
| `ListGroupChats200` | `06_Channels_Read_API.md` | список групповых чатов типа `200` | | `ListGroupChats200` | `06_Channels_Read_API.md` | список групповых чатов типа `200` |
| `GetGroupDialog` | `06_Channels_Read_API.md` | сообщения группового чата типа `200` | | `GetGroupDialog` | `06_Channels_Read_API.md` | сообщения группового чата типа `200` |
@@ -20,7 +20,7 @@
- `type=2` — REACTION: LIKE/UNLIKE. - `type=2` — REACTION: LIKE/UNLIKE.
- `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции. - `type=3` — CONNECTION: FRIEND/CONTACT/FOLLOW/SPOUSE/PARENT/CHILD/SIBLING и обратные операции.
- `type=4` — USER_PARAM: key/value-параметры пользователя. - `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.
## Примечание ## Примечание
+25 -18
View File
@@ -5,29 +5,32 @@
## Подтипы ## Подтипы
1. `subType=10``STATUS_DONE_ONCE` 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` 4. `subType=100``STATUS_CONFIRMED`
- находится в процессе полноценного изучения.
5. `subType=50``STATUS_COMPLETED`
- завершил.
6. `subType=60``STATUS_ABANDONED`
- бросил.
7. `subType=70``STATUS_LEARNED`
- выучил упражнение / комплекс.
8. `subType=80``STATUS_CONFIRMED`
- подтвердил чужой status-блок. - подтвердил чужой 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 ## Формат body
Все `STATUS_ACTION` используют один и тот же бинарный body-формат: Все `STATUS_ACTION` используют один и тот же бинарный body-формат:
@@ -55,6 +58,10 @@
- Текст может быть пустым: основной смысл задаётся самим `subType`. - Текст может быть пустым: основной смысл задаётся самим `subType`.
- Базовые статусы пользователь ставит сам за себя. - Базовые статусы пользователь ставит сам за себя.
- `STATUS_CONFIRMED` ставится другим человеком на конкретный status-блок. - `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`.
## Что не поддерживается ## Что не поддерживается
+14
View File
@@ -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 ## 2026-08-09 19:40:00 +0400
- Базовый коммит-ориентир: `3552e05`. - Базовый коммит-ориентир: `3552e05`.
- Уточнено серверное чтение каналов и тредов для `TEXT_RATING`: - Уточнено серверное чтение каналов и тредов для `TEXT_RATING`:
+25 -11
View File
@@ -235,11 +235,11 @@ function resolveChannelHeadingFromNode(node) {
function getChannelMessageTypeMeta(msgSubType) { function getChannelMessageTypeMeta(msgSubType) {
switch (Number(msgSubType || 0)) { switch (Number(msgSubType || 0)) {
case MSG_SUBTYPE_TEXT_EXERCISE: case MSG_SUBTYPE_TEXT_EXERCISE:
return { label: 'Упражнение' }; return { label: 'Упражнение', actionable: true };
case MSG_SUBTYPE_TEXT_SERVICE: case MSG_SUBTYPE_TEXT_SERVICE:
return { label: 'Процедура' }; return { label: 'Услуга', actionable: true };
case MSG_SUBTYPE_TEXT_COURSE: case MSG_SUBTYPE_TEXT_COURSE:
return { label: 'Курс' }; return { label: 'Курс', actionable: true };
case MSG_SUBTYPE_TEXT_ENTRYPOINT: case MSG_SUBTYPE_TEXT_ENTRYPOINT:
return { label: 'Оглавление' }; return { label: 'Оглавление' };
default: default:
@@ -770,6 +770,8 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const authorTile = document.createElement('button'); const authorTile = document.createElement('button');
authorTile.type = 'button'; authorTile.type = 'button';
authorTile.className = 'channel-message-author-tile'; authorTile.className = 'channel-message-author-tile';
const headRow = document.createElement('div');
headRow.className = 'channel-message-head-row';
const avatar = createThreadAvatar(author); const avatar = createThreadAvatar(author);
@@ -787,13 +789,6 @@ function renderNodeCard(node, heading, handlers, localNumber) {
numberEl.textContent = `· #${localNumber}`; numberEl.textContent = `· #${localNumber}`;
titleMain.append(loginEl, numberEl); titleMain.append(loginEl, numberEl);
title.append(titleMain); 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) { if (versionsTotal > 1) {
const editedMarker = document.createElement('button'); const editedMarker = document.createElement('button');
editedMarker.type = 'button'; editedMarker.type = 'button';
@@ -815,6 +810,25 @@ function renderNodeCard(node, heading, handlers, localNumber) {
timestamp.textContent = node?.createdAtMs ? new Date(node.createdAtMs).toLocaleString() : '—'; timestamp.textContent = node?.createdAtMs ? new Date(node.createdAtMs).toLocaleString() : '—';
authorBlock.append(title, timestamp); authorBlock.append(title, timestamp);
authorTile.append(avatar, authorBlock); 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() === 'удалено'; const isDeletedMessage = String(text || '').trim().toLowerCase() === 'удалено';
@@ -835,7 +849,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
card.append(deleted); card.append(deleted);
return card; return card;
} else { } else {
card.append(authorTile); card.append(headRow);
if (parsedText.attachments.length > 0) { if (parsedText.attachments.length > 0) {
card.append(createAttachmentCarouselElement(parsedText.attachments, { card.append(createAttachmentCarouselElement(parsedText.attachments, {
gateway: state.entrySettings.arweaveServer, gateway: state.entrySettings.arweaveServer,
+301 -19
View File
@@ -43,6 +43,17 @@ const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100;
const MSG_SUBTYPE_TEXT_EXERCISE = 110; const MSG_SUBTYPE_TEXT_EXERCISE = 110;
const MSG_SUBTYPE_TEXT_SERVICE = 120; const MSG_SUBTYPE_TEXT_SERVICE = 120;
const MSG_SUBTYPE_TEXT_COURSE = 130; 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 pendingReactionActions = new Set();
const pendingScrollByRoute = new Map(); const pendingScrollByRoute = new Map();
@@ -296,11 +307,11 @@ function resolveMessageTimestampMs(message) {
function getChannelMessageTypeMeta(msgSubType) { function getChannelMessageTypeMeta(msgSubType) {
switch (Number(msgSubType || 0)) { switch (Number(msgSubType || 0)) {
case MSG_SUBTYPE_TEXT_EXERCISE: case MSG_SUBTYPE_TEXT_EXERCISE:
return { label: 'Упражнение' }; return { label: 'Упражнение', actionable: true };
case MSG_SUBTYPE_TEXT_SERVICE: case MSG_SUBTYPE_TEXT_SERVICE:
return { label: 'Процедура' }; return { label: 'Услуга', actionable: true };
case MSG_SUBTYPE_TEXT_COURSE: case MSG_SUBTYPE_TEXT_COURSE:
return { label: 'Курс' }; return { label: 'Курс', actionable: true };
case MSG_SUBTYPE_TEXT_ENTRYPOINT: case MSG_SUBTYPE_TEXT_ENTRYPOINT:
return { label: 'Оглавление' }; return { label: 'Оглавление' };
default: 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) { function createChannelAvatarElement(channel, size = 72) {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'channel-profile-avatar'; wrap.className = 'channel-profile-avatar';
@@ -674,6 +742,97 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
if (textEl) textEl.focus(); if (textEl) textEl.focus();
} }
function openStatusActionCommentModal({ title, submitLabel, onSubmit }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="channel-status-action-modal">
<div class="modal-card stack">
<h3 class="modal-title">${escapeHtml(title || 'Новое действие')}</h3>
<p class="meta-muted">Если хотите, можете добавить комментарий</p>
<textarea id="channel-status-action-text" class="input" rows="5" maxlength="2000" placeholder="Комментарий"></textarea>
<div class="meta-muted inline-error" id="channel-status-action-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="channel-status-action-cancel" type="button">Отмена</button>
<button class="primary-btn" id="channel-status-action-submit" type="button">${escapeHtml(submitLabel || 'Сохранить')}</button>
</div>
</div>
</div>
`;
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) => `
<button class="channel-menu-item channel-status-action-item" data-status-index="${index}" type="button">
${escapeHtml(item.label || 'Действие')}
</button>
`)
.join('');
root.innerHTML = `
<div class="modal" id="channel-status-menu-modal">
<div class="modal-card stack">
<h3 class="modal-title">${escapeHtml(targetLabel || 'Действия')}</h3>
<div class="stack">${rows}</div>
<div class="form-actions-grid">
<button class="secondary-btn" id="channel-status-menu-cancel" type="button">Закрыть</button>
</div>
</div>
</div>
`;
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 }) { function openRepostModal({ navigate, channels = [], onSubmit }) {
const root = document.getElementById('modal-root'); const root = document.getElementById('modal-root');
const options = (Array.isArray(channels) ? channels : []) const options = (Array.isArray(channels) ? channels : [])
@@ -761,7 +920,7 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
<select id="channel-message-type" class="input channel-message-type-select"> <select id="channel-message-type" class="input channel-message-type-select">
<option value="${10}">Пост</option> <option value="${10}">Пост</option>
<option value="${MSG_SUBTYPE_TEXT_EXERCISE}">Упражнение</option> <option value="${MSG_SUBTYPE_TEXT_EXERCISE}">Упражнение</option>
<option value="${MSG_SUBTYPE_TEXT_SERVICE}">Процедура</option> <option value="${MSG_SUBTYPE_TEXT_SERVICE}">Услуга</option>
<option value="${MSG_SUBTYPE_TEXT_COURSE}">Курс</option> <option value="${MSG_SUBTYPE_TEXT_COURSE}">Курс</option>
<option value="${MSG_SUBTYPE_TEXT_ENTRYPOINT}">Оглавление канала</option> <option value="${MSG_SUBTYPE_TEXT_ENTRYPOINT}">Оглавление канала</option>
</select> </select>
@@ -943,6 +1102,8 @@ function mapApiMessageToPost(message, selector, localNumber) {
const hasRef = !!(messageBch && blockNumber != null && blockHash); const hasRef = !!(messageBch && blockNumber != null && blockHash);
const resolvedText = resolveMessageText(message); const resolvedText = resolveMessageText(message);
const msgSubType = Number(message?.msgSubType || 0);
const isStatusAction = isStatusActionSubType(msgSubType);
const messageRef = hasRef const messageRef = hasRef
? { ? {
blockchainName: messageBch, blockchainName: messageBch,
@@ -958,7 +1119,7 @@ function mapApiMessageToPost(message, selector, localNumber) {
return { return {
localNumber, localNumber,
authorLogin: message?.authorLogin || 'автор', authorLogin: message?.authorLogin || 'автор',
body: resolvedText || (Number(message?.versionsTotal || 1) > 1 ? 'удалено' : '(пусто)'), body: resolvedText || (isStatusAction ? '' : (Number(message?.versionsTotal || 1) > 1 ? 'удалено' : '(пусто)')),
versionsTotal: Number(message?.versionsTotal || 1), versionsTotal: Number(message?.versionsTotal || 1),
versions: Array.isArray(message?.versions) ? message.versions : [], versions: Array.isArray(message?.versions) ? message.versions : [],
likesCount: Number(message?.likesCount || 0), likesCount: Number(message?.likesCount || 0),
@@ -967,8 +1128,9 @@ function mapApiMessageToPost(message, selector, localNumber) {
timestampMs: resolveMessageTimestampMs(message), timestampMs: resolveMessageTimestampMs(message),
messageRef, messageRef,
rawMessage: message, rawMessage: message,
msgSubType: Number(message?.msgSubType || 0), msgSubType,
isRating: Number(message?.msgSubType || 0) === MSG_SUBTYPE_TEXT_RATING, isRating: msgSubType === MSG_SUBTYPE_TEXT_RATING,
isStatusAction,
targetRef: message?.targetBlockchainName && Number.isFinite(Number(message?.targetBlockNumber)) targetRef: message?.targetBlockchainName && Number.isFinite(Number(message?.targetBlockNumber))
? { ? {
blockchainName: String(message.targetBlockchainName).trim(), blockchainName: String(message.targetBlockchainName).trim(),
@@ -976,6 +1138,11 @@ function mapApiMessageToPost(message, selector, localNumber) {
blockHash: normalizeMessageHash(message?.targetBlockHash), blockHash: normalizeMessageHash(message?.targetBlockHash),
} }
: null, : 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) : '', reactionState: messageRef ? getMessageReactionState(messageRef) : '',
isOwnMessage: String(message?.authorLogin || '').trim().toLowerCase() === String(state.session.login || '').trim().toLowerCase(), 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); 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) { if (selector?.ownerBlockchainName && selector?.channelName) {
const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim(); const routeOwnerRaw = String(selector.ownerBlockchainName || '').trim();
const routeOwnerNormalized = routeOwnerRaw.toLowerCase(); const routeOwnerNormalized = routeOwnerRaw.toLowerCase();
@@ -1275,6 +1481,7 @@ function renderPostCard(post, {
onToggleLike, onToggleLike,
onReply, onReply,
onRating, onRating,
onStatusAction,
onRepost, onRepost,
onShare, onShare,
onEdit, onEdit,
@@ -1293,6 +1500,8 @@ function renderPostCard(post, {
const authorBlock = document.createElement('div'); const authorBlock = document.createElement('div');
authorBlock.className = 'channel-message-author'; authorBlock.className = 'channel-message-author';
const headRow = document.createElement('div');
headRow.className = 'channel-message-head-row';
const title = document.createElement('div'); const title = document.createElement('div');
title.className = 'channel-message-title author-line'; title.className = 'channel-message-title author-line';
@@ -1307,13 +1516,6 @@ function renderPostCard(post, {
numberEl.textContent = `· #${post.localNumber}`; numberEl.textContent = `· #${post.localNumber}`;
titleMain.append(loginEl, numberEl); titleMain.append(loginEl, numberEl);
title.append(titleMain); 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'); const timestamp = document.createElement('div');
timestamp.className = 'channel-message-time'; timestamp.className = 'channel-message-time';
@@ -1336,6 +1538,25 @@ function renderPostCard(post, {
} }
authorBlock.append(title, timestamp); authorBlock.append(title, timestamp);
authorTile.append(avatar, authorBlock); 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) => { authorTile.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
const cleanLogin = String(post.authorLogin || '').trim(); const cleanLogin = String(post.authorLogin || '').trim();
@@ -1363,7 +1584,7 @@ function renderPostCard(post, {
card.append(deleted); card.append(deleted);
return card; return card;
} else { } else {
card.append(authorTile); card.append(headRow);
if (parsedBody.attachments.length > 0) { if (parsedBody.attachments.length > 0) {
card.append(createAttachmentCarouselElement(parsedBody.attachments, { card.append(createAttachmentCarouselElement(parsedBody.attachments, {
gateway: state.entrySettings.arweaveServer, gateway: state.entrySettings.arweaveServer,
@@ -1376,6 +1597,25 @@ function renderPostCard(post, {
ratingBadge.textContent = 'Оценка'; ratingBadge.textContent = 'Оценка';
card.append(ratingBadge); 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 = `
<strong>${escapeHtml(targetType)}</strong>
<span>${escapeHtml(targetAuthor || 'автор')}</span>
<p>${escapeHtml(targetText || 'Без текста')}</p>
`;
card.append(targetPreview);
}
}
const body = document.createElement('p'); const body = document.createElement('p');
body.className = 'channel-message-body'; body.className = 'channel-message-body';
body.textContent = parsedBody.text; body.textContent = parsedBody.text;
@@ -1607,6 +1847,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
onToggleLike: handlers.onToggleLike, onToggleLike: handlers.onToggleLike,
onReply: handlers.onReply, onReply: handlers.onReply,
onRating: handlers.onRating, onRating: handlers.onRating,
onStatusAction: handlers.onStatusAction,
onRepost: handlers.onRepost, onRepost: handlers.onRepost,
onShare: handlers.onShare, onShare: handlers.onShare,
onEdit: handlers.onEdit, onEdit: handlers.onEdit,
@@ -1620,7 +1861,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
} else { } else {
const empty = document.createElement('div'); const empty = document.createElement('div');
empty.className = 'card meta-muted'; empty.className = 'card meta-muted';
empty.textContent = 'Ждем ваших начинаний'; empty.textContent = channelData.isDiary
? 'К сожалению, у вас пока еще ничего нет в личном дневнике.'
: 'Ждем ваших начинаний';
feed.append(empty); feed.append(empty);
} }
@@ -1634,7 +1877,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
backButton.textContent = 'Назад к каналам'; backButton.textContent = 'Назад к каналам';
backButton.addEventListener('click', () => navigate('channels-list')); backButton.addEventListener('click', () => navigate('channels-list'));
if (channelData.isOwnChannel) { if (channelData.isDiary) {
screen.append(feed, backButton);
} else if (channelData.isOwnChannel) {
screen.append(feed, addMessageButton); screen.append(feed, addMessageButton);
} else if (!channelData.isSubscribed && !isStoriesChannel(channelData.channel)) { } else if (!channelData.isSubscribed && !isStoriesChannel(channelData.channel)) {
screen.append(actionButton, feed, backButton); screen.append(actionButton, feed, backButton);
@@ -1642,7 +1887,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
screen.append(feed, backButton); screen.append(feed, backButton);
} }
applyPendingScroll(screen, routeKey, channelData.isOwnChannel); applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary);
return () => { return () => {
// noop // noop
}; };
@@ -1764,6 +2009,35 @@ export function render({ navigate, route }) {
rerender(); 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 loadOwnedChannelsForRepost = async (login) => {
const feed = await authService.listSubscriptionsFeed(login, 1000); const feed = await authService.listSubscriptionsFeed(login, 1000);
const rows = Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : []; const rows = Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : [];
@@ -1911,7 +2185,7 @@ export function render({ navigate, route }) {
channelHeaderButton.onclick = (event) => { channelHeaderButton.onclick = (event) => {
animatePress(event.currentTarget); animatePress(event.currentTarget);
openAboutChannelModal(apiData.channel, { openAboutChannelModal(apiData.channel, {
canEdit: apiData?.isOwnChannel === true && !isStoriesChannel(apiData?.channel), canEdit: apiData?.isOwnChannel === true && !apiData?.isDiary && !isStoriesChannel(apiData?.channel),
onEdit: () => openEditChannelModal({ onEdit: () => openEditChannelModal({
channel: apiData.channel, channel: apiData.channel,
onSave: onEditChannelMeta, onSave: onEditChannelMeta,
@@ -1959,6 +2233,14 @@ export function render({ navigate, route }) {
throw new Error(toUserMessage(error, 'Не удалось отправить оценку.')); throw new Error(toUserMessage(error, 'Не удалось отправить оценку.'));
} }
}, },
onStatusAction: async (post) => {
try {
await onStatusAction(post);
showStatus('');
} catch (error) {
showStatus(toUserMessage(error, 'Не удалось записать действие.'));
}
},
onRepost: async (messageRef) => { onRepost: async (messageRef) => {
try { try {
await onRepost(messageRef); await onRepost(messageRef);
+63 -2
View File
@@ -21,6 +21,8 @@ const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
const MENU_OVERLAY_ID = 'channels-context-menu-overlay'; const MENU_OVERLAY_ID = 'channels-context-menu-overlay';
const CHANNEL_TYPE_STORIES = 0; const CHANNEL_TYPE_STORIES = 0;
const CHANNEL_TYPE_PERSONAL = 100; const CHANNEL_TYPE_PERSONAL = 100;
const DIARY_CHANNEL_NAME = 'diary';
const DIARY_DISPLAY_NAME = 'Личный дневник';
function cleanChannelMessagePreview(text) { function cleanChannelMessagePreview(text) {
const parsed = parseMessageAttachments(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 index = {};
const ownChannels = (feed?.ownedChannels || []) const ownChannels = (feed?.ownedChannels || [])
.filter(isVisibleChannelSummary) .filter(isVisibleChannelSummary)
.map((it, idx) => mapApiChannelRow(it, 'own', idx, index, notificationsState)); .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 || []) const followedUserChannels = (feed?.followedUsersChannels || [])
.filter(isVisibleChannelSummary) .filter(isVisibleChannelSummary)
.map((it, idx) => mapApiChannelRow(it, 'followedUsers', idx, index, notificationsState)); .map((it, idx) => mapApiChannelRow(it, 'followedUsers', idx, index, notificationsState));
@@ -1126,7 +1181,13 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
try { try {
const feed = await authService.listSubscriptionsFeed(state.session.login, 200); 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); listState.channels = toListModel(groups);
setChannelsFeed(feed, groups.index); setChannelsFeed(feed, groups.index);
+86
View File
@@ -47,6 +47,7 @@ const MSG_TYPE_TECH = 0;
const MSG_TYPE_TEXT = 1; const MSG_TYPE_TEXT = 1;
const MSG_TYPE_REACTION = 2; const MSG_TYPE_REACTION = 2;
const MSG_TYPE_CONNECTION = 3; const MSG_TYPE_CONNECTION = 3;
const MSG_TYPE_STATUS_ACTION = 5;
const MSG_SUBTYPE_TECH_CREATE_CHANNEL = 1; const MSG_SUBTYPE_TECH_CREATE_CHANNEL = 1;
const MSG_SUBTYPE_TEXT_POST = 10; 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_EXERCISE = 110;
const MSG_SUBTYPE_TEXT_SERVICE = 120; const MSG_SUBTYPE_TEXT_SERVICE = 120;
const MSG_SUBTYPE_TEXT_COURSE = 130; 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_LIKE = 1;
const MSG_SUBTYPE_REACTION_UNLIKE = 2; const MSG_SUBTYPE_REACTION_UNLIKE = 2;
const MSG_SUBTYPE_CONNECTION_FOLLOW = 30; 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({ function makeTextRepostBodyBytes({
lineCode, lineCode,
prevLineNumber, prevLineNumber,
@@ -1464,6 +1503,13 @@ export class AuthService {
return response.payload || {}; 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 = '') { async getMessageThread(message, depthUp = 20, depthDown = 2, limitChildrenPerNode = 50, login = '') {
const normalizedMessage = { const normalizedMessage = {
blockchainName: String(message?.blockchainName || '').trim(), 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 }) { async addBlockRepost({ login, channel, message, text, storagePwd }) {
const cleanLogin = String(login || '').trim(); const cleanLogin = String(login || '').trim();
if (!cleanLogin) throw new Error('Missing login'); if (!cleanLogin) throw new Error('Missing login');
+56 -2
View File
@@ -3972,7 +3972,9 @@ textarea.input {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
width: 100%; flex: 1 1 auto;
width: auto;
min-width: 0;
padding: 8px 10px; padding: 8px 10px;
border-radius: 8px; border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.12);
@@ -4013,6 +4015,12 @@ textarea.input {
min-width: 0; min-width: 0;
} }
.channel-message-head-row {
display: flex;
align-items: stretch;
gap: 8px;
}
.channel-message-title { .channel-message-title {
font-size: 15px; font-size: 15px;
color: #f5f8ff; color: #f5f8ff;
@@ -4035,6 +4043,38 @@ textarea.input {
border: 1px solid rgba(255, 214, 117, 0.28); 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, .channels-screen .channel-message-card.is-rating,
.thread-node-card.is-rating { .thread-node-card.is-rating {
border-color: rgba(255, 214, 117, 0.34); border-color: rgba(255, 214, 117, 0.34);
@@ -4964,7 +5004,8 @@ textarea.input {
flex-wrap: wrap; flex-wrap: wrap;
} }
.channel-message-type-chip { .channel-message-type-chip,
.channel-message-type-button {
flex: 0 0 auto; flex: 0 0 auto;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -4981,6 +5022,19 @@ textarea.input {
text-transform: uppercase; 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 { .channel-message-tools {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;